Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0883b14b | ||
|
|
acc6db901c | ||
|
|
7f463830bd | ||
|
|
6a27fb2959 | ||
|
|
21867c9cd4 | ||
|
|
276821e0b5 | ||
|
|
8028b1ddad | ||
|
|
5abbd818d3 | ||
|
|
43944a7d31 | ||
|
|
8bdc137917 | ||
|
|
02fb15228b | ||
|
|
59a1db9068 | ||
|
|
8e956893ca | ||
|
|
d960e03acf | ||
|
|
99666440cf | ||
|
|
f274c0bde3 | ||
|
|
d1082e55a4 | ||
|
|
af0d1980c6 | ||
|
|
a6183e0ccf | ||
|
|
eea40c69f7 | ||
|
|
13ae773868 | ||
|
|
6f0fcc9d1b | ||
|
|
7d98c0bb1c | ||
|
|
82d77dc10f | ||
|
|
ae1f35f515 | ||
|
|
66f3fd2e07 | ||
|
|
cf346f6aa3 | ||
|
|
34d3aac4b0 | ||
|
|
c22b6ca670 | ||
|
|
fa1b29a99f | ||
|
|
d61238c832 | ||
|
|
28b432058a | ||
|
|
4360ada3e4 | ||
|
|
5336bf3fe2 | ||
|
|
6d5db1cb3a | ||
|
|
9fffaa20a2 | ||
|
|
fd73a4ac92 | ||
|
|
29a6c70fc3 | ||
|
|
96f6c644bd | ||
|
|
7c12bb1e20 | ||
|
|
10e796f956 | ||
|
|
071077f2dc | ||
|
|
afe957674c | ||
|
|
307b098f83 | ||
|
|
95a793dd5a | ||
|
|
a0057911c1 | ||
|
|
93c75b5fef | ||
|
|
53d7e57142 | ||
|
|
2c4a891a89 | ||
|
|
8e0c7759be | ||
|
|
41dc01ea3c | ||
|
|
9c985edb6b | ||
|
|
69ef309cb5 | ||
|
|
f10bf38292 | ||
|
|
02f68b104b | ||
|
|
8bc5a75249 | ||
|
|
97e4cca252 | ||
|
|
18d41b52a2 | ||
|
|
26c0f82edf | ||
|
|
c12436afb0 | ||
|
|
fead885aa4 | ||
|
|
e680bb1548 | ||
|
|
6e82d38249 | ||
|
|
9f63299a1a | ||
|
|
702f31c935 | ||
|
|
08db282a07 | ||
|
|
080a394bb3 | ||
|
|
8fb429e073 | ||
|
|
00a3ff89e4 | ||
|
|
8f3b854559 | ||
|
|
05edb144b2 | ||
|
|
8c173c840b | ||
|
|
b540f44d6c | ||
|
|
7493093a1a | ||
|
|
cf68541520 | ||
|
|
b4ee4413cc | ||
|
|
f214830d0f | ||
|
|
c92070a5a2 | ||
|
|
102ca5403d | ||
|
|
7fd57b88dc | ||
|
|
5f57694148 | ||
|
|
73c97e85d4 | ||
|
|
ccde0c20ab | ||
|
|
d2d3c67144 | ||
|
|
6d5af81f68 | ||
|
|
2e9d4125ed | ||
|
|
56c32beebc | ||
|
|
faa67e0bb6 | ||
|
|
ffebe8e901 | ||
|
|
2cafe2d98e | ||
|
|
d03038d976 | ||
|
|
7e392da78a | ||
|
|
f8f3456b92 | ||
|
|
3b637eefaa | ||
|
|
46e1d28881 | ||
|
|
f23cc8b99f | ||
|
|
6843347dd7 |
26
.env.example
26
.env.example
@@ -8,11 +8,13 @@
|
||||
# - APP STORE
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - HUBSPOT
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
# - TANDEM
|
||||
# - ZOOM
|
||||
# - GIPHY
|
||||
|
||||
# - LICENSE *************************************************************************************************
|
||||
# Set this value to 'agree' to accept our license:
|
||||
@@ -47,10 +49,13 @@ PGSSLMODE=
|
||||
|
||||
# - NEXTAUTH
|
||||
# @see: https://github.com/calendso/calendso/issues/263
|
||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_url
|
||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your NEXT_PUBLIC_WEBAPP_URL
|
||||
# NEXTAUTH_URL='http://localhost:3000'
|
||||
NEXTAUTH_URL=
|
||||
JWT_SECRET='secret'
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||
# You can use: `openssl rand -base64 32` to generate one
|
||||
NEXTAUTH_SECRET=
|
||||
# Used for cross-domain cookie authentication
|
||||
NEXTAUTH_COOKIE_DOMAIN=.example.com
|
||||
|
||||
@@ -83,6 +88,9 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT=
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - E-MAIL SETTINGS *****************************************************************************************
|
||||
@@ -109,6 +117,7 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
# **********************************************************************************************************
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
# - DAILY.CO VIDEO
|
||||
DAILY_API_KEY=
|
||||
DAILY_SCALE_PLAN=''
|
||||
@@ -124,6 +133,12 @@ GOOGLE_API_CREDENTIALS='{}'
|
||||
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
||||
GOOGLE_LOGIN_ENABLED=false
|
||||
|
||||
# - HUBSPOT
|
||||
# Used for the HubSpot integration
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-hubspot-client-id-and-secret
|
||||
HUBSPOT_CLIENT_ID=""
|
||||
HUBSPOT_CLIENT_SECRET=""
|
||||
|
||||
# - OFFICE 365
|
||||
# Used for the Office 365 / Outlook.com Calendar / MS Teams integration
|
||||
# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret
|
||||
@@ -145,7 +160,7 @@ PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||
|
||||
# - TANDEM
|
||||
# Used for the Tandem integration -- contact support@tandem.chat to for API access.
|
||||
# Used for the Tandem integration -- contact support@tandem.chat for API access.
|
||||
TANDEM_CLIENT_ID=""
|
||||
TANDEM_CLIENT_SECRET=""
|
||||
TANDEM_BASE_URL="https://tandem.chat"
|
||||
@@ -155,4 +170,9 @@ TANDEM_BASE_URL="https://tandem.chat"
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
|
||||
# - GIPHY
|
||||
# Used for the Giphy integration
|
||||
# @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key
|
||||
GIPHY_API_KEY=
|
||||
# *********************************************************************************************************
|
||||
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -4,6 +4,10 @@
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
<!-- Please provide a loom video for visual changes to speed up reviews
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete options that are not relevant. -->
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
JWT_SECRET: secret
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,3 +1,7 @@
|
||||
[submodule "apps/admin"]
|
||||
path = apps/admin
|
||||
url = https://github.com/calcom/admin.git
|
||||
branch = main
|
||||
[submodule "apps/api"]
|
||||
path = apps/api
|
||||
url = https://github.com/calcom/api.git
|
||||
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
bailey@cal.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
56
README.md
56
README.md
@@ -102,18 +102,13 @@ Here is what you need to be able to run Cal.
|
||||
cd cal.com
|
||||
```
|
||||
|
||||
1. Copy `apps/web/.env.example` to `apps/web/.env`
|
||||
|
||||
```sh
|
||||
cp apps/web/.env.example apps/web/.env
|
||||
cp packages/prisma/.env.example packages/prisma/.env
|
||||
```
|
||||
|
||||
1. Install packages with yarn
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the .env file.
|
||||
|
||||
#### Quick start with `yarn dx`
|
||||
|
||||
@@ -126,10 +121,10 @@ 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**.
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **trpc**.
|
||||
|
||||
```sh
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
```
|
||||
|
||||
#### Manual setup
|
||||
@@ -196,10 +191,8 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
||||
### E2E-Testing
|
||||
|
||||
```sh
|
||||
# In first terminal. Must run on port 3000.
|
||||
yarn dx
|
||||
# In second terminal
|
||||
yarn workspace @calcom/web test-e2e
|
||||
# In a terminal. Just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
yarn workspace @calcom/web playwright-report
|
||||
@@ -213,7 +206,13 @@ yarn workspace @calcom/web playwright-report
|
||||
git pull
|
||||
```
|
||||
|
||||
2. Apply database migrations by running <b>one of</b> the following commands:
|
||||
1. Check if dependencies got added/updated/removed
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Apply database migrations by running <b>one of</b> the following commands:
|
||||
|
||||
In a development environment, run:
|
||||
|
||||
@@ -229,16 +228,13 @@ yarn workspace @calcom/web playwright-report
|
||||
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
|
||||
in your current `.env`, add them there.
|
||||
1. Check for `.env` variables changes
|
||||
|
||||
For the current version, especially check if the variable `BASE_URL` is present and properly set in your environment, for example:
|
||||
```sh
|
||||
yarn predev
|
||||
```
|
||||
|
||||
```
|
||||
BASE_URL='https://yourdomain.com'
|
||||
```
|
||||
|
||||
4. Start the server. In a development environment, just do:
|
||||
1. Start the server. In a development environment, just do:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
@@ -251,7 +247,7 @@ yarn workspace @calcom/web playwright-report
|
||||
yarn start
|
||||
```
|
||||
|
||||
5. Enjoy the new version.
|
||||
1. Enjoy the new version.
|
||||
<!-- DEPLOYMENT -->
|
||||
|
||||
## Deployment
|
||||
@@ -349,6 +345,7 @@ oauth_config:
|
||||
bot:
|
||||
- chat:write
|
||||
- commands
|
||||
- chat:write.public
|
||||
settings:
|
||||
interactivity:
|
||||
is_enabled: true
|
||||
@@ -391,6 +388,19 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
||||
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.
|
||||
|
||||
### Obtaining HubSpot Client ID and Secret
|
||||
|
||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||
2. From within the home of the Developer account page, go to "Manage apps".
|
||||
3. Click "Create app" button top right.
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot othercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
2
app.json
2
app.json
@@ -18,7 +18,7 @@
|
||||
"description": "Application Key for symmetric encryption and decryption. Must be 32 bytes for AES256 encryption algorithm.",
|
||||
"value": "secret"
|
||||
},
|
||||
"JWT_SECRET": "secret"
|
||||
"NEXTAUTH_SECRET": "secret"
|
||||
},
|
||||
"scripts": {
|
||||
"postdeploy": "cd packages/prisma && npx prisma migrate deploy"
|
||||
|
||||
1
apps/admin
Submodule
1
apps/admin
Submodule
Submodule apps/admin added at cf71a8b47e
2
apps/api
2
apps/api
Submodule apps/api updated: 187b97afa1...6124577bc2
@@ -5,7 +5,7 @@
|
||||
</a>
|
||||
<a href="https://cal.com">Website</a>
|
||||
·
|
||||
<a href="https://github.com/calcom/docs/issues">Community Support</a>
|
||||
<a href="https://github.com/calcom/cal.com/issues">Community Support</a>
|
||||
</div>
|
||||
|
||||
# Cal.com Documentation
|
||||
|
||||
@@ -3,4 +3,14 @@ const withNextra = require("nextra")({
|
||||
themeConfig: "./theme.config.js",
|
||||
unstable_staticImage: true,
|
||||
});
|
||||
module.exports = withNextra();
|
||||
module.exports = withNextra({
|
||||
async rewrites() {
|
||||
return [
|
||||
// This redirects requests recieved at /api to /public-api to workaround nextjs default use of /api.
|
||||
{
|
||||
source: "/api",
|
||||
destination: "/public-api",
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,6 @@ This is also the home of our design system documentation and developer docs.
|
||||
|
||||
If you don't already know what Cal.com is about, please head over to [our website](https://cal.com), where you can learn more about the product before venturing into the documentation.
|
||||
|
||||
Want to help make these docs even better? This site is fully open source, and the source code is available on [GitHub](https://github.com/calcom/docs). You can also click the edit button at the bottom of any page to start editing the source code and start a pull request.
|
||||
Want to help make these docs even better? This site is fully open source, and the source code is available on [GitHub](https://github.com/calcom/cal.com/tree/main/apps/docs). You can also click the edit button at the bottom of any page to start editing the source code and start a pull request.
|
||||
|
||||
<Bleed></Bleed>
|
||||
|
||||
@@ -8,11 +8,9 @@ The Embed allows your website visitors to book a meeting with you directly from
|
||||
|
||||
## Install on any website
|
||||
|
||||
TODO: Mention possibility of installation through tag managers as well
|
||||
|
||||
- _Step-1._ Install the Vanilla JS Snippet
|
||||
|
||||
```javascript
|
||||
```html
|
||||
<script>
|
||||
(function (C, A, L) {
|
||||
let p = function (a, ar) {
|
||||
a.q.push(ar);
|
||||
@@ -40,14 +38,10 @@ TODO: Mention possibility of installation through tag managers as well
|
||||
}
|
||||
p(cal, ar);
|
||||
};
|
||||
})(window, "https://cal.com/embed.js", "init");
|
||||
```
|
||||
|
||||
- _Step-2_. Initialize it
|
||||
|
||||
```javascript
|
||||
Cal("init)
|
||||
```
|
||||
})(window, "https://cal.com/embed.js", "init");
|
||||
Cal("init")
|
||||
</script>
|
||||
```
|
||||
|
||||
## Install with a Framework
|
||||
|
||||
@@ -65,7 +59,7 @@ You can use Vanilla JS Snippet to install
|
||||
|
||||
## Popular ways in which you can embed on your website
|
||||
|
||||
Assuming that you have followed the steps of installing and initializing the snippet, you can add show the embed in following ways:
|
||||
Assuming that you have followed the steps of installing and initializing the snippet, you can show the embed in following ways:
|
||||
|
||||
### Inline
|
||||
|
||||
@@ -74,18 +68,20 @@ Show the embed inline inside a container element. It would take the width and he
|
||||
<details>
|
||||
<summary>_Vanilla JS_</summary>
|
||||
|
||||
```javascript
|
||||
Cal("inline", {
|
||||
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
|
||||
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
|
||||
config: {
|
||||
name: "John Doe", // Prefill Name
|
||||
email: "johndoe@gmail.com", // Prefill Email
|
||||
notes: "Test Meeting", // Prefill Notes
|
||||
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
|
||||
theme: "dark", // "dark" or "light" theme
|
||||
},
|
||||
});
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", {
|
||||
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
|
||||
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
|
||||
config: {
|
||||
name: "John Doe", // Prefill Name
|
||||
email: "johndoe@gmail.com", // Prefill Email
|
||||
notes: "Test Meeting", // Prefill Notes
|
||||
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
|
||||
theme: "dark", // "dark" or "light" theme
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -139,7 +135,6 @@ To show the embed as a popup on clicking an element, simply add `data-cal-link`
|
||||
````
|
||||
|
||||
</details>
|
||||
### Full Screen
|
||||
|
||||
## Supported Instructions
|
||||
|
||||
@@ -149,8 +144,10 @@ Consider an instruction as a function with that name and that would be called wi
|
||||
|
||||
Appends embed inline as the child of the element.
|
||||
|
||||
```javascript
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", { elementOrSelector, calLink });
|
||||
</script>
|
||||
````
|
||||
|
||||
- `elementOrSelector` - Give it either a valid CSS selector or an HTMLElement instance directly
|
||||
@@ -161,8 +158,10 @@ Cal("inline", { elementOrSelector, calLink });
|
||||
|
||||
Configure UI for embed. Make it look part of your webpage.
|
||||
|
||||
```javascript
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", { styles });
|
||||
</script>
|
||||
```
|
||||
|
||||
- `styles` - It supports styling for `body` and `eventTypeListItem`. Right now we support just background on these two.
|
||||
@@ -173,15 +172,18 @@ Usage:
|
||||
|
||||
If you want to open cal link on some action. Make it pop open instantly by preloading it.
|
||||
|
||||
```javascript
|
||||
```html
|
||||
<script>
|
||||
Cal("preload", { calLink });
|
||||
</script>
|
||||
```
|
||||
|
||||
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john]()
|
||||
|
||||
## Actions
|
||||
You can listen to an action that occurs in embedded cal link as follows. You can think of them as DOM events. We are avoiding the term events to not confuse it with Cal Events.
|
||||
```javascript
|
||||
You can listen to an action that occurs in embedded cal link as follows. You can think of them as DOM events. We are avoiding the term "events" to not confuse it with Cal Events.
|
||||
```html
|
||||
<script>
|
||||
Cal("on", {
|
||||
action: "ANY_ACTION_NAME",
|
||||
callback: (e)=>{
|
||||
@@ -191,11 +193,12 @@ Cal("on", {
|
||||
const {data, type, namespace} = e.detail;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Following are the list of supported actions.
|
||||
-
|
||||
| action | description | properties |
|
||||
| Action | Description | Properties |
|
||||
|----------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| eventTypeSelected | When user chooses an event-type from the listing. | eventType:object // Event Type that has been selected" |
|
||||
| bookingSuccessful | When the booking is successfully done. It might not be confirmed. | confirmed: boolean; //Whether confirmation from organizer is pending or not <br/><br/>eventType: "Object for Event Type that has been booked"; <br/><br/>date: string; // Date of Event <br/><br/>duration: number; //Duration of booked Event <br/><br/>organizer: object //Organizer details like name, timezone, email |
|
||||
|
||||
21
apps/docs/pages/integrations/slack.mdx
Normal file
21
apps/docs/pages/integrations/slack.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
Title: Slack
|
||||
---
|
||||
|
||||
# Slack
|
||||
|
||||
## Connecting
|
||||
Connecting the bot is easy - If you are a workspace admin, the install button will add the bot to the workspace and also authorize your account with the bot. If you are a normal user, the install button will connect your Slack account with Cal.com. This will allow you to perform commands in Slack.
|
||||
|
||||
## Commands
|
||||
`/today` - This command will display all meetings you have in your Cal.com profile for the current day. This will send a hidden message (not visible to anyone other than you) to the channel you issued the command in.
|
||||
|
||||
`/create-event` - It will display a modal allowing you to simply create a meeting invite with anyone in Slack. Success/Error information will be displayed in a private direct message from the bot.
|
||||
|
||||
`/links` - This command will post all your Cal.com meeting links into the current Slack channel you are in. **Note**: The bot needs to have permission to talk in the channel you are sending the message in. Otherwise, you won't be able to send your links.
|
||||
|
||||
As this is the beggining stage of our Slack integration, we plan on adding more commands in the future that will further improve your Cal.com experience.
|
||||
|
||||
## Self-Hosted
|
||||
If you are using our self-hosted version, please refer to our documentation in
|
||||
[cal.com/README.md](https://github.com/calcom/cal.com/blob/main/README.md#obtaining-slack-client-id-and-secret-and-signing-secret)
|
||||
@@ -6,6 +6,7 @@
|
||||
"event-types": "Event Types",
|
||||
"teams": "Teams",
|
||||
"integrations": "Integrations",
|
||||
"public-api": "API",
|
||||
"webhooks": "Webhooks",
|
||||
"settings": "Settings",
|
||||
"import": "Import",
|
||||
|
||||
11
apps/docs/pages/public-api.mdx
Normal file
11
apps/docs/pages/public-api.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Bleed from 'nextra-theme-docs/bleed'
|
||||
import Head from "next/head";
|
||||
|
||||
<Bleed full>
|
||||
<Head><title>Public API | Cal.com</title></Head>
|
||||
<iframe src="https://developer.cal.com"
|
||||
width="100%"
|
||||
height="900px"
|
||||
title="Public API | Cal.com"
|
||||
></iframe>
|
||||
</Bleed>
|
||||
1
apps/swagger/.env.example
Normal file
1
apps/swagger/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/docs
|
||||
353
apps/swagger/lib/snippets.ts
Normal file
353
apps/swagger/lib/snippets.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import * as OpenAPISnippet from "openapi-snippet";
|
||||
|
||||
export const requestSnippets = {
|
||||
generators: {
|
||||
curl_bash: {
|
||||
title: "cURL (bash)",
|
||||
syntax: "bash",
|
||||
},
|
||||
curl_powershell: {
|
||||
title: "cURL (PowerShell)",
|
||||
syntax: "powershell",
|
||||
},
|
||||
curl_cmd: {
|
||||
title: "cURL (CMD)",
|
||||
syntax: "bash",
|
||||
},
|
||||
node: {
|
||||
title: "Node",
|
||||
syntax: "node",
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["node"],
|
||||
};
|
||||
// Since swagger-ui-react was not configured to change the request snippets some workarounds required
|
||||
// configuration will be added programatically
|
||||
// Custom Plugin
|
||||
export const SnippedGenerator = {
|
||||
statePlugins: {
|
||||
// extend some internals to gain information about current path, method and spec in the generator function metioned later
|
||||
spec: {
|
||||
wrapSelectors: {
|
||||
requestFor: (ori, system) => (state, path, method) => {
|
||||
return ori(path, method)
|
||||
?.set("spec", state.get("json", {}))
|
||||
?.setIn(["oasPathMethod", "path"], path)
|
||||
?.setIn(["oasPathMethod", "method"], method);
|
||||
},
|
||||
mutatedRequestFor: (ori) => (state, path, method) => {
|
||||
return ori(path, method)
|
||||
?.set("spec", state.get("json", {}))
|
||||
?.setIn(["oasPathMethod", "path"], path)
|
||||
?.setIn(["oasPathMethod", "method"], method);
|
||||
},
|
||||
},
|
||||
},
|
||||
// extend the request snippets core plugin
|
||||
requestSnippets: {
|
||||
wrapSelectors: {
|
||||
// add additional snippet generators here
|
||||
getSnippetGenerators:
|
||||
(ori, system) =>
|
||||
(state, ...args) =>
|
||||
ori(state, ...args)
|
||||
// add node native snippet generator
|
||||
// .set(
|
||||
// // key
|
||||
// "node_native",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "NodeJs Native",
|
||||
// syntax: "javascript",
|
||||
// hostname: "test",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["node_native"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// // Since I don't know why hostname was undefinedundefined, I harcoded it here
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
.set(
|
||||
// key
|
||||
"node_fetch",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "NodeJS",
|
||||
syntax: "javascript",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["node_fetch"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"shell_httpie",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "HTTPie",
|
||||
syntax: "bash",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["shell_httpie"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"php_curl",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "PHP",
|
||||
syntax: "php",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["php_curl"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"java_okhttp",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Java",
|
||||
syntax: "java",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
console.log(spec, oasPathMethod, path, method);
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["java_okhttp"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
// .set(
|
||||
// // key
|
||||
// "java",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "Java (Unirest)",
|
||||
// syntax: "java",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["java"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
// .set(
|
||||
// // key
|
||||
// "c_libcurl",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "C (libcurl) ",
|
||||
// syntax: "bash",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["c_libcurl"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
.set(
|
||||
// key
|
||||
"go_native",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Go",
|
||||
syntax: "bash",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["go_native"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"ruby",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Ruby",
|
||||
syntax: "ruby",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["ruby"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"python",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Python",
|
||||
syntax: "python",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["python"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3,19 +3,21 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "PORT=4200 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.5.1",
|
||||
"isarray": "2.0.5",
|
||||
"next": "12.1.4",
|
||||
"next": "12.1.5",
|
||||
"openapi-snippet": "^0.13.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"swagger-ui-react": "4.8.1"
|
||||
"swagger-ui-react": "4.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "16.11.26",
|
||||
"@types/node": "17.0.27",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/react-dom": "17.0.14",
|
||||
"typescript": "4.6.3"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "highlight.js/styles/default.css";
|
||||
import "swagger-ui-react/swagger-ui.css";
|
||||
|
||||
import "../styles/globals.css";
|
||||
import "../styles/swagger-cal.css";
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />;
|
||||
|
||||
@@ -1,45 +1,21 @@
|
||||
import Head from "next/head";
|
||||
import SwaggerUI from "swagger-ui-react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { SnippedGenerator, requestSnippets } from "@lib/snippets";
|
||||
|
||||
const SwaggerUI: any = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||
|
||||
const requestSnippets = {
|
||||
generators: {
|
||||
curl_bash: {
|
||||
title: "cURL (bash)",
|
||||
syntax: "bash",
|
||||
},
|
||||
curl_powershell: {
|
||||
title: "cURL (PowerShell)",
|
||||
syntax: "powershell",
|
||||
},
|
||||
curl_cmd: {
|
||||
title: "cURL (CMD)",
|
||||
syntax: "bash",
|
||||
},
|
||||
node: {
|
||||
title: "Node",
|
||||
syntax: "node",
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["curl_bash"],
|
||||
// e.g. only show curl bash = ["curl_bash"]
|
||||
};
|
||||
export default function APIDocs() {
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Cal.com - Docs - SwaggerUI</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<SwaggerUI
|
||||
requestSnippets={requestSnippets}
|
||||
requestSnippetsEnabled={true}
|
||||
docExpansion="none"
|
||||
operationsSorter="method"
|
||||
filter={true}
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/api/docs"}
|
||||
/>
|
||||
</div>
|
||||
<SwaggerUI
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/docs"}
|
||||
supportedSubmitMethods={["get", "post", "delete", "patch"]}
|
||||
requestSnippetsEnabled={true}
|
||||
requestSnippets={requestSnippets}
|
||||
plugins={[SnippedGenerator]}
|
||||
tryItOutEnabled={true}
|
||||
syntaxHighlight={true}
|
||||
docExpansion="none"
|
||||
filter={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,11 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -8,14 +8,13 @@ import {
|
||||
} from "@heroicons/react/outline";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
//import NavTabs from "@components/NavTabs";
|
||||
import Shell from "@components/Shell";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
@@ -60,7 +59,29 @@ export default function App({
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price);
|
||||
|
||||
const [installedApp, setInstalledApp] = useState(false);
|
||||
useEffect(() => {
|
||||
async function getInstalledApp(appCredentialType: string) {
|
||||
const queryParam = new URLSearchParams();
|
||||
queryParam.set("app-credential-type", appCredentialType);
|
||||
try {
|
||||
const result = await fetch(`/api/app-store/installed?${queryParam.toString()}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (result.status === 200) {
|
||||
setInstalledApp(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
getInstalledApp(type);
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Shell large>
|
||||
@@ -83,7 +104,7 @@ export default function App({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
||||
{isGlobal ? (
|
||||
{isGlobal || installedApp ? (
|
||||
<Button color="secondary" disabled title="This app is globally installed">
|
||||
{t("installed")}
|
||||
</Button>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AppsShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="mb-12 block lg:hidden">
|
||||
{status === "authenticated" && <NavTabs tabs={tabs} linkProps={{ shallow: true }} />}
|
||||
</div>
|
||||
<main>{children}</main>
|
||||
<main className="pb-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
import { AdminRequired } from "components/ui/AdminRequired";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ElementType, FC } from "react";
|
||||
import React, { ElementType, FC, Fragment } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
interface Props {
|
||||
export interface NavTabProps {
|
||||
tabs: {
|
||||
name: string;
|
||||
href: string;
|
||||
icon?: ElementType;
|
||||
adminRequired?: boolean;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
}
|
||||
|
||||
const NavTabs: FC<Props> = ({ tabs, linkProps }) => {
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="-mb-px flex space-x-2 space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse"
|
||||
aria-label="Tabs">
|
||||
<nav className="-mb-px flex space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const isCurrent = router.asPath === tab.href;
|
||||
const Component = tab.adminRequired ? AdminRequired : Fragment;
|
||||
return (
|
||||
<Link key={tab.name} href={tab.href} {...linkProps}>
|
||||
<a
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Component key={tab.name}>
|
||||
<Link href={tab.href} {...linkProps}>
|
||||
<a
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Component>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import { CreditCardIcon, KeyIcon, LockClosedIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
import NavTabs, { NavTabProps } from "./NavTabs";
|
||||
|
||||
export default function SettingsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
@@ -29,6 +29,12 @@ export default function SettingsShell({ children }: { children: React.ReactNode
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
name: t("admin"),
|
||||
href: "/settings/admin",
|
||||
icon: LockClosedIcon,
|
||||
adminRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MoonIcon,
|
||||
ViewGridIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { SessionContextValue, signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -19,7 +20,6 @@ import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { UserPlan } from "@calcom/prisma/client";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
@@ -40,6 +40,7 @@ import { trpc } from "@lib/trpc";
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import Loader from "@components/Loader";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import ImpersonatingBanner from "@components/ui/ImpersonatingBanner";
|
||||
|
||||
import pkg from "../package.json";
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
@@ -69,7 +70,7 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
||||
router.replace({
|
||||
pathname: "/auth/login",
|
||||
query: {
|
||||
callbackUrl: `${WEBAPP_URL}/${location.pathname}${location.search}`,
|
||||
callbackUrl: `${WEBAPP_URL}${location.pathname}${location.search}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -125,9 +126,10 @@ const Layout = ({
|
||||
status,
|
||||
plan,
|
||||
...props
|
||||
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan }) => {
|
||||
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => {
|
||||
const isEmbed = useIsEmbed();
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useLocale();
|
||||
const navigation = [
|
||||
{
|
||||
@@ -311,6 +313,7 @@ const Layout = ({
|
||||
props.flexChildrenContainer && "flex flex-1 flex-col",
|
||||
!props.large && "py-8"
|
||||
)}>
|
||||
<ImpersonatingBanner />
|
||||
{!!props.backPath && (
|
||||
<div className="mx-3 mb-8 sm:mx-8">
|
||||
<Button
|
||||
@@ -329,10 +332,21 @@ const Layout = ({
|
||||
)}>
|
||||
{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 capitalize tracking-wide text-gray-900">
|
||||
{props.heading}
|
||||
</h1>
|
||||
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<div className="mb-1 h-6 w-24 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className="mb-1 h-6 w-32 animate-pulse rounded-md bg-gray-200"></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
|
||||
{props.heading}
|
||||
</h1>
|
||||
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">
|
||||
{props.subtitle}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{props.CTA && <div className="mb-4 flex-shrink-0">{props.CTA}</div>}
|
||||
</div>
|
||||
@@ -342,7 +356,7 @@ const Layout = ({
|
||||
"px-4 sm:px-6 md:px-8",
|
||||
props.flexChildrenContainer && "flex flex-1 flex-col"
|
||||
)}>
|
||||
{props.children}
|
||||
{!props.isLoading ? props.children : props.customLoader}
|
||||
</div>
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
{status === "authenticated" && (
|
||||
@@ -403,6 +417,7 @@ type LayoutProps = {
|
||||
// use when content needs to expand with flex
|
||||
flexChildrenContainer?: boolean;
|
||||
isPublic?: boolean;
|
||||
customLoader?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Shell(props: LayoutProps) {
|
||||
@@ -423,8 +438,10 @@ export default function Shell(props: LayoutProps) {
|
||||
const i18n = useViewerI18n();
|
||||
const { status } = useSession();
|
||||
|
||||
if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) {
|
||||
// show spinner whilst i18n is loading to avoid language flicker
|
||||
const isLoading =
|
||||
i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
|
||||
<Loader />
|
||||
@@ -437,7 +454,7 @@ export default function Shell(props: LayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||
<MemoizedLayout plan={user?.plan} status={status} {...props} />
|
||||
<MemoizedLayout plan={user?.plan} status={status} {...props} isLoading={isLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ export function Tooltip({
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive.Root
|
||||
delayDuration={150}
|
||||
delayDuration={50}
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content
|
||||
className="-mt-2 rounded-sm bg-black px-1 py-0.5 text-xs text-white shadow-lg"
|
||||
className="slideInBottom -mt-2 rounded-sm bg-black px-1 py-0.5 text-xs text-white shadow-lg"
|
||||
side="top"
|
||||
align="center"
|
||||
{...props}>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function AppStoreCategories({
|
||||
return (
|
||||
<div className="mb-16">
|
||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("popular_categories")}</h2>
|
||||
<div className="grid-col-1 grid gap-3 md:grid-flow-col">
|
||||
<div className="grid-col-1 grid w-full gap-3 overflow-scroll sm:grid-flow-col">
|
||||
{categories.map((category) => (
|
||||
<Link key={category.name} href={"/apps/categories/" + category.name}>
|
||||
<a
|
||||
|
||||
41
apps/web/components/apps/SkeletonLoader.tsx
Normal file
41
apps/web/components/apps/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonAvatar, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<>
|
||||
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100"></div>} />
|
||||
<ul className="-mx-4 animate-pulse divide-y divide-neutral-200 rounded-sm border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between p-3">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-start space-x-2">
|
||||
<SkeletonText width="10" height="10"></SkeletonText>
|
||||
<div className="space-y-2">
|
||||
<SkeletonText height="4" width="32"></SkeletonText>
|
||||
<SkeletonText height="4" width="16"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="32" height="11"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -29,8 +29,15 @@ export default function TwoFactor() {
|
||||
<div className="mx-auto !mt-0 max-w-sm">
|
||||
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
|
||||
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
|
||||
<div className="flex flex-row space-x-1">
|
||||
<Input className={className} name="2fa1" inputMode="decimal" {...digits[0]} autoFocus />
|
||||
<div className="flex flex-row justify-between">
|
||||
<Input
|
||||
className={className}
|
||||
name="2fa1"
|
||||
inputMode="decimal"
|
||||
{...digits[0]}
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<Input className={className} name="2fa2" inputMode="decimal" {...digits[1]} />
|
||||
<Input className={className} name="2fa3" inputMode="decimal" {...digits[2]} />
|
||||
<Input className={className} name="2fa4" inputMode="decimal" {...digits[3]} />
|
||||
|
||||
33
apps/web/components/availability/SkeletonLoader.tsx
Normal file
33
apps/web/components/availability/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-2 py-[23px] sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText width="32" height="4"></SkeletonText>
|
||||
<SkeletonText width="32" height="2"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="12" height="6"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline";
|
||||
import { PaperAirplaneIcon } from "@heroicons/react/outline";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
@@ -87,11 +87,13 @@ function BookingListItem(booking: BookingItem) {
|
||||
actions: [
|
||||
{
|
||||
id: "edit",
|
||||
icon: PencilAltIcon,
|
||||
label: t("edit_booking"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
icon: ClockIcon,
|
||||
label: t("send_reschedule_request"),
|
||||
onClick: () => setIsOpenRescheduleDialog(true),
|
||||
},
|
||||
|
||||
39
apps/web/components/booking/SkeletonLoader.tsx
Normal file
39
apps/web/components/booking/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
import BookingsShell from "@components/BookingsShell";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="mt-6 animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-2 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText width="32" height="5" />
|
||||
<SkeletonText width="16" height="4" />
|
||||
</div>
|
||||
<SkeletonText width="24" height="5" className="ml-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="16" height="6" />
|
||||
<SkeletonText width="32" height="6" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -18,13 +18,20 @@ import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager } from "@calcom/embed-core";
|
||||
import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
useIsBackgroundTransparent,
|
||||
sdkActionManager,
|
||||
useEmbedType,
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
import { BASE_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
@@ -56,6 +63,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
const { t, i18n } = useLocale();
|
||||
const { contracts } = useContracts();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
let isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
useExposePlanGlobally(plan);
|
||||
useEffect(() => {
|
||||
@@ -146,23 +155,24 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<div>
|
||||
<main
|
||||
className={
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? classNames("m-auto", selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
? classNames(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
}>
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
)}>
|
||||
{isReady && (
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||
"border-bookinglightest rounded-sm md:border",
|
||||
"border-bookinglightest rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
<div className="block items-center sm:flex sm:space-x-4">
|
||||
<div>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
@@ -180,20 +190,22 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
<div className="mt-4 sm:-mt-2">
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
||||
<div className="mt-2 flex gap-2 text-xl font-medium dark:text-gray-100">
|
||||
{eventType.title}
|
||||
<div className="mt-2 gap-2 dark:text-gray-100">
|
||||
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
{eventType?.description && (
|
||||
<p className="mb-2 text-gray-600 dark:text-white">
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<ClockIcon className="mr-[10px] -mt-1 inline-block h-4 w-4" />
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</div>
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
|
||||
@@ -206,6 +218,22 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
</IntlProvider>
|
||||
</div>
|
||||
)}
|
||||
<div className="md:hidden">
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-8 mb-2 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_mobile">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-white">
|
||||
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{typeof booking.startTime === "string" &&
|
||||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,12 +267,12 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
{eventType.title}
|
||||
</h1>
|
||||
{eventType?.description && (
|
||||
<p className="mb-2 text-gray-600 dark:text-white">
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
@@ -262,7 +290,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
)}
|
||||
|
||||
<TimezoneDropdown />
|
||||
{previousPage === `${BASE_URL}/${profile.slug}` && (
|
||||
{previousPage === `${WEBAPP_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"
|
||||
@@ -273,7 +301,9 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
)}
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mt-8 mb-2 text-gray-600 dark:text-white" data-testid="former_time_p">
|
||||
<p
|
||||
className="mt-4 mb-2 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_desktop">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-white">
|
||||
@@ -283,6 +313,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
periodType={eventType?.periodType}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs from "dayjs";
|
||||
@@ -17,8 +18,15 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
import { useMutation } from "react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useIsEmbed, useIsBackgroundTransparent } from "@calcom/embed-core";
|
||||
import {
|
||||
useIsEmbed,
|
||||
useEmbedStyles,
|
||||
useIsBackgroundTransparent,
|
||||
useEmbedType,
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -41,6 +49,7 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
||||
|
||||
import { BookPageProps } from "../../../pages/[user]/book";
|
||||
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
@@ -48,7 +57,7 @@ const PhoneInput = dynamic(
|
||||
() => import("@components/ui/form/PhoneInput")
|
||||
) as unknown as typeof PhoneInputType;
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
|
||||
|
||||
type BookingFormValues = {
|
||||
name: string;
|
||||
@@ -68,9 +77,13 @@ const BookingPage = ({
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
locationLabels,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const router = useRouter();
|
||||
const { contracts } = useContracts();
|
||||
const { data: session } = useSession();
|
||||
@@ -187,8 +200,16 @@ const BookingPage = ({
|
||||
};
|
||||
};
|
||||
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const bookingForm = useForm<BookingFormValues>({
|
||||
defaultValues: defaultValues(),
|
||||
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
||||
});
|
||||
|
||||
const selectedLocation = useWatch({
|
||||
@@ -272,6 +293,8 @@ const BookingPage = ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -298,16 +321,17 @@ const BookingPage = ({
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<main
|
||||
className={classNames(
|
||||
isEmbed ? "mx-auto" : "mx-auto my-0 rounded-sm sm:my-24",
|
||||
"max-w-3xl sm:border sm:dark:border-gray-600"
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "" : "sm:my-24",
|
||||
"my-0 max-w-3xl "
|
||||
)}>
|
||||
{isReady && (
|
||||
<div
|
||||
className={classNames(
|
||||
"overflow-hidden",
|
||||
"main overflow-hidden",
|
||||
isEmbed ? "" : "border border-gray-200",
|
||||
isBackgroundTransparent ? "" : "bg-white dark:border-0 dark:bg-gray-800",
|
||||
"sm:rounded-sm"
|
||||
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
|
||||
"rounded-md sm:border sm:dark:border-gray-600"
|
||||
)}>
|
||||
<div className="px-4 py-5 sm:flex sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
|
||||
@@ -372,7 +396,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<div className="mt-8 sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
@@ -380,7 +404,7 @@ const BookingPage = ({
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
{...bookingForm.register("name")}
|
||||
{...bookingForm.register("name", { required: true })}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
@@ -427,7 +451,6 @@ const BookingPage = ({
|
||||
{...bookingForm.register("locationType", { required: true })}
|
||||
value={location.type}
|
||||
defaultChecked={selectedLocation === location.type}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
|
||||
{locationLabels[location.type]}
|
||||
|
||||
@@ -56,23 +56,32 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
: undefined;
|
||||
const pageSlug = router.query.eventPage || props.options[0].slug;
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
const title: string =
|
||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
||||
const length: number =
|
||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
||||
const description: string =
|
||||
typeof router.query.description === "string" && router.query.description ? router.query.description : "";
|
||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
||||
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
||||
|
||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||
resolver: zodResolver(createEventTypeInput),
|
||||
});
|
||||
const { setValue, watch, register } = form;
|
||||
setValue("title", title);
|
||||
setValue("length", length);
|
||||
setValue("description", description);
|
||||
setValue("slug", slug);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
|
||||
const title: string =
|
||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
||||
const length: number =
|
||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
||||
const description: string =
|
||||
typeof router.query.description === "string" && router.query.description
|
||||
? router.query.description
|
||||
: "";
|
||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
||||
|
||||
setValue("title", title);
|
||||
setValue("length", length);
|
||||
setValue("description", description);
|
||||
setValue("slug", slug);
|
||||
// If query params change, update the form
|
||||
}, [router.isReady, router.query, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name, type }) => {
|
||||
@@ -86,7 +95,7 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
|
||||
const createMutation = trpc.useMutation("viewer.eventTypes.create", {
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.push("/event-types/" + eventType.id);
|
||||
await router.replace("/event-types/" + eventType.id);
|
||||
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
63
apps/web/components/eventtype/SkeletonLoader.tsx
Normal file
63
apps/web/components/eventtype/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { LinkIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-4 flex items-center">
|
||||
<SkeletonAvatar width="8" height="8"></SkeletonAvatar>
|
||||
<div className="space-y-1">
|
||||
<SkeletonText height="4" width="16"></SkeletonText>
|
||||
<SkeletonText height="4" width="24"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div>
|
||||
<SkeletonText width="32" height="5"></SkeletonText>
|
||||
</div>
|
||||
<div className="">
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></ClockIcon>
|
||||
<SkeletonText width="12" height="4"></SkeletonText>
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></UserIcon>
|
||||
<SkeletonText width="16" height="4"></SkeletonText>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
|
||||
<div className="flex justify-between rtl:space-x-reverse">
|
||||
<div className="btn-icon appearance-none">
|
||||
<ExternalLinkIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="btn-icon appearance-none">
|
||||
<LinkIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="btn-icon appearance-none">
|
||||
<DotsHorizontalIcon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Something went wrong"
|
||||
title={t("calendar_error")}
|
||||
message={item.error?.message}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
|
||||
@@ -56,11 +56,11 @@ const ChangePasswordSection = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="py-6 lg:pb-5">
|
||||
<div className="my-3">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-1/2 ltr:mr-2 rtl:ml-2">
|
||||
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
|
||||
@@ -99,9 +99,10 @@ const ChangePasswordSection = () => {
|
||||
</div>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
<div className="flex justify-end py-8">
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
<Button color="secondary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -173,6 +173,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
inputMode="numeric"
|
||||
onInput={(e) => setTotpCode(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,21 +17,25 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
<div className="flex flex-row justify-between truncate pt-9 pl-2">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
|
||||
<Button
|
||||
className="mt-6"
|
||||
type="submit"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")} {t("2fa")}
|
||||
</Button>
|
||||
|
||||
{enableModalOpen && (
|
||||
<EnableTwoFactorModal
|
||||
onEnable={() => {
|
||||
|
||||
14
apps/web/components/ui/AdminRequired.tsx
Normal file
14
apps/web/components/ui/AdminRequired.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { FC, Fragment } from "react";
|
||||
|
||||
type AdminRequiredProps = {
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
};
|
||||
|
||||
export const AdminRequired: FC<AdminRequiredProps> = ({ children, as, ...rest }) => {
|
||||
const session = useSession();
|
||||
|
||||
if (session.data?.user.role !== "ADMIN") return null;
|
||||
const Component = as ?? Fragment;
|
||||
return <Component {...rest}>{children}</Component>;
|
||||
};
|
||||
34
apps/web/components/ui/ImpersonatingBanner.tsx
Normal file
34
apps/web/components/ui/ImpersonatingBanner.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Trans } from "next-i18next";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
||||
type Props = {};
|
||||
|
||||
function ImpersonatingBanner({}: Props) {
|
||||
const { t } = useLocale();
|
||||
const { data } = useSession();
|
||||
|
||||
if (!data?.user.impersonatedByUID) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={
|
||||
<>
|
||||
{t("impersonating_user_warning", { user: data.user.username })}{" "}
|
||||
<Trans i18nKey="impersonating_stop_instructions">
|
||||
<a href="/auth/logout" className="underline">
|
||||
Click Here To stop
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</>
|
||||
}
|
||||
className="mx-4 mb-2 sm:mx-6 md:mx-8"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpersonatingBanner;
|
||||
@@ -22,7 +22,7 @@ export default function ModalContainer(props: Props) {
|
||||
{
|
||||
"sm:w-full sm:max-w-lg ": !props.wide,
|
||||
"sm:w-4xl sm:max-w-4xl": props.wide,
|
||||
"overflow-scroll": props.scroll,
|
||||
"overflow-auto": props.scroll,
|
||||
"!p-0": props.noPadding,
|
||||
}
|
||||
)}>
|
||||
|
||||
@@ -52,6 +52,12 @@ const DropdownActions = ({ actions, actionTrigger }: { actions: ActionType[]; ac
|
||||
};
|
||||
|
||||
const TableActions: FC<Props> = ({ actions }) => {
|
||||
const mobileActions = actions.flatMap((action) => {
|
||||
if (action.actions) {
|
||||
return action.actions;
|
||||
}
|
||||
return action;
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="hidden space-x-2 rtl:space-x-reverse lg:block">
|
||||
@@ -72,12 +78,11 @@ const TableActions: FC<Props> = ({ actions }) => {
|
||||
if (!action.actions) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return <DropdownActions key={action.id} actions={action.actions} actionTrigger={button} />;
|
||||
})}
|
||||
</div>
|
||||
<div className="inline-block text-left lg:hidden">
|
||||
<DropdownActions actions={actions} />
|
||||
<DropdownActions actions={mobileActions} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,11 @@ type Props = {
|
||||
date: Date;
|
||||
onDatesChange?: ((date: Date) => void) | undefined;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
minDate?: Date;
|
||||
};
|
||||
|
||||
export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
||||
export const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => {
|
||||
return (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
@@ -22,6 +24,8 @@ export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
||||
clearIcon={null}
|
||||
calendarIcon={<CalendarIcon className="h-5 w-5 text-gray-500" />}
|
||||
value={date}
|
||||
minDate={minDate}
|
||||
disabled={disabled}
|
||||
onChange={onDatesChange}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function LicenseBanner() {
|
||||
- Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_LICENSE_CONSENT === "agree") {
|
||||
if (process.env.NEXT_PUBLIC_LICENSE_CONSENT === "agree" || process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
150
apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
Normal file
150
apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { ClipboardCopyIcon } from "@heroicons/react/solid";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||
|
||||
import { TApiKeys } from "./ApiKeyListItem";
|
||||
|
||||
export default function ApiKeyDialogForm(props: {
|
||||
title: string;
|
||||
defaultValues?: Omit<TApiKeys, "userId" | "createdAt" | "lastUsedAt"> & { neverExpires: boolean };
|
||||
handleClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const {
|
||||
defaultValues = {
|
||||
note: "",
|
||||
neverExpires: false,
|
||||
expiresAt: dayjs().add(1, "month").toDate(),
|
||||
},
|
||||
} = props;
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [successfulNewApiKeyModal, setSuccessfulNewApiKeyModal] = useState(false);
|
||||
const [apiKeyDetails, setApiKeyDetails] = useState({
|
||||
id: "",
|
||||
hashedKey: "",
|
||||
expiresAt: null as Date | null,
|
||||
note: "" as string | null,
|
||||
neverExpires: false,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
const watchNeverExpires = form.watch("neverExpires");
|
||||
|
||||
return (
|
||||
<>
|
||||
{successfulNewApiKeyModal ? (
|
||||
<>
|
||||
<div className="mb-10">
|
||||
<h2 className="font-semi-bold font-cal mb-2 text-xl tracking-wide text-gray-900">
|
||||
{apiKeyDetails ? t("success_api_key_edited") : t("success_api_key_created")}
|
||||
</h2>
|
||||
<div className="text-sm text-gray-900">
|
||||
<span className="font-semibold">{t("success_api_key_created_bold_tagline")}</span>{" "}
|
||||
{t("you_will_only_view_it_once")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex">
|
||||
<code className="my-2 mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 align-middle font-mono text-gray-800">
|
||||
{apiKey}
|
||||
</code>
|
||||
<Tooltip content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
showToast(t("api_key_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className=" my-2 px-4 text-base">
|
||||
<ClipboardCopyIcon className="mr-2 h-5 w-5 text-neutral-100" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{apiKeyDetails.neverExpires
|
||||
? t("never_expire_key")
|
||||
: `${t("expires")} ${apiKeyDetails?.expiresAt?.toLocaleDateString()}`}
|
||||
</span>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<Form<Omit<TApiKeys, "userId" | "createdAt" | "lastUsedAt"> & { neverExpires: boolean }>
|
||||
form={form}
|
||||
handleSubmit={async (event) => {
|
||||
const apiKey = await utils.client.mutation("viewer.apiKeys.create", event);
|
||||
setApiKey(apiKey);
|
||||
setApiKeyDetails({ ...event });
|
||||
await utils.invalidateQueries(["viewer.apiKeys.list"]);
|
||||
setSuccessfulNewApiKeyModal(true);
|
||||
}}
|
||||
className="space-y-4">
|
||||
<div className=" mb-10 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">{props.title}</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("api_key_modal_subtitle")}</p>
|
||||
</div>
|
||||
<TextField
|
||||
label={t("personal_note")}
|
||||
placeholder={t("personal_note_placeholder")}
|
||||
{...form.register("note")}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="block text-sm font-medium text-gray-700">{t("expire_date")}</span>
|
||||
<Controller
|
||||
name="neverExpires"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch label={t("never_expire_key")} onCheckedChange={onChange} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="expiresAt"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DatePicker
|
||||
disabled={watchNeverExpires}
|
||||
minDate={new Date()}
|
||||
date={value}
|
||||
onDatesChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{apiKeyDetails ? t("save") : t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
Normal file
77
apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { PlusIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
import ApiKeyDialogForm from "@ee/components/apiKeys/ApiKeyDialogForm";
|
||||
import ApiKeyListItem, { TApiKeys } from "@ee/components/apiKeys/ApiKeyListItem";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { List } from "@components/List";
|
||||
|
||||
export default function ApiKeyListContainer() {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.apiKeys.list"]);
|
||||
|
||||
const [newApiKeyModal, setNewApiKeyModal] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [apiKeyToEdit, setApiKeyToEdit] = useState<(TApiKeys & { neverExpires: boolean }) | null>(null);
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
<div className="flex flex-row justify-between truncate pl-2 pr-1 ">
|
||||
<div className="mt-9">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("api_keys")}</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("api_keys_subtitle")}</p>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<Button StartIcon={PlusIcon} color="secondary" onClick={() => setNewApiKeyModal(true)}>
|
||||
{t("generate_new_api_key")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.length > 0 && (
|
||||
<List className="pb-6">
|
||||
{data.map((item: any) => (
|
||||
<ApiKeyListItem
|
||||
key={item.id}
|
||||
apiKey={item}
|
||||
onEditApiKey={() => {
|
||||
setApiKeyToEdit(item);
|
||||
setEditModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* New api key dialog */}
|
||||
<Dialog open={newApiKeyModal} onOpenChange={(isOpen) => !isOpen && setNewApiKeyModal(false)}>
|
||||
<DialogContent>
|
||||
<ApiKeyDialogForm title={t("create_api_key")} handleClose={() => setNewApiKeyModal(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit api key dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent>
|
||||
{apiKeyToEdit && (
|
||||
<ApiKeyDialogForm
|
||||
title={t("edit_api_key")}
|
||||
key={apiKeyToEdit.id}
|
||||
handleClose={() => setEditModalOpen(false)}
|
||||
defaultValues={apiKeyToEdit}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
Normal file
107
apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { ListItem } from "@components/List";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export type TApiKeys = inferQueryOutput<"viewer.apiKeys.list">[number];
|
||||
|
||||
export default function ApiKeyListItem(props: { apiKey: TApiKeys; onEditApiKey: () => void }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const isExpired = props?.apiKey?.expiresAt ? props.apiKey.expiresAt < new Date() : null;
|
||||
const neverExpires = props?.apiKey?.expiresAt === null;
|
||||
const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.apiKeys.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-x-2">
|
||||
<span className="text-gray-900">
|
||||
{props?.apiKey?.note ? props.apiKey.note : t("api_key_no_note")}
|
||||
</span>
|
||||
{!neverExpires && isExpired && (
|
||||
<Badge className="-p-2" variant="default">
|
||||
{t("expired")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex">
|
||||
<span
|
||||
className={classNames(
|
||||
"flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse",
|
||||
isExpired ? "text-red-600" : "text-gray-500",
|
||||
neverExpires ? "text-yellow-600" : ""
|
||||
)}>
|
||||
{neverExpires ? (
|
||||
<div className="flex flex-row space-x-3 text-gray-500">
|
||||
<ExclamationIcon className="w-4" />
|
||||
{t("api_key_never_expires")}
|
||||
</div>
|
||||
) : (
|
||||
`${isExpired ? t("expired") : t("expires")} ${dayjs(
|
||||
props?.apiKey?.expiresAt?.toString()
|
||||
).fromNow()}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Tooltip content={t("edit_api_key")}>
|
||||
<Button
|
||||
onClick={() => props.onEditApiKey()}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={PencilAltIcon}
|
||||
className="ml-4 w-full self-center p-2"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dialog>
|
||||
<Tooltip content={t("delete_api_key")}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={TrashIcon}
|
||||
className="ml-2 w-full self-center p-2"
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</Tooltip>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("confirm_delete_api_key")}
|
||||
confirmBtnText={t("revoke_api_key")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() =>
|
||||
deleteApiKey.mutate({
|
||||
id: props.apiKey.id,
|
||||
})
|
||||
}>
|
||||
{t("delete_api_key_confirm_title")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CreditCardIcon } from "@heroicons/react/solid";
|
||||
import { Elements } from "@stripe/react-stripe-js";
|
||||
import classNames from "classnames";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
@@ -8,6 +9,7 @@ import Head from "next/head";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core";
|
||||
import getStripe from "@calcom/stripe/client";
|
||||
import PaymentComponent from "@ee/components/stripe/Payment";
|
||||
import { PaymentPageProps } from "@ee/pages/payment/[uid]";
|
||||
@@ -26,16 +28,33 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
||||
const [date, setDate] = useState(dayjs.utc(props.booking.startTime));
|
||||
const { isReady, Theme } = useTheme(props.profile.theme);
|
||||
|
||||
const isEmbed = useIsEmbed();
|
||||
useEffect(() => {
|
||||
let embedIframeWidth = 0;
|
||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
||||
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
|
||||
}, []);
|
||||
if (isEmbed) {
|
||||
requestAnimationFrame(function fixStripeIframe() {
|
||||
// HACK: Look for stripe iframe and center position it just above the embed content
|
||||
const stripeIframeWrapper = document.querySelector(
|
||||
'iframe[src*="https://js.stripe.com/v3/authorize-with-url-inner"]'
|
||||
)?.parentElement;
|
||||
if (stripeIframeWrapper) {
|
||||
stripeIframeWrapper.style.margin = "0 auto";
|
||||
stripeIframeWrapper.style.width = embedIframeWidth + "px";
|
||||
}
|
||||
requestAnimationFrame(fixStripeIframe);
|
||||
});
|
||||
sdkActionManager?.on("__dimensionChanged", (e) => {
|
||||
embedIframeWidth = e.detail.data.iframeWidth as number;
|
||||
});
|
||||
}
|
||||
}, [isEmbed]);
|
||||
|
||||
const eventName = props.booking.title;
|
||||
|
||||
return isReady ? (
|
||||
<div className="h-screen bg-neutral-50 dark:bg-neutral-900">
|
||||
<div className="h-screen">
|
||||
<Theme />
|
||||
<Head>
|
||||
<title>
|
||||
@@ -51,7 +70,10 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className="inline-block transform overflow-hidden rounded-sm border border-neutral-200 bg-white px-8 pt-5 pb-4 text-left align-bottom transition-all dark:border-neutral-700 dark:bg-gray-800 sm:my-8 sm:w-full sm:max-w-lg sm:py-6 sm:align-middle"
|
||||
className={classNames(
|
||||
"main inline-block transform overflow-hidden rounded-lg border border-neutral-200 bg-white px-8 pt-5 pb-4 text-left align-bottom transition-all dark:border-neutral-700 dark:bg-gray-800 sm:w-full sm:max-w-lg sm:py-6 sm:align-middle",
|
||||
isEmbed ? "" : "sm:my-8"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function TeamAvailabilityModal(props: Props) {
|
||||
</div>
|
||||
{props.team && props.member && (
|
||||
<TeamAvailabilityTimes
|
||||
className="overflow-scroll"
|
||||
className="overflow-auto"
|
||||
teamId={props.team.id}
|
||||
memberId={props.member.id}
|
||||
frequency={frequency}
|
||||
|
||||
62
apps/web/ee/lib/impersonation/ImpersonationProvider.ts
Normal file
62
apps/web/ee/lib/impersonation/ImpersonationProvider.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
const ImpersonationProvider = CredentialsProvider({
|
||||
id: "impersonation-auth",
|
||||
name: "Impersonation",
|
||||
type: "credentials",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text " },
|
||||
},
|
||||
async authorize(creds, req) {
|
||||
// @ts-ignore need to figure out how to correctly type this
|
||||
const session = await getSession({ req });
|
||||
if (session?.user.role !== "ADMIN") {
|
||||
throw new Error("You do not have permission to do this.");
|
||||
}
|
||||
|
||||
if (session?.user.username === creds?.username) {
|
||||
throw new Error("You cannot impersonate yourself.");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: creds?.username,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("This user does not exist");
|
||||
}
|
||||
|
||||
// Log impersonations for audit purposes
|
||||
await prisma.impersonations.create({
|
||||
data: {
|
||||
impersonatedBy: {
|
||||
connect: {
|
||||
id: session.user.id,
|
||||
},
|
||||
},
|
||||
impersonatedUser: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const obj = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
impersonatedByUID: session?.user.id,
|
||||
};
|
||||
return obj;
|
||||
},
|
||||
});
|
||||
|
||||
export default ImpersonationProvider;
|
||||
@@ -32,7 +32,9 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
bookingId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment?.bookingId) {
|
||||
console.log(JSON.stringify(paymentIntent), JSON.stringify(payment));
|
||||
}
|
||||
if (!payment?.bookingId) throw new Error("Payment not found");
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
@@ -172,6 +174,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
|
||||
if (event.account) {
|
||||
throw new HttpCode({ statusCode: 202, message: "Incoming connected account" });
|
||||
}
|
||||
|
||||
const handler = webhookHandlers[event.type];
|
||||
if (handler) {
|
||||
await handler(event);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import {
|
||||
QueryObserverIdleResult,
|
||||
QueryObserverLoadingErrorResult,
|
||||
@@ -31,6 +32,7 @@ type JSXElementOrNull = JSX.Element | null;
|
||||
|
||||
interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
|
||||
query: UseQueryResult<TData, TError>;
|
||||
customLoader?: ReactNode;
|
||||
error?: (
|
||||
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
|
||||
) => JSXElementOrNull;
|
||||
@@ -62,7 +64,6 @@ export function QueryCell<TData, TError extends ErrorLike>(
|
||||
opts: QueryCellOptionsNoEmpty<TData, TError> | QueryCellOptionsWithEmpty<TData, TError>
|
||||
) {
|
||||
const { query } = opts;
|
||||
|
||||
if (query.status === "success") {
|
||||
if ("empty" in opts && (query.data == null || (Array.isArray(query.data) && query.data.length === 0))) {
|
||||
return opts.empty(query);
|
||||
@@ -76,11 +77,13 @@ export function QueryCell<TData, TError extends ErrorLike>(
|
||||
)
|
||||
);
|
||||
}
|
||||
const StatusLoader = opts.customLoader || <Loader />; // Fixes edge case where this can return null form query cell
|
||||
|
||||
if (query.status === "loading") {
|
||||
return opts.loading?.(query) ?? <Loader />;
|
||||
return opts.loading?.(query) ?? StatusLoader;
|
||||
}
|
||||
if (query.status === "idle") {
|
||||
return opts.idle?.(query) ?? <Loader />;
|
||||
return opts.idle?.(query) ?? StatusLoader;
|
||||
}
|
||||
// impossible state
|
||||
return null;
|
||||
@@ -108,6 +111,7 @@ const withQuery = <TPath extends keyof TQueryValues & string>(
|
||||
>
|
||||
) {
|
||||
const query = trpc.useQuery(pathAndInput, params);
|
||||
|
||||
return <QueryCell query={query} {...opts} />;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Attendee } from "@prisma/client";
|
||||
import { TFunction } from "next-i18next";
|
||||
|
||||
import { Attendee } from "@calcom/prisma/client";
|
||||
import { Person } from "@calcom/types/Calendar";
|
||||
|
||||
export const attendeeToPersonConversionType = (attendees: Attendee[], t: TFunction): Person[] => {
|
||||
|
||||
85
apps/web/lib/auth/next-auth-custom-adapter.ts
Normal file
85
apps/web/lib/auth/next-auth-custom-adapter.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Account, IdentityProvider, Prisma, PrismaClient, User, VerificationToken } from "@prisma/client";
|
||||
|
||||
import { identityProviderNameMap } from "@lib/auth";
|
||||
|
||||
/** @return { import("next-auth/adapters").Adapter } */
|
||||
export default function CalComAdapter(prismaClient: PrismaClient) {
|
||||
return {
|
||||
createUser: (data: Prisma.UserCreateInput) => prismaClient.user.create({ data }),
|
||||
getUser: (id: User["id"]) => prismaClient.user.findUnique({ where: { id } }),
|
||||
getUserByEmail: (email: User["email"]) => prismaClient.user.findUnique({ where: { email } }),
|
||||
async getUserByAccount(provider_providerAccountId: {
|
||||
providerAccountId: Account["providerAccountId"];
|
||||
provider: User["identityProvider"];
|
||||
}) {
|
||||
let _account;
|
||||
const account = await prismaClient.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId,
|
||||
},
|
||||
select: { user: true },
|
||||
});
|
||||
if (account) {
|
||||
return (_account = account === null || account === void 0 ? void 0 : account.user) !== null &&
|
||||
_account !== void 0
|
||||
? _account
|
||||
: null;
|
||||
}
|
||||
|
||||
// NOTE: this code it's our fallback to users without Account but credentials in User Table
|
||||
// We should remove this code after all googles tokens have expired
|
||||
const provider = provider_providerAccountId?.provider.toUpperCase() as IdentityProvider;
|
||||
if (["GOOGLE", "SAML"].indexOf(provider) < 0) {
|
||||
return null;
|
||||
}
|
||||
const obtainProvider = identityProviderNameMap[provider].toUpperCase() as IdentityProvider;
|
||||
const user = await prismaClient.user.findFirst({
|
||||
where: {
|
||||
identityProviderId: provider_providerAccountId?.providerAccountId,
|
||||
identityProvider: obtainProvider,
|
||||
},
|
||||
});
|
||||
return user || null;
|
||||
},
|
||||
updateUser: ({ id, ...data }: Prisma.UserUncheckedCreateInput) =>
|
||||
prismaClient.user.update({ where: { id }, data }),
|
||||
deleteUser: (id: User["id"]) => prismaClient.user.delete({ where: { id } }),
|
||||
async createVerificationToken(data: VerificationToken) {
|
||||
const { id: _, ...verificationToken } = await prismaClient.verificationToken.create({
|
||||
data,
|
||||
});
|
||||
return verificationToken;
|
||||
},
|
||||
async useVerificationToken(identifier_token: Prisma.VerificationTokenIdentifierTokenCompoundUniqueInput) {
|
||||
try {
|
||||
const { id: _, ...verificationToken } = await prismaClient.verificationToken.delete({
|
||||
where: { identifier_token },
|
||||
});
|
||||
return verificationToken;
|
||||
} catch (error) {
|
||||
// If token already used/deleted, just return null
|
||||
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
|
||||
// @ts-ignore
|
||||
if (error.code === "P2025") return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
linkAccount: (data: Prisma.AccountCreateInput) => prismaClient.account.create({ data }),
|
||||
// @NOTE: All methods below here are not being used but leaved if they are required
|
||||
unlinkAccount: (provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput) =>
|
||||
prismaClient.account.delete({ where: { provider_providerAccountId } }),
|
||||
async getSessionAndUser(sessionToken: string) {
|
||||
const userAndSession = await prismaClient.session.findUnique({
|
||||
where: { sessionToken },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!userAndSession) return null;
|
||||
const { user, ...session } = userAndSession;
|
||||
return { user, session };
|
||||
},
|
||||
createSession: (data: Prisma.SessionCreateInput) => prismaClient.session.create({ data }),
|
||||
updateSession: (data: Prisma.SessionWhereUniqueInput) =>
|
||||
prismaClient.session.update({ where: { sessionToken: data.sessionToken }, data }),
|
||||
deleteSession: (sessionToken: string) => prismaClient.session.delete({ where: { sessionToken } }),
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
|
||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||
import { Attendee } from "@calcom/prisma/client";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
|
||||
@@ -9,10 +9,9 @@ import nodemailer from "nodemailer";
|
||||
import { getAppName } from "@calcom/app-store/utils";
|
||||
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { serverConfig } from "@lib/serverConfig";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
emailSchedulingBodyHeader,
|
||||
@@ -321,7 +320,8 @@ ${getRichDescription(this.calEvent)}
|
||||
}
|
||||
|
||||
protected getLocation(): string {
|
||||
let providerName = this.calEvent.location ? getAppName(this.calEvent.location) : "";
|
||||
let providerName = this.calEvent.location && getAppName(this.calEvent.location);
|
||||
|
||||
if (this.calEvent.location && this.calEvent.location.includes("integrations:")) {
|
||||
const location = this.calEvent.location.split(":")[1];
|
||||
providerName = location[0].toUpperCase() + location.slice(1);
|
||||
|
||||
573
apps/web/lib/emails/templates/confirm-email.html
Normal file
573
apps/web/lib/emails/templates/confirm-email.html
Normal file
@@ -0,0 +1,573 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<!-- <head> -->
|
||||
<title>${headerContent}</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Roboto:400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- </head> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<title></title>
|
||||
<style type="text/css" rel="stylesheet" media="all">
|
||||
/* Base ------------------------------ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css?family=Inter:400,700&display=swap');
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
-webkit-text-size-adjust: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
a {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preheader {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
mso-hide: all;
|
||||
font-size: 1px;
|
||||
line-height: 1px;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Type ------------------------------ */
|
||||
|
||||
body,
|
||||
td,
|
||||
th {
|
||||
font-family: 'Roboto', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: 0.4em 0 1.1875em;
|
||||
font-size: 16px;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
p.sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Utilities ------------------------------ */
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
/* Buttons ------------------------------ */
|
||||
|
||||
.button {
|
||||
background-color: #000;
|
||||
border-top: 10px solid #000;
|
||||
border-right: 18px solid #000;
|
||||
border-bottom: 10px solid #000;
|
||||
border-left: 18px solid #000;
|
||||
display: inline-block;
|
||||
color: #fff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 0;
|
||||
/* box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); */
|
||||
-webkit-text-size-adjust: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
/* Attribute list ------------------------------ */
|
||||
|
||||
.attributes {
|
||||
margin: 0 0 21px;
|
||||
}
|
||||
|
||||
.attributes_content {
|
||||
background-color: #f4f4f7;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.attributes_item {
|
||||
padding: 0;
|
||||
}
|
||||
/* Related Items ------------------------------ */
|
||||
|
||||
.related {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 25px 0 0 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.related_item {
|
||||
padding: 10px 0;
|
||||
color: #cbcccf;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.related_item-title {
|
||||
display: block;
|
||||
margin: 0.5em 0 0;
|
||||
}
|
||||
|
||||
.related_item-thumb {
|
||||
display: block;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.related_heading {
|
||||
border-top: 1px solid #cbcccf;
|
||||
text-align: center;
|
||||
padding: 25px 0 10px;
|
||||
}
|
||||
/* Data table ------------------------------ */
|
||||
|
||||
body {
|
||||
background-color: #f2f4f6;
|
||||
color: #51545e;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #51545e;
|
||||
}
|
||||
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #f2f4f6;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
/* Masthead ----------------------- */
|
||||
|
||||
.email-masthead {
|
||||
padding: 25px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-masthead_logo {
|
||||
width: 94px;
|
||||
}
|
||||
|
||||
.email-masthead_name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #a8aaaf;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 1px 0 white;
|
||||
}
|
||||
/* Body ------------------------------ */
|
||||
|
||||
.email-body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.email-body_inner {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.email-footer {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-footer p {
|
||||
color: #a8aaaf;
|
||||
}
|
||||
|
||||
.body-action {
|
||||
width: 100%;
|
||||
margin: 30px auto;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.body-sub {
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid #eaeaec;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
padding: 45px;
|
||||
}
|
||||
/*Media Queries ------------------------------ */
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-body_inner,
|
||||
.email-footer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body,
|
||||
.email-body,
|
||||
.email-body_inner,
|
||||
.email-content,
|
||||
.email-wrapper,
|
||||
.email-masthead,
|
||||
.email-footer {
|
||||
background-color: #333333 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
span,
|
||||
.purchase_item {
|
||||
color: #fff !important;
|
||||
}
|
||||
.attributes_content {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
.email-masthead_name {
|
||||
text-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
.f-fallback {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<span class="preheader">This link will expire in 10 min.</span>
|
||||
<table
|
||||
class="email-wrapper"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table
|
||||
class="email-content"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<!-- <tr>
|
||||
<td class="email-masthead">
|
||||
<a href="{{base_url}}" class="f-fallback email-masthead_name">
|
||||
Cal.com
|
||||
</a>
|
||||
</td>
|
||||
</tr> -->
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:32px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:89px;">
|
||||
<a href="{{base_url}}" target="_blank">
|
||||
<img height="19" src="https://app.cal.com/emails/CalLogo@2x.png" style="border:0;display:block;outline:none;text-decoration:none;height:19px;width:100%;font-size:13px;" width="89" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Email Body -->
|
||||
<tr>
|
||||
<td
|
||||
class="email-body"
|
||||
width="570"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
>
|
||||
<table
|
||||
class="email-body_inner"
|
||||
align="center"
|
||||
width="570"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<!-- Body content -->
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
<div class="f-fallback">
|
||||
<p>
|
||||
Click the button below to log in to Cal.com<br />
|
||||
This link will expire in 10 minutes.
|
||||
</p>
|
||||
<!-- Action -->
|
||||
<table
|
||||
class="body-action"
|
||||
align="center"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Border based button
|
||||
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="{{signin_url}}"
|
||||
class="f-fallback button"
|
||||
target="_blank"
|
||||
>Log into Cal.com</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
Confirming this request will securely log you in using
|
||||
{{email}}.
|
||||
</p>
|
||||
<p>Enjoy your new scheduling soultion by,<br />The Cal.com Team</p>
|
||||
<!-- Sub copy -->
|
||||
<table class="body-sub" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="f-fallback sub">
|
||||
If you’re having trouble with the button above,
|
||||
copy and paste the URL below into your web
|
||||
browser.
|
||||
</p>
|
||||
<p class="f-fallback sub">{{signin_url}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table
|
||||
class="email-footer"
|
||||
align="center"
|
||||
width="570"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tr>
|
||||
<td class="content-cell" align="center">
|
||||
<p class="f-fallback sub align-center">
|
||||
© 2022 Cal.com. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,8 +2,7 @@ import { TFunction } from "next-i18next";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
|
||||
import { serverConfig } from "@lib/serverConfig";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
|
||||
import { emailHead, linkIcon, emailBodyLogo } from "./common";
|
||||
|
||||
|
||||
@@ -9,10 +9,9 @@ import nodemailer from "nodemailer";
|
||||
import { getAppName } from "@calcom/app-store/utils";
|
||||
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { serverConfig } from "@lib/serverConfig";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
emailSchedulingBodyHeader,
|
||||
@@ -314,7 +313,7 @@ ${getRichDescription(this.calEvent)}
|
||||
}
|
||||
|
||||
protected getLocation(): string {
|
||||
let providerName = this.calEvent.location ? getAppName(this.calEvent.location) : "";
|
||||
let providerName = this.calEvent.location && getAppName(this.calEvent.location); // This returns null if nothing is found
|
||||
|
||||
if (this.calEvent.location && this.calEvent.location.includes("integrations:")) {
|
||||
const location = this.calEvent.location.split(":")[1];
|
||||
|
||||
@@ -2,8 +2,7 @@ import { TFunction } from "next-i18next";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
|
||||
import { serverConfig } from "@lib/serverConfig";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
|
||||
import { emailHead, linkIcon, emailBodyLogo } from "./common";
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { UserPlan } from "@calcom/prisma/client";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* TODO: It should be exposed at a single place.
|
||||
|
||||
@@ -18,6 +18,8 @@ function applyThemeAndAddListener(theme: string) {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.classList.remove("light");
|
||||
document.documentElement.classList.add(theme);
|
||||
}
|
||||
};
|
||||
@@ -33,15 +35,16 @@ export default function useTheme(theme?: Maybe<string>) {
|
||||
const embedTheme = useEmbedTheme();
|
||||
// Embed UI configuration takes more precedence over App Configuration
|
||||
theme = embedTheme || theme;
|
||||
|
||||
const [_theme, setTheme] = useState<Maybe<string>>(null);
|
||||
useEffect(() => {
|
||||
// TODO: isReady doesn't seem required now. This is also impacting PSI Score for pages which are using isReady.
|
||||
setIsReady(true);
|
||||
setTheme(theme);
|
||||
}, []);
|
||||
|
||||
function Theme() {
|
||||
const code = applyThemeAndAddListener.toString();
|
||||
const themeStr = theme ? `"${theme}"` : null;
|
||||
const themeStr = _theme ? `"${_theme}"` : null;
|
||||
return (
|
||||
<Head>
|
||||
<script dangerouslySetInnerHTML={{ __html: `(${code})(${themeStr})` }}></script>
|
||||
|
||||
@@ -27,6 +27,8 @@ export type BookingCreateBody = {
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
hasHashedBookingLink: boolean;
|
||||
hashedLink?: string | null;
|
||||
};
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
|
||||
@@ -80,10 +80,14 @@ const nextConfig = {
|
||||
source: "/:user/avatar.png",
|
||||
destination: "/api/user/avatar?username=:user",
|
||||
},
|
||||
{
|
||||
source: "/team/:teamname/avatar.png",
|
||||
destination: "/api/user/avatar?teamname=:teamname",
|
||||
},
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
const redirects = [
|
||||
{
|
||||
source: "/settings",
|
||||
destination: "/settings/profile",
|
||||
@@ -100,6 +104,28 @@ const nextConfig = {
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||
redirects.push(
|
||||
{
|
||||
source: "/apps/dailyvideo",
|
||||
destination: "/apps/daily-video",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/apps/huddle01_video",
|
||||
destination: "/apps/huddle01",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/apps/jitsi_video",
|
||||
destination: "/apps/jitsi",
|
||||
permanent: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return redirects;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -37,11 +37,12 @@
|
||||
"@calcom/ui": "*",
|
||||
"@daily-co/daily-js": "^0.21.0",
|
||||
"@glidejs/glide": "^3.5.2",
|
||||
"@heroicons/react": "^1.0.5",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/error-message": "^2.0.0",
|
||||
"@hookform/resolvers": "^2.8.5",
|
||||
"@jitsu/sdk-js": "^2.2.4",
|
||||
"@metamask/providers": "^8.1.1",
|
||||
"@next-auth/prisma-adapter": "^1.0.3",
|
||||
"@next/bundle-analyzer": "12.1.0",
|
||||
"@radix-ui/react-avatar": "^0.1.0",
|
||||
"@radix-ui/react-collapsible": "^0.1.0",
|
||||
@@ -76,7 +77,7 @@
|
||||
"micro": "^9.3.4",
|
||||
"mime-types": "^2.1.35",
|
||||
"next": "^12.1.0",
|
||||
"next-auth": "^4.0.6",
|
||||
"next-auth": "^4.3.3",
|
||||
"next-i18next": "^8.9.0",
|
||||
"next-mdx-remote": "^4.0.2",
|
||||
"next-seo": "^4.26.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { BadgeCheckIcon } from "@heroicons/react/solid";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import classNames from "classnames";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
@@ -9,9 +10,10 @@ import React, { useEffect, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { sdkActionManager, useEmbedStyles, useIsEmbed } from "@calcom/embed-core";
|
||||
import { sdkActionManager, useEmbedNonStylesConfig, useEmbedStyles, useIsEmbed } from "@calcom/embed-core";
|
||||
import defaultEvents, {
|
||||
getDynamicEventDescription,
|
||||
getGroupName,
|
||||
getUsernameList,
|
||||
getUsernameSlugLink,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
@@ -23,6 +25,7 @@ import prisma from "@lib/prisma";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
|
||||
@@ -37,7 +40,7 @@ interface EvtsToVerify {
|
||||
}
|
||||
|
||||
export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { users } = props;
|
||||
const { users, profile } = props;
|
||||
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||
const { Theme } = useTheme(user.theme);
|
||||
const { t } = useLocale();
|
||||
@@ -84,11 +87,11 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<div className="mt-1 self-center">
|
||||
<AvatarGroup
|
||||
border="border-2 border-white"
|
||||
truncateAfter={4}
|
||||
className="flex-shrink-0"
|
||||
className="flex flex-shrink-0"
|
||||
size={10}
|
||||
items={props.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
@@ -102,13 +105,15 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
const isEmbed = useIsEmbed();
|
||||
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const query = { ...router.query };
|
||||
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
||||
useExposePlanGlobally("PRO");
|
||||
const nameOrUsername = user.name || user.username || "";
|
||||
const [evtsToVerify, setEvtsToVerify] = useState<EvtsToVerify>({});
|
||||
const isEmbed = useIsEmbed();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,8 +133,17 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
username={isDynamicGroup ? dynamicUsernames.join(", ") : (user.username as string) || ""}
|
||||
// avatar={user.avatar || undefined}
|
||||
/>
|
||||
<div className={"h-screen dark:bg-neutral-900" + isEmbed ? " bg:white m-auto max-w-3xl" : ""}>
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
|
||||
<div className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "max-w-3xl" : "")}>
|
||||
<main
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? " border-bookinglightest rounded-md border bg-white dark:bg-neutral-900 sm:dark:border-gray-600"
|
||||
: "",
|
||||
"max-w-3xl py-24 px-4"
|
||||
)}>
|
||||
{isSingleUser && ( // When we deal with a single user, not dynamic group
|
||||
<div className="mb-8 text-center">
|
||||
<AvatarSSR user={user} className="mx-auto mb-4 h-24 w-24" alt={nameOrUsername}></AvatarSSR>
|
||||
@@ -284,6 +298,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
email: true,
|
||||
name: true,
|
||||
bio: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
plan: true,
|
||||
@@ -298,10 +314,36 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isDynamicGroup = users.length > 1;
|
||||
|
||||
const dynamicNames = isDynamicGroup
|
||||
? users.map((user) => {
|
||||
return user.name || "";
|
||||
})
|
||||
: [];
|
||||
const [user] = users; //to be used when dealing with single user, not dynamic group
|
||||
|
||||
const profile = isDynamicGroup
|
||||
? {
|
||||
name: getGroupName(dynamicNames),
|
||||
image: null,
|
||||
theme: null,
|
||||
weekStart: "Sunday",
|
||||
brandColor: "",
|
||||
darkBrandColor: "",
|
||||
allowDynamicBooking: users.some((user) => {
|
||||
return !user.allowDynamicBooking;
|
||||
})
|
||||
? false
|
||||
: true,
|
||||
}
|
||||
: {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
};
|
||||
const usersIds = users.map((user) => user.id);
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
@@ -337,6 +379,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return {
|
||||
props: {
|
||||
users,
|
||||
profile,
|
||||
user: {
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
},
|
||||
|
||||
@@ -20,7 +20,22 @@ export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type(props: AvailabilityPageProps) {
|
||||
const { t } = useLocale();
|
||||
return props.isDynamicGroup && !props.profile.allowDynamicBooking ? (
|
||||
return props.away ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="p-8 text-center text-gray-400 dark:text-white">
|
||||
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">
|
||||
😴{" " + t("user_away")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : props.isDynamicGroup && !props.profile.allowDynamicBooking ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
@@ -118,6 +133,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
darkBrandColor: true,
|
||||
defaultScheduleId: true,
|
||||
allowDynamicBooking: true,
|
||||
away: true,
|
||||
schedules: {
|
||||
select: {
|
||||
availability: true,
|
||||
@@ -301,6 +317,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
isDynamicGroup,
|
||||
profile,
|
||||
plan: user.plan,
|
||||
|
||||
@@ -30,7 +30,22 @@ export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Book(props: BookPageProps) {
|
||||
const { t } = useLocale();
|
||||
return props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? (
|
||||
return props.away ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||
<div className="p-8 text-center text-gray-400 dark:text-white">
|
||||
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">
|
||||
😴{" " + t("user_away")}
|
||||
</h2>
|
||||
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? (
|
||||
<div className="h-screen dark:bg-neutral-900">
|
||||
<main className="mx-auto max-w-3xl px-4 py-24">
|
||||
<div className="space-y-6" data-testid="event-types">
|
||||
@@ -71,6 +86,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
away: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -190,12 +206,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
locationLabels: getLocationLabels(t),
|
||||
profile,
|
||||
eventType: eventTypeObject,
|
||||
booking,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ type Props = Record<string, unknown> & DocumentProps;
|
||||
class MyDocument extends Document<Props> {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
const isEmbed = ctx.req?.url?.includes("embed");
|
||||
const isEmbed = ctx.req?.url?.includes("embed=");
|
||||
return { ...initialProps, isEmbed };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ class MyDocument extends Document<Props> {
|
||||
</Head>
|
||||
|
||||
{/* Keep the embed hidden till parent initializes and gives it the appropriate styles */}
|
||||
<body className="bg-gray-100 dark:bg-neutral-900" style={props.isEmbed ? { display: "none" } : {}}>
|
||||
<body
|
||||
className={props.isEmbed ? "bg-transparent" : "bg-gray-100 dark:bg-neutral-900"}
|
||||
style={props.isEmbed ? { display: "none" } : {}}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
|
||||
34
apps/web/pages/api/app-store/installed.ts
Normal file
34
apps/web/pages/api/app-store/installed.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
req.session = await getSession({ req });
|
||||
if (req.method === "GET" && req.session && req.session.user.id && req.query) {
|
||||
const { "app-credential-type": appCredentialType } = req.query;
|
||||
if (!appCredentialType && Array.isArray(appCredentialType)) {
|
||||
return res.status(400);
|
||||
}
|
||||
|
||||
const userId = req.session.user.id;
|
||||
try {
|
||||
const installedApp = await prisma?.credential.findFirst({
|
||||
where: {
|
||||
type: appCredentialType as string,
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (installedApp && !!installedApp.key) {
|
||||
res.status(200);
|
||||
} else {
|
||||
res.status(404);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500);
|
||||
}
|
||||
} else {
|
||||
res.status(400);
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
@@ -1,20 +1,35 @@
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import { IdentityProvider, UserPermissionRole } from "@prisma/client";
|
||||
import { readFileSync } from "fs";
|
||||
import Handlebars from "handlebars";
|
||||
import NextAuth, { Session } from "next-auth";
|
||||
import { Provider } from "next-auth/providers";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import nodemailer, { TransportOptions } from "nodemailer";
|
||||
import { authenticator } from "otplib";
|
||||
import path from "path";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import ImpersonationProvider from "@ee/lib/impersonation/ImpersonationProvider";
|
||||
|
||||
import { ErrorCode, verifyPassword } from "@lib/auth";
|
||||
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
|
||||
import prisma from "@lib/prisma";
|
||||
import { randomString } from "@lib/random";
|
||||
import { isSAMLLoginEnabled, samlLoginUrl, hostedCal } from "@lib/saml";
|
||||
import { hostedCal, isSAMLLoginEnabled, samlLoginUrl } from "@lib/saml";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||
|
||||
const transporter = nodemailer.createTransport<TransportOptions>({
|
||||
...(serverConfig.transport as TransportOptions),
|
||||
} as TransportOptions);
|
||||
|
||||
const usernameSlug = (username: string) => slugify(username) + "-" + randomString(6).toLowerCase();
|
||||
|
||||
const providers: Provider[] = [
|
||||
@@ -90,9 +105,11 @@ const providers: Provider[] = [
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
ImpersonationProvider,
|
||||
];
|
||||
|
||||
if (IS_GOOGLE_LOGIN_ENABLED) {
|
||||
@@ -141,15 +158,47 @@ if (isSAMLLoginEnabled) {
|
||||
});
|
||||
}
|
||||
|
||||
if (true) {
|
||||
const emailsDir = path.resolve(process.cwd(), "lib", "emails", "templates");
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
maxAge: 10 * 60 * 60, // Magic links are valid for 10 min only
|
||||
// Here we setup the sendVerificationRequest that calls the email template with the identifier (email) and token to verify.
|
||||
sendVerificationRequest: ({ identifier, url }) => {
|
||||
// Here we add /new endpoint to the callback URL by adding it before &token=.
|
||||
// This is not elegant but it works. We should probably use a different approach when we can.
|
||||
url = url.includes("/auth/new") ? url : url.replace("&token", "/auth/new&token");
|
||||
const emailFile = readFileSync(path.join(emailsDir, "confirm-email.html"), {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const emailTemplate = Handlebars.compile(emailFile);
|
||||
transporter.sendMail({
|
||||
from: `${process.env.EMAIL_FROM}` || "Cal.com",
|
||||
to: identifier,
|
||||
subject: "Your sign-in link for Cal.com",
|
||||
html: emailTemplate({
|
||||
base_url: WEBSITE_URL,
|
||||
signin_url: url,
|
||||
email: identifier,
|
||||
}),
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const calcomAdapter = CalComAdapter(prisma);
|
||||
export default NextAuth({
|
||||
// @ts-ignore
|
||||
adapter: calcomAdapter,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
secret: process.env.JWT_SECRET,
|
||||
cookies: defaultCookies(WEBSITE_URL?.startsWith("https://")),
|
||||
pages: {
|
||||
signIn: "/auth/login",
|
||||
signOut: "/auth/logout",
|
||||
error: "/auth/error", // Error code passed in query string as ?error=
|
||||
newUser: "/auth/new", // New users will be directed here on first sign in (leave the property out if not of interest)
|
||||
},
|
||||
providers,
|
||||
callbacks: {
|
||||
@@ -169,6 +218,8 @@ export default NextAuth({
|
||||
username: existingUser.username,
|
||||
name: existingUser.name,
|
||||
email: existingUser.email,
|
||||
role: existingUser.role,
|
||||
impersonatedByUID: token?.impersonatedByUID as number,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,6 +236,8 @@ export default NextAuth({
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
impersonatedByUID: user?.impersonatedByUID as number,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,7 +248,6 @@ export default NextAuth({
|
||||
if (account.provider === "saml") {
|
||||
idP = IdentityProvider.SAML;
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
@@ -218,6 +270,8 @@ export default NextAuth({
|
||||
name: existingUser.name,
|
||||
username: existingUser.username,
|
||||
email: existingUser.email,
|
||||
role: existingUser.role,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,11 +285,18 @@ export default NextAuth({
|
||||
id: token.id as number,
|
||||
name: token.name,
|
||||
username: token.username as string,
|
||||
role: token.role as UserPermissionRole,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
},
|
||||
};
|
||||
return calendsoSession;
|
||||
},
|
||||
async signIn({ user, account, profile }) {
|
||||
async signIn(params) {
|
||||
const { user, account, profile } = params;
|
||||
|
||||
if (account.provider === "email") {
|
||||
return true;
|
||||
}
|
||||
// In this case we've already verified the credentials in the authorize
|
||||
// callback so we can sign the user in.
|
||||
if (account.type === "credentials") {
|
||||
@@ -264,10 +325,20 @@ export default NextAuth({
|
||||
if (!user.email_verified) {
|
||||
return "/auth/error?error=unverified-email";
|
||||
}
|
||||
// Only google oauth on this path
|
||||
const provider = account.provider.toUpperCase() as IdentityProvider;
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
include: {
|
||||
accounts: {
|
||||
where: {
|
||||
provider: account.provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
AND: [{ identityProvider: idP }, { identityProviderId: user.id as string }],
|
||||
identityProvider: provider,
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -275,6 +346,17 @@ export default NextAuth({
|
||||
// In this case there's an existing user and their email address
|
||||
// hasn't changed since they last logged in.
|
||||
if (existingUser.email === user.email) {
|
||||
try {
|
||||
// If old user without Account entry we link their google account
|
||||
if (existingUser.accounts.length === 0) {
|
||||
const linkAccountWithUserData = { ...account, userId: existingUser.id };
|
||||
await calcomAdapter.linkAccount(linkAccountWithUserData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error("Error while linking account of already existing user");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -335,7 +417,7 @@ export default NextAuth({
|
||||
return "/auth/error?error=use-identity-login";
|
||||
}
|
||||
|
||||
await prisma.user.create({
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
// Slugify the incoming name and append a few random characters to
|
||||
// prevent conflicts for users with the same name.
|
||||
@@ -347,6 +429,8 @@ export default NextAuth({
|
||||
identityProviderId: user.id as string,
|
||||
},
|
||||
});
|
||||
const linkAccountNewUserData = { ...account, userId: newUser.id };
|
||||
await calcomAdapter.linkAccount(linkAccountNewUserData);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -233,6 +233,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const dynamicUserList = Array.isArray(reqBody.user)
|
||||
? getGroupName(req.body.user)
|
||||
: getUsernameList(reqBody.user as string);
|
||||
const hasHashedBookingLink = reqBody.hasHashedBookingLink;
|
||||
const eventTypeSlug = reqBody.eventTypeSlug;
|
||||
const eventTypeId = reqBody.eventTypeId;
|
||||
const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
|
||||
@@ -673,7 +674,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
}
|
||||
|
||||
await sendRescheduledEmails({ ...evt, additionInformation: metadata });
|
||||
await sendRescheduledEmails({
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes, // Resets back to the addtionalNote input and not the overriden value
|
||||
});
|
||||
}
|
||||
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
||||
// Create a booking
|
||||
@@ -703,13 +708,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
}
|
||||
await sendScheduledEmails({ ...evt, additionInformation: metadata });
|
||||
await sendScheduledEmails({
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType.requiresConfirmation && !rescheduleUid) {
|
||||
await sendOrganizerRequestEmail(evt);
|
||||
await sendAttendeeRequestEmail(evt, attendeesList[0]);
|
||||
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
||||
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
|
||||
}
|
||||
|
||||
if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
|
||||
@@ -772,6 +781,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
},
|
||||
});
|
||||
// refresh hashed link if used
|
||||
const urlSeed = `${users[0].username}:${dayjs(req.body.start).utc().format()}`;
|
||||
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
|
||||
|
||||
if (hasHashedBookingLink) {
|
||||
await prisma.hashedLink.update({
|
||||
where: {
|
||||
link: reqBody.hashedLink as string,
|
||||
},
|
||||
data: {
|
||||
link: hashedUid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// booking successful
|
||||
return res.status(201).json(booking);
|
||||
|
||||
@@ -174,14 +174,6 @@ const handler = async (
|
||||
}
|
||||
});
|
||||
|
||||
// Creating cancelled event as placeholders in calendars, remove when virtual calendar handles it
|
||||
const eventManager = new EventManager({
|
||||
credentials: userOwner.credentials,
|
||||
destinationCalendar: userOwner.destinationCalendar,
|
||||
});
|
||||
builder.calendarEvent.title = `Cancelled: ${builder.calendarEvent.title}`;
|
||||
await eventManager.updateAndSetCancelledPlaceholder(builder.calendarEvent, bookingToReschedule);
|
||||
|
||||
// Send emails
|
||||
await sendRequestRescheduleEmail(builder.calendarEvent, {
|
||||
rescheduleLink: builder.rescheduleLink,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import appStore from "@calcom/app-store";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
@@ -19,19 +17,15 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const appName = _appName.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
|
||||
|
||||
try {
|
||||
// TODO: Find a way to dynamically import these modules
|
||||
// const app = (await import(`@calcom/${appName}`)).default;
|
||||
const app = appStore[appName as keyof typeof appStore];
|
||||
if (!(app && "api" in app && apiEndpoint in app.api))
|
||||
throw new HttpError({ statusCode: 404, message: `API handler not found` });
|
||||
|
||||
const handler = app.api[apiEndpoint as keyof typeof app.api] as NextApiHandler;
|
||||
/* Absolute path didn't work */
|
||||
const handlerMap = (await import("@calcom/app-store/apiHandlers")).default;
|
||||
const handlers = await handlerMap[appName as keyof typeof handlerMap];
|
||||
const handler = handlers[apiEndpoint as keyof typeof handlers] as NextApiHandler;
|
||||
|
||||
if (typeof handler !== "function")
|
||||
throw new HttpError({ statusCode: 404, message: `API handler not found` });
|
||||
|
||||
const response = await handler(req, res);
|
||||
console.log("response", response);
|
||||
|
||||
return res.status(200);
|
||||
} catch (error) {
|
||||
|
||||
@@ -76,7 +76,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const token: string = randomBytes(32).toString("hex");
|
||||
|
||||
await prisma.verificationRequest.create({
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: usernameOrEmail,
|
||||
token,
|
||||
|
||||
@@ -1,31 +1,60 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import prisma from "@lib/prisma";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// const username = req.url?.substring(1, req.url.lastIndexOf("/"));
|
||||
const username = req.query.username as string;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: username,
|
||||
},
|
||||
select: {
|
||||
avatar: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
const teamname = req.query.teamname as string;
|
||||
let identity;
|
||||
if (username) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: username,
|
||||
},
|
||||
select: {
|
||||
avatar: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
identity = {
|
||||
name: username,
|
||||
email: user?.email,
|
||||
avatar: user?.avatar,
|
||||
};
|
||||
} else if (teamname) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
slug: teamname,
|
||||
},
|
||||
select: {
|
||||
logo: true,
|
||||
},
|
||||
});
|
||||
identity = {
|
||||
name: teamname,
|
||||
shouldDefaultBeNameBased: true,
|
||||
avatar: team?.logo,
|
||||
};
|
||||
}
|
||||
|
||||
const emailMd5 = crypto
|
||||
.createHash("md5")
|
||||
.update((user?.email as string) || "guest@example.com")
|
||||
.update((identity?.email as string) || "guest@example.com")
|
||||
.digest("hex");
|
||||
const img = user?.avatar;
|
||||
const img = identity?.avatar;
|
||||
if (!img) {
|
||||
let defaultSrc = defaultAvatarSrc({ md5: emailMd5 });
|
||||
if (identity?.shouldDefaultBeNameBased) {
|
||||
defaultSrc = getPlaceholderAvatar(null, identity.name);
|
||||
}
|
||||
res.writeHead(302, {
|
||||
Location: defaultAvatarSrc({ md5: emailMd5 }),
|
||||
Location: defaultSrc,
|
||||
});
|
||||
|
||||
res.end();
|
||||
} else if (!img.includes("data:image")) {
|
||||
res.writeHead(302, {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import fs from "fs";
|
||||
import matter from "gray-matter";
|
||||
import { GetStaticPaths, GetStaticPathsResult, GetStaticPropsContext } from "next";
|
||||
import { GetStaticPaths, GetStaticPropsContext } from "next";
|
||||
import { MDXRemote } from "next-mdx-remote";
|
||||
import { serialize } from "next-mdx-remote/serialize";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import path from "path";
|
||||
|
||||
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import useMediaQuery from "@lib/hooks/useMediaQuery";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -68,11 +69,8 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
|
||||
const appStore = getAppRegistry();
|
||||
const paths = appStore.reduce((paths, app) => {
|
||||
paths.push({ params: { slug: app.slug } });
|
||||
return paths;
|
||||
}, [] as GetStaticPathsResult<{ slug: string }>["paths"]);
|
||||
const appStore = await prisma.app.findMany({ select: { slug: true } });
|
||||
const paths = appStore.map(({ slug }) => ({ params: { slug } }));
|
||||
|
||||
return {
|
||||
paths,
|
||||
@@ -81,23 +79,19 @@ export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
|
||||
};
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
const appStore = getAppRegistry();
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true };
|
||||
|
||||
if (typeof ctx.params?.slug !== "string") {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const app = await prisma.app.findUnique({
|
||||
where: { slug: ctx.params.slug },
|
||||
});
|
||||
|
||||
const singleApp = appStore.find((app) => app.slug === ctx.params?.slug);
|
||||
if (!app) return { notFound: true };
|
||||
|
||||
if (!singleApp) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const singleApp = await getAppWithMetadata(app);
|
||||
|
||||
const appDirname = singleApp.type.replace("_", "");
|
||||
if (!singleApp) return { notFound: true };
|
||||
|
||||
const appDirname = app.dirName;
|
||||
const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/README.mdx`);
|
||||
const postFilePath = path.join(README_PATH);
|
||||
let source = "";
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function Apps({ appStore }: InferGetStaticPropsType<typeof getSta
|
||||
}
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const appStore = getAppRegistry();
|
||||
const appStore = await getAppRegistry();
|
||||
const paths = appStore.reduce((categories, app) => {
|
||||
if (!categories.includes(app.category)) {
|
||||
categories.push(app.category);
|
||||
@@ -67,7 +67,7 @@ export const getStaticPaths = async () => {
|
||||
export const getStaticProps = async () => {
|
||||
return {
|
||||
props: {
|
||||
appStore: getAppRegistry(),
|
||||
appStore: await getAppRegistry(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function Apps({ categories }: InferGetStaticPropsType<typeof getS
|
||||
}
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const appStore = getAppRegistry();
|
||||
const appStore = await getAppRegistry();
|
||||
const categories = appStore.reduce((c, app) => {
|
||||
c[app.category] = c[app.category] ? c[app.category] + 1 : 1;
|
||||
return c;
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Apps({ appStore, categories }: InferGetStaticPropsType<t
|
||||
}
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const appStore = getAppRegistry();
|
||||
const appStore = await getAppRegistry();
|
||||
const categories = appStore.reduce((c, app) => {
|
||||
c[app.category] = c[app.category] ? c[app.category] + 1 : 1;
|
||||
return c;
|
||||
|
||||
@@ -18,8 +18,8 @@ import { trpc } from "@lib/trpc";
|
||||
import AppsShell from "@components/AppsShell";
|
||||
import { ClientSuspense } from "@components/ClientSuspense";
|
||||
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell, { ShellSubHeading } from "@components/Shell";
|
||||
import SkeletonLoader from "@components/apps/SkeletonLoader";
|
||||
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
||||
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
||||
import IntegrationListItem from "@components/integrations/IntegrationListItem";
|
||||
@@ -256,7 +256,7 @@ function Web3Container() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShellSubHeading title="Web3" subtitle={t("meet_people_with_the_same_tokens")} />
|
||||
<ShellSubHeading title="Web3" subtitle={t("meet_people_with_the_same_tokens")} className="mt-10" />
|
||||
<div className="lg:col-span-9 lg:pb-8">
|
||||
<List>
|
||||
<ListItem className={classNames("flex-col")}>
|
||||
@@ -332,9 +332,13 @@ export default function IntegrationsPage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Shell heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")} large>
|
||||
<Shell
|
||||
heading={t("installed_apps")}
|
||||
subtitle={t("manage_your_connected_apps")}
|
||||
large
|
||||
customLoader={<SkeletonLoader />}>
|
||||
<AppsShell>
|
||||
<ClientSuspense fallback={<Loader />}>
|
||||
<ClientSuspense fallback={<SkeletonLoader />}>
|
||||
<IntegrationsContainer />
|
||||
<CalendarListContainer />
|
||||
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { EmailField, Form, PasswordField } from "@calcom/ui/form/fields";
|
||||
@@ -61,12 +62,15 @@ export default function Login({
|
||||
|
||||
let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "";
|
||||
|
||||
// If not absolute URL, make it absolute
|
||||
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
|
||||
|
||||
// If not absolute URL, make it absolute
|
||||
if (!/^https?:\/\//.test(callbackUrl)) {
|
||||
callbackUrl = `${WEBAPP_URL}/${callbackUrl}`;
|
||||
}
|
||||
|
||||
callbackUrl = getSafeRedirectUrl(callbackUrl);
|
||||
|
||||
const LoginFooter = (
|
||||
<span>
|
||||
{t("dont_have_an_account")}{" "}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { getCookieParser } from "next/dist/server/api-utils";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
@@ -51,6 +52,11 @@ export default function Logout(props: Props) {
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
// Deleting old cookie manually, remove this code after all existing cookies have expired
|
||||
context.res.setHeader(
|
||||
"Set-Cookie",
|
||||
"next-auth.session-token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
6
apps/web/pages/auth/new.tsx
Normal file
6
apps/web/pages/auth/new.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export default function NewUserPage() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -153,14 +153,14 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const verificationRequest = await prisma.verificationRequest.findUnique({
|
||||
const verificationToken = await prisma.verificationToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
// for now, disable if no verificationRequestToken given or token expired
|
||||
if (!verificationRequest || verificationRequest.expires < new Date()) {
|
||||
// for now, disable if no verificationToken given or token expired
|
||||
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
@@ -170,7 +170,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: verificationRequest.identifier,
|
||||
email: verificationToken.identifier,
|
||||
},
|
||||
{
|
||||
emailVerified: {
|
||||
@@ -194,7 +194,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
props: {
|
||||
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
|
||||
isSAMLLoginEnabled,
|
||||
email: verificationRequest.identifier,
|
||||
email: verificationToken.identifier,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Shell from "@components/Shell";
|
||||
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
|
||||
import SkeletonLoader from "@components/availability/SkeletonLoader";
|
||||
|
||||
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
|
||||
const { t, i18n } = useLocale();
|
||||
@@ -105,8 +106,12 @@ export default function AvailabilityPage() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<Shell heading={t("availability")} subtitle={t("configure_availability")} CTA={<NewScheduleButton />}>
|
||||
<WithQuery success={({ data }) => <AvailabilityList {...data} />} />
|
||||
<Shell
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<NewScheduleButton />}
|
||||
customLoader={<SkeletonLoader />}>
|
||||
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,9 +12,9 @@ import { inferQueryInput, trpc } from "@lib/trpc";
|
||||
|
||||
import BookingsShell from "@components/BookingsShell";
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import BookingListItem from "@components/booking/BookingListItem";
|
||||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
|
||||
@@ -45,8 +45,11 @@ export default function Bookings() {
|
||||
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
||||
|
||||
return (
|
||||
<Shell heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<WipeMyCalActionButton trpc={trpc} />
|
||||
<Shell
|
||||
heading={t("bookings")}
|
||||
subtitle={t("bookings_description")}
|
||||
customLoader={<SkeletonLoader></SkeletonLoader>}>
|
||||
<WipeMyCalActionButton trpc={trpc} bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
<BookingsShell>
|
||||
<div className="-mx-4 flex flex-col sm:mx-auto">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
@@ -54,7 +57,7 @@ export default function Bookings() {
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.status === "idle") && <Loader />}
|
||||
{(query.status === "loading" || query.status === "idle") && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<>
|
||||
<div className="mt-6 overflow-hidden rounded-sm border border-b border-gray-200">
|
||||
@@ -72,6 +75,7 @@ export default function Bookings() {
|
||||
</div>
|
||||
<div className="p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
|
||||
@@ -101,8 +101,8 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
className="mb-5 sm:mb-6"
|
||||
/>
|
||||
<div className="space-x-2 text-center rtl:space-x-reverse">
|
||||
<Button color="secondary" onClick={() => router.back()}>
|
||||
{t("back_to_bookings")}
|
||||
<Button color="secondary" onClick={() => router.push("/reschedule/" + uid)}>
|
||||
{t("reschedule_this")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="cancel"
|
||||
|
||||
182
apps/web/pages/d/[link]/[slug].tsx
Normal file
182
apps/web/pages/d/[link]/[slug].tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import { GetBookingType } from "@lib/getBooking";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type(props: AvailabilityPageProps) {
|
||||
return <AvailabilityPage {...props} />;
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const link = asStringOrNull(context.query.link) || "";
|
||||
const slug = asStringOrNull(context.query.slug) || "";
|
||||
const dateParam = asStringOrNull(context.query.date);
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
id: true,
|
||||
title: true,
|
||||
availability: true,
|
||||
description: true,
|
||||
length: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
schedulingType: true,
|
||||
userId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
hidden: true,
|
||||
slug: true,
|
||||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
timeZone: true,
|
||||
metadata: true,
|
||||
slotInterval: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
avatar: true,
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
plan: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hashedLink = await prisma.hashedLink.findUnique({
|
||||
where: {
|
||||
link,
|
||||
},
|
||||
select: {
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: eventTypeSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
|
||||
if (!userId)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
if (hashedLink?.eventType.slug !== slug)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
availability: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
defaultScheduleId: true,
|
||||
allowDynamicBooking: true,
|
||||
away: true,
|
||||
schedules: {
|
||||
select: {
|
||||
availability: true,
|
||||
timeZone: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users || !users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
const [user] = users;
|
||||
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
|
||||
metadata: {} as JSONObject,
|
||||
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
|
||||
slug,
|
||||
});
|
||||
|
||||
const schedule = {
|
||||
...user.schedules.filter(
|
||||
(schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId
|
||||
)[0],
|
||||
};
|
||||
|
||||
const timeZone = schedule.timeZone || user.timeZone;
|
||||
|
||||
const workingHours = getWorkingHours(
|
||||
{
|
||||
timeZone,
|
||||
},
|
||||
schedule.availability || user.availability
|
||||
);
|
||||
eventTypeObject.schedule = null;
|
||||
eventTypeObject.availability = [];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
|
||||
const profile = {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
slug: user.username,
|
||||
theme: user.theme,
|
||||
weekStart: user.weekStart,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
isDynamicGroup: false,
|
||||
profile,
|
||||
plan: user.plan,
|
||||
date: dateParam,
|
||||
eventType: eventTypeObject,
|
||||
workingHours,
|
||||
trpcState: ssr.dehydrate(),
|
||||
previousPage: context.req.headers.referer ?? null,
|
||||
booking,
|
||||
},
|
||||
};
|
||||
};
|
||||
163
apps/web/pages/d/[link]/book.tsx
Normal file
163
apps/web/pages/d/[link]/book.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import BookingPage from "@components/booking/pages/BookingPage";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export type HashLinkPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Book(props: HashLinkPageProps) {
|
||||
return <BookingPage {...props} />;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const link = asStringOrThrow(context.query.link as string);
|
||||
const slug = context.query.slug as string;
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
locations: true,
|
||||
customInputs: true,
|
||||
periodType: true,
|
||||
periodDays: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
metadata: true,
|
||||
periodCountCalendarDays: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
disableGuests: true,
|
||||
userId: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hashedLink = await prisma.hashedLink.findUnique({
|
||||
where: {
|
||||
link,
|
||||
},
|
||||
select: {
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: eventTypeSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
|
||||
|
||||
if (!userId)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
bio: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users.length) return { notFound: true };
|
||||
const [user] = users;
|
||||
const eventTypeRaw = hashedLink?.eventType;
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: {
|
||||
in: users.map((user) => user.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||
isWeb3Active:
|
||||
web3Credentials && web3Credentials.key
|
||||
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
|
||||
: false,
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
...e,
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
};
|
||||
})[0];
|
||||
|
||||
const profile = {
|
||||
name: user.name || user.username,
|
||||
image: user.avatar,
|
||||
slug: user.username,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
eventName: null,
|
||||
};
|
||||
|
||||
const t = await getTranslation(context.locale ?? "en", "common");
|
||||
|
||||
return {
|
||||
props: {
|
||||
locationLabels: getLocationLabels(t),
|
||||
profile,
|
||||
eventType: eventTypeObject,
|
||||
booking: null,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: true,
|
||||
hashedLink: link,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { GlobeAltIcon, PhoneIcon, XIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
DocumentDuplicateIcon,
|
||||
DocumentIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
@@ -28,6 +29,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SelectGifInput } from "@calcom/app-store/giphy/components";
|
||||
import getApps, { getLocationOptions, hasIntegration } from "@calcom/app-store/utils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
@@ -52,6 +54,7 @@ import { ClientSuspense } from "@components/ClientSuspense";
|
||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
||||
@@ -197,7 +200,15 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
prefix: t("indefinitely_into_future"),
|
||||
},
|
||||
];
|
||||
const { eventType, locationOptions, team, teamMembers, hasPaymentIntegration, currency } = props;
|
||||
const {
|
||||
eventType,
|
||||
locationOptions,
|
||||
team,
|
||||
teamMembers,
|
||||
hasPaymentIntegration,
|
||||
currency,
|
||||
hasGiphyIntegration,
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -262,6 +273,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
|
||||
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
||||
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTokens = async () => {
|
||||
@@ -442,6 +454,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
team ? `team/${team.slug}` : eventType.users[0].username
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${
|
||||
eventType.hashedLink ? eventType.hashedLink.link : "xxxxxxxxxxxxxxxxx"
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const mapUserToValue = ({
|
||||
id,
|
||||
name,
|
||||
@@ -471,6 +487,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
currency: string;
|
||||
hidden: boolean;
|
||||
hideCalendarNotes: boolean;
|
||||
hashedLink: boolean;
|
||||
locations: { type: LocationType; address?: string; link?: string }[];
|
||||
customInputs: EventTypeCustomInput[];
|
||||
users: string[];
|
||||
@@ -488,6 +505,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
externalId: string;
|
||||
};
|
||||
successRedirectUrl: string;
|
||||
giphyThankYouPage: string;
|
||||
}>({
|
||||
defaultValues: {
|
||||
locations: eventType.locations || [],
|
||||
@@ -906,6 +924,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
periodDates,
|
||||
periodCountCalendarDays,
|
||||
smartContractAddress,
|
||||
giphyThankYouPage,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
locations,
|
||||
@@ -923,11 +942,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
id: eventType.id,
|
||||
beforeEventBuffer: beforeBufferTime,
|
||||
afterEventBuffer: afterBufferTime,
|
||||
metadata: smartContractAddress
|
||||
? {
|
||||
smartContractAddress,
|
||||
}
|
||||
: "",
|
||||
metadata: {
|
||||
...(smartContractAddress ? { smartContractAddress } : {}),
|
||||
...(giphyThankYouPage ? { giphyThankYouPage } : {}),
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="space-y-6">
|
||||
@@ -1117,7 +1135,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
open={advancedSettingsVisible}
|
||||
onOpenChange={() => setAdvancedSettingsVisible(!advancedSettingsVisible)}>
|
||||
<>
|
||||
<CollapsibleTrigger type="button" className="flex w-full">
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
data-testid="show-advanced-settings"
|
||||
className="flex w-full">
|
||||
<ChevronRightIcon
|
||||
className={`${
|
||||
advancedSettingsVisible ? "rotate-90 transform" : ""
|
||||
@@ -1127,7 +1148,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
{t("show_advanced_settings")}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-6">
|
||||
<CollapsibleContent data-testid="advanced-settings-content" className="mt-4 space-y-6">
|
||||
{/**
|
||||
* Only display calendar selector if user has connected calendars AND if it's not
|
||||
* a team event. Since we don't have logic to handle each attende calendar (for now).
|
||||
@@ -1330,6 +1351,65 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="hashedLink"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.hashedLink ? true : false}
|
||||
render={() => (
|
||||
<>
|
||||
<CheckboxField
|
||||
id="hashedLink"
|
||||
name="hashedLink"
|
||||
label={t("hashed_link")}
|
||||
description={t("hashed_link_description")}
|
||||
defaultChecked={eventType.hashedLink ? true : false}
|
||||
onChange={(e) => {
|
||||
setHashedLinkVisible(e?.target.checked);
|
||||
formMethods.setValue("hashedLink", e?.target.checked);
|
||||
}}
|
||||
/>
|
||||
{hashedLinkVisible && (
|
||||
<div className="block items-center sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0"></div>
|
||||
<div className="w-full">
|
||||
<div className="relative mt-1 flex w-full">
|
||||
<input
|
||||
disabled
|
||||
data-testid="generated-hash-url"
|
||||
type="text"
|
||||
className=" grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm"
|
||||
defaultValue={placeholderHashedLink}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
eventType.hashedLink
|
||||
? t("copy_to_clipboard")
|
||||
: t("enabled_after_update")
|
||||
}>
|
||||
<Button
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
if (eventType.hashedLink) {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
showToast("Link copied!", "success");
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
className="text-md flex items-center border border-gray-300 px-2 py-1 text-sm font-medium text-gray-700 ltr:rounded-r-sm ltr:border-l-0 rtl:rounded-l-sm rtl:border-r-0">
|
||||
<DocumentDuplicateIcon className="w-6 p-1 text-neutral-500" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
The URL will regenerate after each use
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<hr className="my-2 border-neutral-200" />
|
||||
<Controller
|
||||
name="minimumBookingNotice"
|
||||
@@ -1424,7 +1504,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
<div className="inline-flex">
|
||||
<input
|
||||
type="number"
|
||||
className="block w-12 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
placeholder="30"
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
@@ -1655,6 +1735,39 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasGiphyIntegration && (
|
||||
<>
|
||||
<hr className="border-neutral-200" />
|
||||
<div className="block sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
<label
|
||||
htmlFor="gif"
|
||||
className="mt-2 flex text-sm font-medium text-neutral-700">
|
||||
{t("confirmation_page_gif")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="w-full">
|
||||
<div className="block items-center sm:flex">
|
||||
<div className="w-full">
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center">
|
||||
<SelectGifInput
|
||||
defaultValue={eventType?.metadata?.giphyThankYouPage as string}
|
||||
onChange={(url) => {
|
||||
formMethods.setValue("giphyThankYouPage", url);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
{/* )} */}
|
||||
@@ -1663,7 +1776,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
<Button href="/event-types" color="secondary" tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isLoading}>
|
||||
<Button type="submit" data-testid="update-eventtype" disabled={updateMutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1941,6 +2054,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
slotInterval: true,
|
||||
hashedLink: true,
|
||||
successRedirectUrl: true,
|
||||
team: {
|
||||
select: {
|
||||
@@ -1990,6 +2104,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
type: true,
|
||||
key: true,
|
||||
userId: true,
|
||||
appId: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2005,6 +2120,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
: false,
|
||||
};
|
||||
|
||||
const hasGiphyIntegration = !!credentials.find((credential) => credential.type === "giphy_other");
|
||||
|
||||
// backwards compat
|
||||
if (eventType.users.length === 0 && !eventType.team) {
|
||||
const fallbackUser = await prisma.user.findUnique({
|
||||
@@ -2062,6 +2179,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
team: eventTypeObject.team || null,
|
||||
teamMembers,
|
||||
hasPaymentIntegration,
|
||||
hasGiphyIntegration,
|
||||
currency,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Button } from "@calcom/ui";
|
||||
@@ -41,6 +42,7 @@ import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import Badge from "@components/ui/Badge";
|
||||
@@ -230,7 +232,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
)}
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
|
||||
<div className="flex justify-between rtl:space-x-reverse">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
{type.users?.length > 1 && (
|
||||
<AvatarGroup
|
||||
border="border-2 border-white"
|
||||
@@ -267,7 +269,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger
|
||||
className="h-10 w-10 cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900"
|
||||
className="h-10 w-10 cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900 focus:border-gray-300"
|
||||
data-testid={"event-type-options-" + type.id}>
|
||||
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -451,45 +453,48 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => (
|
||||
<div className="mb-4 flex">
|
||||
<Link href="/settings/teams">
|
||||
<a>
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
imageSrc={profile?.image || undefined}
|
||||
size={8}
|
||||
className="mt-1 inline ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div>
|
||||
const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeadingProps): JSX.Element => {
|
||||
console.log(profile.slug);
|
||||
return (
|
||||
<div className="mb-4 flex">
|
||||
<Link href="/settings/teams">
|
||||
<a className="font-bold">{profile?.name || ""}</a>
|
||||
<a>
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
imageSrc={`${WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
|
||||
size={8}
|
||||
className="mt-1 inline ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
{membershipCount && (
|
||||
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 rtl:mr-2">
|
||||
<Link href="/settings/teams">
|
||||
<a>
|
||||
<Badge variant="gray">
|
||||
<UsersIcon className="mr-1 -mt-px inline h-3 w-3" />
|
||||
{membershipCount}
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{profile?.slug && (
|
||||
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${profile.slug}`}>
|
||||
<a className="block text-xs text-neutral-500">{`${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(
|
||||
"https://",
|
||||
""
|
||||
)}/${profile.slug}`}</a>
|
||||
<div>
|
||||
<Link href="/settings/teams">
|
||||
<a className="font-bold">{profile?.name || ""}</a>
|
||||
</Link>
|
||||
)}
|
||||
{membershipCount && (
|
||||
<span className="relative -top-px text-xs text-neutral-500 ltr:ml-2 rtl:mr-2">
|
||||
<Link href="/settings/teams">
|
||||
<a>
|
||||
<Badge variant="gray">
|
||||
<UsersIcon className="mr-1 -mt-px inline h-3 w-3" />
|
||||
{membershipCount}
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{profile?.slug && (
|
||||
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${profile.slug}`}>
|
||||
<a className="block text-xs text-neutral-500">{`${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(
|
||||
"https://",
|
||||
""
|
||||
)}/${profile.slug}`}</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const CreateFirstEventTypeView = ({ canAddEvents, profiles }: CreateEventTypeProps) => {
|
||||
const { t } = useLocale();
|
||||
@@ -523,8 +528,13 @@ const EventTypesPage = () => {
|
||||
<title>Home | Cal.com</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Shell heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")} CTA={<CTA />}>
|
||||
<Shell
|
||||
heading={t("event_types_page_title")}
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
CTA={<CTA />}
|
||||
customLoader={<SkeletonLoader />}>
|
||||
<WithQuery
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
{data.viewer.plan === "FREE" && !data.viewer.canAddEvents && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user