Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b0c7f579a | ||
|
|
ad126efee8 | ||
|
|
4e97d3ed72 | ||
|
|
57dee30526 | ||
|
|
0bfdf2f8ef | ||
|
|
81d917e27a | ||
|
|
3421af10a9 | ||
|
|
f75917d9c4 | ||
|
|
c05040795d | ||
|
|
7d1b13c88d | ||
|
|
f8785e78a5 | ||
|
|
9bd1246ada | ||
|
|
60c97c9fa9 | ||
|
|
9c52e195ea | ||
|
|
e4c5df7a2e | ||
|
|
2eae40eff2 | ||
|
|
9e662aa202 | ||
|
|
f2a6d00348 | ||
|
|
7fd149fd2e | ||
|
|
3c37c2e774 | ||
|
|
84f19bb4df | ||
|
|
27540b09ce | ||
|
|
549e38fb78 | ||
|
|
2655f816af | ||
|
|
016023fa50 | ||
|
|
2a53614723 | ||
|
|
212fd1bc14 | ||
|
|
93bd4ed79e | ||
|
|
2bb6f33112 | ||
|
|
68e275ab07 | ||
|
|
498759890f | ||
|
|
bf1f9b6a32 | ||
|
|
b69c1e46d1 | ||
|
|
dcd2862e72 | ||
|
|
7a08403636 | ||
|
|
574f9cb5d4 | ||
|
|
613e42ce70 | ||
|
|
43f479299a | ||
|
|
ecb3264b52 | ||
|
|
e89520d7d6 | ||
|
|
892bb90d9d | ||
|
|
a2ea08ba5f | ||
|
|
50632dd9dd | ||
|
|
06702aa48a | ||
|
|
f2988870d5 | ||
|
|
f0ea8d30ca | ||
|
|
c3909ccc70 | ||
|
|
ae15a7d739 | ||
|
|
01e88b3807 | ||
|
|
effb9d56d9 | ||
|
|
2a5a89fe50 | ||
|
|
2ce1e78053 | ||
|
|
3bbbc80511 | ||
|
|
19128fb08e | ||
|
|
0945bbe5cf | ||
|
|
ced6975fc8 | ||
|
|
fb436996c0 | ||
|
|
50f1fe544e | ||
|
|
746643bf8e | ||
|
|
65a69ef1e4 | ||
|
|
6483182ef6 | ||
|
|
784a91709c | ||
|
|
82a52e065f | ||
|
|
a1f6738cf1 | ||
|
|
a231945842 | ||
|
|
a507d5963c | ||
|
|
92806d5257 | ||
|
|
2d6d1cb444 | ||
|
|
ef68f4f4f8 | ||
|
|
9440df4445 | ||
|
|
4e0efb76cd | ||
|
|
18c28cc3fd | ||
|
|
d40e8caff9 | ||
|
|
3161cc4d45 | ||
|
|
ed808c3be6 | ||
|
|
4099a477d1 | ||
|
|
6542da7e30 | ||
|
|
8336611f54 | ||
|
|
819c6c96e8 | ||
|
|
e9ff358ac2 | ||
|
|
f79fd36c03 | ||
|
|
edd99cdeb2 | ||
|
|
9825754b32 | ||
|
|
6a18b40c97 | ||
|
|
d00f0bae1d | ||
|
|
fb042a36b6 | ||
|
|
6c27b04f83 | ||
|
|
9322b4ab4c | ||
|
|
5464d4c010 | ||
|
|
351622c4a2 | ||
|
|
44736ac461 | ||
|
|
a2da95b12b | ||
|
|
8ae5b68504 | ||
|
|
1a3c3af072 | ||
|
|
5dde542952 | ||
|
|
4922a13b68 | ||
|
|
a05860515e | ||
|
|
7399d6421e | ||
|
|
269dea70a1 | ||
|
|
46690fa72b | ||
|
|
8c9096b55b | ||
|
|
67cc3a6409 | ||
|
|
83ec6d69eb | ||
|
|
65a76b96c6 | ||
|
|
dd7f22e021 | ||
|
|
1a06d9906b | ||
|
|
6fb301970b | ||
|
|
3baf7060f7 | ||
|
|
0b82b85166 | ||
|
|
82dfd807c8 | ||
|
|
af9612968b | ||
|
|
70455f56a2 | ||
|
|
f839fd2bb4 | ||
|
|
1a79e0624c | ||
|
|
26e46ff06c | ||
|
|
71e67b50b2 | ||
|
|
174ed9f6d1 | ||
|
|
60146ed2c5 | ||
|
|
df7abdfc06 | ||
|
|
6ec2b20a23 | ||
|
|
09d0f68c4c | ||
|
|
d6b7311c66 | ||
|
|
f1a2239c97 | ||
|
|
977ad141ee | ||
|
|
06f88eb5a3 | ||
|
|
daf39a4095 | ||
|
|
0973d79c31 | ||
|
|
257481bad5 | ||
|
|
68e08f13a1 | ||
|
|
3234898892 | ||
|
|
82f7779a23 | ||
|
|
bd9b83540b | ||
|
|
4f0ee7b0b6 | ||
|
|
4437bfa840 | ||
|
|
b9e34c99ca | ||
|
|
71fa872d5c | ||
|
|
377857915f | ||
|
|
ec5020ca3d | ||
|
|
f1bed08c13 | ||
|
|
2cb663cd6a | ||
|
|
fa1ca5fba0 | ||
|
|
02b935bcde | ||
|
|
b8d5c53813 | ||
|
|
bb90fe0d4b | ||
|
|
ba283e3dc0 | ||
|
|
9ae8a48dcd | ||
|
|
774f707c9f | ||
|
|
a6417c5757 | ||
|
|
aebb610403 | ||
|
|
fdbfd759af | ||
|
|
0213f66eb6 | ||
|
|
1de385a410 | ||
|
|
6011b440a8 | ||
|
|
de4b3c186e | ||
|
|
4c8ff47ae7 | ||
|
|
54269ba0bf | ||
|
|
361579ba31 | ||
|
|
000785c29f | ||
|
|
2e6bc5e5b4 | ||
|
|
11f6972ec9 | ||
|
|
df801b4205 | ||
|
|
433f2bafd8 | ||
|
|
c5fc1a4648 | ||
|
|
33d486b160 | ||
|
|
7e57c192ee | ||
|
|
e4f7e26ad5 | ||
|
|
89b4acdfaf | ||
|
|
d856ef53a7 | ||
|
|
3256d601db | ||
|
|
938f4f2b4d | ||
|
|
6999ab09b6 | ||
|
|
c2d52bcfd2 | ||
|
|
d1c37f84aa | ||
|
|
83f9defc65 | ||
|
|
c4dbab2637 | ||
|
|
e76fafdccf | ||
|
|
73e3e4e226 | ||
|
|
6535d654d7 | ||
|
|
a224a46654 | ||
|
|
59c0784cd6 | ||
|
|
b544d5f781 | ||
|
|
ebf1373339 | ||
|
|
75a07f527e | ||
|
|
4b75bf7cce | ||
|
|
e260ba0e49 | ||
|
|
95dfb5b538 | ||
|
|
7d3f070e27 | ||
|
|
01fdbaa990 | ||
|
|
05acd26efe | ||
|
|
1421b9c0af | ||
|
|
6197ae25c6 |
@@ -0,0 +1,89 @@
|
||||
# ********** INDEX **********
|
||||
#
|
||||
# - APP STORE
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - HUBSPOT
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
# - TANDEM
|
||||
# - ZOOM
|
||||
# - GIPHY
|
||||
# - VITAL
|
||||
|
||||
# - 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=''
|
||||
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS='{}'
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||
# @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
|
||||
MS_GRAPH_CLIENT_ID=
|
||||
MS_GRAPH_CLIENT_SECRET=
|
||||
|
||||
# - SLACK
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret
|
||||
SLACK_SIGNING_SECRET=
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
|
||||
# - STRIPE
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
STRIPE_CLIENT_ID= # ca_...
|
||||
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 for API access.
|
||||
TANDEM_CLIENT_ID=""
|
||||
TANDEM_CLIENT_SECRET=""
|
||||
TANDEM_BASE_URL="https://tandem.chat"
|
||||
|
||||
# - ZOOM
|
||||
# Used for the Zoom integration
|
||||
# @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=
|
||||
|
||||
# - VITAL
|
||||
# Used for the vital integration
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-vital-api-keys
|
||||
VITAL_API_KEY=
|
||||
VITAL_WEBHOOK_SECRET=
|
||||
# "sandbox" | "prod" | "production" | "development"
|
||||
VITAL_DEVELOPMENT_MODE="sandbox"
|
||||
# "us" | "eu"
|
||||
VITAL_REGION="us"
|
||||
|
||||
# - ZAPIER
|
||||
# Used for the Zapier integration
|
||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md
|
||||
ZAPIER_INVITE_LINK=""
|
||||
# *********************************************************************************************************
|
||||
+1
-71
@@ -5,16 +5,6 @@
|
||||
# - SHARED
|
||||
# - NEXTAUTH
|
||||
# - E-MAIL SETTINGS
|
||||
# - 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:
|
||||
@@ -35,6 +25,7 @@ NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
||||
# Change to 'http://localhost:3001' if running the website simultaneously
|
||||
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
||||
NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js'
|
||||
|
||||
# To enable SAML login, set both these variables
|
||||
# @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login
|
||||
@@ -115,64 +106,3 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
## @see https://support.google.com/accounts/answer/185833
|
||||
# EMAIL_SERVER_PASSWORD='<gmail_app_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=''
|
||||
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS='{}'
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||
# @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
|
||||
MS_GRAPH_CLIENT_ID=
|
||||
MS_GRAPH_CLIENT_SECRET=
|
||||
|
||||
# - SLACK
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret
|
||||
SLACK_SIGNING_SECRET=
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
|
||||
# - STRIPE
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
STRIPE_CLIENT_ID= # ca_...
|
||||
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 for API access.
|
||||
TANDEM_CLIENT_ID=""
|
||||
TANDEM_CLIENT_SECRET=""
|
||||
TANDEM_BASE_URL="https://tandem.chat"
|
||||
|
||||
# - ZOOM
|
||||
# Used for the Zoom integration
|
||||
# @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=
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@1.4.2
|
||||
uses: crowdin/github-action@1.4.9
|
||||
with:
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
- public/static/locales/**
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -39,6 +39,7 @@ yarn-error.log*
|
||||
.env.production.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.appStore.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=social"></a>
|
||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||
|
||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||
</p>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
@@ -107,7 +107,7 @@ Here is what you need to be able to run Cal.
|
||||
```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`
|
||||
@@ -190,8 +190,10 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
|
||||
### E2E-Testing
|
||||
|
||||
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
||||
|
||||
```sh
|
||||
# In a terminal. Just run:
|
||||
# In a terminal just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
@@ -230,9 +232,9 @@ yarn workspace @calcom/web playwright-report
|
||||
|
||||
1. Check for `.env` variables changes
|
||||
|
||||
```sh
|
||||
yarn predev
|
||||
```
|
||||
```sh
|
||||
yarn predev
|
||||
```
|
||||
|
||||
1. Start the server. In a development environment, just do:
|
||||
|
||||
@@ -401,6 +403,17 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
||||
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.
|
||||
|
||||
### Obtaining Vital API Keys
|
||||
|
||||
1. Open [Vital](https://tryvital.io/) and click Get API Keys.
|
||||
1. Create a team with the team name you desire
|
||||
1. Head to the configuration section on the sidebar of the dashboard
|
||||
1. Click on API keys and you'll find your sandbox `api_key`.
|
||||
1. Copy your `api_key` to `VITAL_API_KEY` in the .env.appStore file.
|
||||
1. Open [Vital Webhooks](https://app.tryvital.io/team/{team_id}/webhooks) and add `<CALCOM BASE URL>/api/integrations/vital/webhook` as webhook for connected applications.
|
||||
1. Select all events for the webhook you interested, e.g. `sleep_created`
|
||||
1. Copy the webhook secret (`sec...`) to `VITAL_WEBHOOK_SECRET` in the .env.appStore file.
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
@@ -420,7 +433,7 @@ Special thanks to these amazing projects which help power Cal.com:
|
||||
- [Day.js](https://day.js.org/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Prisma](https://prisma.io/)
|
||||
|
||||
[<img src="https://jitsu.com/img/powered-by-jitsu.png?gh=true">](https://jitsu.com/?utm_source=cal.com-gihub)
|
||||
|
||||
<a href="https://jitsu.com/?utm_source=cal.com-gihub"><img height="40px" src="https://jitsu.com/img/powered-by-jitsu.png?gh=true" alt="Jitsu.com"></a>
|
||||
|
||||
Cal.com is an [open startup](https://jitsu.com) and [Jitsu](https://github.com/jitsucom/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics.
|
||||
|
||||
+1
-1
Submodule apps/admin updated: cf71a8b47e...943cd10de1
+1
-1
Submodule apps/api updated: 6124577bc2...be2d4338ee
@@ -0,0 +1,21 @@
|
||||
function getAnchor(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9 ]/g, "")
|
||||
.replace(/[ ]/g, "-")
|
||||
.replace(/ /g, "%20");
|
||||
}
|
||||
|
||||
export default function Anchor({ as, children }) {
|
||||
const anchor = getAnchor(children);
|
||||
const link = `#${anchor}`;
|
||||
const Component = as || "div";
|
||||
return (
|
||||
<Component id={anchor}>
|
||||
<a href={link} className="anchor-link">
|
||||
§
|
||||
</a>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Define general type for useWindowSize hook, which includes width and height
|
||||
export interface Size {
|
||||
width: number | undefined;
|
||||
height: number | undefined;
|
||||
}
|
||||
// Hook from: https://usehooks.com/useWindowSize/
|
||||
export function useWindowSize(): Size {
|
||||
// Initialize state with undefined width/height so server and client renders match
|
||||
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
|
||||
const [windowSize, setWindowSize] = useState<Size>({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
useEffect(() => {
|
||||
// Handler to call on window resize
|
||||
function handleResize() {
|
||||
// Set window width/height to state
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
// Add event listener
|
||||
window.addEventListener("resize", handleResize);
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
handleResize();
|
||||
// Remove event listener on cleanup
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []); // Empty array ensures that effect is only run on mount
|
||||
return windowSize;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"author": "Cal.com, Inc.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iframe-resizer-react": "^1.1.0",
|
||||
"next": "^12.1.0",
|
||||
"nextra": "^1.1.0",
|
||||
"nextra-theme-docs": "^1.2.2",
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
title: Embed
|
||||
---
|
||||
|
||||
import Anchor from "../../components/Anchor"
|
||||
|
||||
# Embed
|
||||
|
||||
The Embed allows your website visitors to book a meeting with you directly from your website.
|
||||
|
||||
## Install on any website
|
||||
|
||||
- _Step-1._ Install the Vanilla JS Snippet
|
||||
Install the following Vanilla JS Snippet to get embed to work on any website. After that you can <a href="#popular-ways-in-which-you-can-embed-on-your-website">choose any of the ways</a> to show your Cal Link embedded on your website.
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function (C, A, L) {
|
||||
@@ -57,7 +60,7 @@ yarn add @calcom/embed-react
|
||||
|
||||
You can use Vanilla JS Snippet to install
|
||||
|
||||
## Popular ways in which you can embed on your website
|
||||
<Anchor as="H2">Popular ways in which you can embed on your website</Anchor>
|
||||
|
||||
Assuming that you have followed the steps of installing and initializing the snippet, you can show the embed in following ways:
|
||||
|
||||
@@ -82,8 +85,15 @@ Show the embed inline inside a container element. It would take the width and he
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
*Sample sandbox*
|
||||
```
|
||||
<iframe src="https://codesandbox.io/embed/vanilla-js-inline-embed-r27n67?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
</details>
|
||||
|
||||
####
|
||||
@@ -108,6 +118,14 @@ const MyComponent = () => (
|
||||
);
|
||||
```
|
||||
|
||||
*Sample sandbox*
|
||||
|
||||
<iframe src="https://codesandbox.io/embed/cal-component-embed-inline-demo-react-typescript-d1zlcn?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
</details>
|
||||
|
||||
### Popup on any existing element
|
||||
@@ -120,9 +138,16 @@ To show the embed as a popup on clicking an element, add `data-cal-link` attribu
|
||||
|
||||
To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element.
|
||||
|
||||
*Sample sandbox*
|
||||
<iframe src="https://codesandbox.io/embed/popup-on-click-of-an-existing-element-y9lcuo?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
|
||||
<button data-cal-link="jane" data-cal-config="A valid config JSON"></button>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>React</summary>
|
||||
```jsx
|
||||
@@ -131,11 +156,37 @@ To show the embed as a popup on clicking an element, simply add `data-cal-link`
|
||||
const MyComponent = ()=> {
|
||||
return <button data-cal-link="jane" data-cal-config='A valid config JSON'></button>
|
||||
}
|
||||
```
|
||||
|
||||
````
|
||||
*Sample sandbox*
|
||||
<iframe src="https://codesandbox.io/embed/embed-popup-on-click-of-an-existing-element-demo-react-sc967e?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
|
||||
</details>
|
||||
|
||||
### Floating pop-up button
|
||||
|
||||
```html
|
||||
<script>
|
||||
Cal("floatingButton", {
|
||||
// The link that you want to embed. It would open https://cal.com/jane in embed
|
||||
calLink: "jane",
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
*Sample sandbox*
|
||||
<iframe src="https://codesandbox.io/embed/embed-floating-button-popup-all-websites-cg7pru?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
|
||||
## Supported Instructions
|
||||
|
||||
Consider an instruction as a function with that name and that would be called with the given arguments.
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import Bleed from 'nextra-theme-docs/bleed'
|
||||
import Head from "next/head";
|
||||
import IframeResizer from "iframe-resizer-react";
|
||||
import {useWindowSize} from "../lib/useWindowSize";
|
||||
|
||||
|
||||
<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>
|
||||
<Head><title>Public API | Cal.com</title></Head>
|
||||
<IframeResizer
|
||||
autoResize
|
||||
src={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://developer.cal.com"}
|
||||
frameBorder="0"
|
||||
style={{
|
||||
width: useWindowSize().width > 768 ? "calc(100vw - 16rem)": "100vw",
|
||||
minHeight: useWindowSize().width > 768 ? "100vh" : "200vh",
|
||||
height: "auto",
|
||||
border: 0,
|
||||
}}
|
||||
/>
|
||||
</Bleed>
|
||||
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/docs
|
||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs
|
||||
@@ -20,7 +20,7 @@ export const requestSnippets = {
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["node"],
|
||||
languages: ["node", "curl_bash"],
|
||||
};
|
||||
// Since swagger-ui-react was not configured to change the request snippets some workarounds required
|
||||
// configuration will be added programatically
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "PORT=4200 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "PORT=4200 next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.5.1",
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { SwaggerUI } from "swagger-ui-react";
|
||||
|
||||
import { SnippedGenerator, requestSnippets } from "@lib/snippets";
|
||||
|
||||
const SwaggerUI: any = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||
const SwaggerUIDynamic: SwaggerUI & { url: string } = dynamic(() => import("swagger-ui-react"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function APIDocs() {
|
||||
return (
|
||||
<SwaggerUI
|
||||
<SwaggerUIDynamic
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/docs"}
|
||||
supportedSubmitMethods={["get", "post", "delete", "patch"]}
|
||||
persistAuthorization={true}
|
||||
supportedSubmitMethods={["get", "post", "delete", "put", "options", "patch"]}
|
||||
requestSnippetsEnabled={true}
|
||||
requestSnippets={requestSnippets}
|
||||
plugins={[SnippedGenerator]}
|
||||
tryItOutEnabled={true}
|
||||
syntaxHighlight={true}
|
||||
docExpansion="none"
|
||||
enableCORS={false} // Doesn't seem to work either
|
||||
docExpansion="list"
|
||||
filter={true}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,3 +14,89 @@ a {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.swagger-ui .opblock-tag {
|
||||
font-size: 90% !important;
|
||||
}
|
||||
.swagger-ui .opblock .opblock-summary {
|
||||
display: grid;
|
||||
flex-direction: column;
|
||||
}
|
||||
.opblock-summary-path {
|
||||
flex-shrink: 0;
|
||||
max-width: 100% !important;
|
||||
padding: 10px 5px !important;
|
||||
}
|
||||
.opblock-summary-description {
|
||||
font-size: 16px !important;
|
||||
padding: 0px 5px;
|
||||
}
|
||||
.swagger-ui .scheme-container .schemes {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.swagger-ui .info .title {
|
||||
color: #3b4151;
|
||||
font-family: sans-serif;
|
||||
font-size: 22px;
|
||||
}
|
||||
.swagger-ui .scheme-container {
|
||||
padding: 14px 0;
|
||||
}
|
||||
.swagger-ui .info {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.swagger-ui .auth-wrapper {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.swagger-ui .authorization__btn {
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui .opblock {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
button.opblock-summary-control > svg {
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui .filter .operation-filter-input {
|
||||
border: 2px solid #d8dde7;
|
||||
margin: 5px 5px;
|
||||
padding: 5px;
|
||||
width: 100vw;
|
||||
}
|
||||
.swagger-ui .wrapper {
|
||||
padding: 0 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.swagger-ui .info .title small {
|
||||
top: 5px;
|
||||
}
|
||||
.swagger-ui a.nostyle, .swagger-ui a.nostyle:visited {
|
||||
width: 100%;
|
||||
}
|
||||
div.request-snippets > div.curl-command > div:nth-child(1) {
|
||||
overscroll-behavior: contain;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.swagger-ui .opblock-body pre.microlight {
|
||||
font-size: 9px;
|
||||
}
|
||||
.swagger-ui table tbody tr td {
|
||||
padding: 0px 0 0;
|
||||
vertical-align: none;
|
||||
}
|
||||
td.response-col_description > div > div > p {
|
||||
font-size: 12px;
|
||||
}
|
||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
flex-direction: column;
|
||||
font-size: 60%;
|
||||
}
|
||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > thead > tr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -61,3 +61,6 @@ yarn-error.log*
|
||||
|
||||
# Typescript
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Autogenerated embed content
|
||||
public/embed
|
||||
|
||||
@@ -81,10 +81,10 @@ export default function App({
|
||||
}
|
||||
}
|
||||
getInstalledApp(type);
|
||||
}, []);
|
||||
}, [type]);
|
||||
return (
|
||||
<>
|
||||
<Shell large>
|
||||
<Shell large isPublic>
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="bg-gray-50 px-4">
|
||||
<Link href="/apps">
|
||||
@@ -94,7 +94,10 @@ export default function App({
|
||||
</Link>
|
||||
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
||||
<div className="flex">
|
||||
<img className="h-16 w-16" src={logo} alt={name} />
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="h-16 w-16" src={logo} alt={name} />
|
||||
}
|
||||
<header className="px-4 py-2">
|
||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
||||
<h2 className="text-sm text-gray-500">
|
||||
|
||||
@@ -11,6 +11,10 @@ export default function BookingsShell({ children }: { children: React.ReactNode
|
||||
name: t("upcoming"),
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: t("recurring"),
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: t("past"),
|
||||
href: "/bookings/past",
|
||||
|
||||
@@ -274,7 +274,17 @@ const BrandColor = ({
|
||||
"--brand-text-color-dark-mode",
|
||||
getContrastingTextColor(darkVal, true)
|
||||
);
|
||||
}, [lightVal, darkVal]);
|
||||
}, [
|
||||
embedBrandingColors.highlightColor,
|
||||
embedBrandingColors.lightestColor,
|
||||
embedBrandingColors.lighterColor,
|
||||
embedBrandingColors.lightColor,
|
||||
embedBrandingColors.medianColor,
|
||||
embedBrandingColors.darkColor,
|
||||
embedBrandingColors.darkerColor,
|
||||
lightVal,
|
||||
darkVal,
|
||||
]);
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
import { CodeIcon, EyeIcon, SunIcon, ChevronRightIcon, ArrowLeftIcon } from "@heroicons/react/solid";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRef, useState } from "react";
|
||||
import { components, ControlProps, SingleValue } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
import { Button, Switch } from "@calcom/ui";
|
||||
import { Dialog, DialogContent, DialogClose } from "@calcom/ui/Dialog";
|
||||
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { WEBAPP_URL, EMBED_LIB_URL } from "@lib/config/constants";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import NavTabs from "@components/NavTabs";
|
||||
import ColorPicker from "@components/ui/colorpicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type EmbedType = "inline" | "floating-popup" | "element-click";
|
||||
const queryParamsForDialog = ["embedType", "tabName", "eventTypeId"];
|
||||
|
||||
const embeds: {
|
||||
illustration: React.ReactElement;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
type: EmbedType;
|
||||
}[] = [
|
||||
{
|
||||
title: "Inline Embed",
|
||||
subtitle: "Loads your Cal scheduling page directly inline with your other website content",
|
||||
type: "inline",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="2" fill="#E1E1E1" />
|
||||
<rect x="48" y="86.5" width="48" height="4" rx="1" fill="#E1E1E1" />
|
||||
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="125.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M61 124.5H67V122.5H61V124.5ZM68 125.5V131.5H70V125.5H68ZM67 132.5H61V134.5H67V132.5ZM60 131.5V125.5H58V131.5H60ZM61 132.5C60.4477 132.5 60 132.052 60 131.5H58C58 133.157 59.3431 134.5 61 134.5V132.5ZM68 131.5C68 132.052 67.5523 132.5 67 132.5V134.5C68.6569 134.5 70 133.157 70 131.5H68ZM67 124.5C67.5523 124.5 68 124.948 68 125.5H70C70 123.843 68.6569 122.5 67 122.5V124.5ZM61 122.5C59.3431 122.5 58 123.843 58 125.5H60C60 124.948 60.4477 124.5 61 124.5V122.5Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="73" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Floating pop-up button",
|
||||
subtitle: "Adds a floating button on your site that launches Cal in a dialog.",
|
||||
type: "floating-popup",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="2" fill="#292929" />
|
||||
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Pop up via element click",
|
||||
subtitle: "Open your Cal dialog when someone clicks an element.",
|
||||
type: "element-click",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="108" y="85" width="80" height="8" rx="2" fill="#E1E1E1" />
|
||||
<rect x="108" y="97" width="48" height="4" rx="1" fill="#E1E1E1" />
|
||||
<rect x="109" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="110" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="133" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="136" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M121 135H127V133H121V135ZM128 136V142H130V136H128ZM127 143H121V145H127V143ZM120 142V136H118V142H120ZM121 143C120.448 143 120 142.552 120 142H118C118 143.657 119.343 145 121 145V143ZM128 142C128 142.552 127.552 143 127 143V145C128.657 145 130 143.657 130 142H128ZM127 135C127.552 135 128 135.448 128 136H130C130 134.343 128.657 133 127 133V135ZM121 133C119.343 133 118 134.343 118 136H120C120 135.448 120.448 135 121 135V133Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="133" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="181" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="181" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="172" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" stroke="#292929" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function getEmbedSnippetString() {
|
||||
// TODO: Import this string from @calcom/embed-snippet
|
||||
return `
|
||||
(function (C, A, L) { let p = function (a, ar) { a.q.push(ar); }; let d = C.document; C.Cal = C.Cal || function () { let cal = C.Cal; let ar = arguments; if (!cal.loaded) { cal.ns = {}; cal.q = cal.q || []; d.head.appendChild(d.createElement("script")).src = A; cal.loaded = true; } if (ar[0] === L) { const api = function () { p(api, arguments); }; const namespace = ar[1]; api.q = api.q || []; typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar); return; } p(cal, ar); }; })(window, "${EMBED_LIB_URL}", "init");
|
||||
Cal("init", {origin:"${WEBAPP_URL}"});
|
||||
`;
|
||||
}
|
||||
|
||||
const EmbedNavBar = () => {
|
||||
const { t } = useLocale();
|
||||
const tabs = [
|
||||
{
|
||||
name: t("Embed"),
|
||||
tabName: "embed-code",
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Preview"),
|
||||
tabName: "embed-preview",
|
||||
icon: EyeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return <NavTabs data-testid="embed-tabs" tabs={tabs} linkProps={{ shallow: true }} />;
|
||||
};
|
||||
const ThemeSelectControl = ({ children, ...props }: ControlProps<any, false>) => {
|
||||
return (
|
||||
<components.Control {...props}>
|
||||
<SunIcon className="h-[32px] w-[32px] text-gray-500" />
|
||||
{children}
|
||||
</components.Control>
|
||||
);
|
||||
};
|
||||
|
||||
const ChooseEmbedTypesDialogContent = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DialogContent size="lg">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("how_you_want_add_cal_site")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{embeds.map((embed, index) => (
|
||||
<button
|
||||
className="mr-2 w-1/3 p-3 text-left hover:rounded-md hover:border hover:bg-neutral-100"
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
embedType: embed.type,
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
||||
{embed.illustration}
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-neutral-900">{embed.title}</div>
|
||||
<p className="text-sm text-gray-500">{embed.subtitle}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
|
||||
const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
eventTypeId,
|
||||
embedType,
|
||||
}: {
|
||||
eventTypeId: EventType["id"];
|
||||
embedType: EmbedType;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const embedCode = useRef<HTMLTextAreaElement>(null);
|
||||
const embed = embeds.find((embed) => embed.type === embedType);
|
||||
|
||||
const { data: eventType, isLoading } = trpc.useQuery([
|
||||
"viewer.eventTypes.get",
|
||||
{
|
||||
id: +eventTypeId,
|
||||
},
|
||||
]);
|
||||
|
||||
const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true);
|
||||
const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true);
|
||||
const [previewState, setPreviewState] = useState({
|
||||
inline: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
theme: "auto",
|
||||
floatingPopup: {},
|
||||
elementClick: {},
|
||||
palette: {
|
||||
brandColor: "#000000",
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
const noPopupQuery = {
|
||||
...router.query,
|
||||
};
|
||||
|
||||
delete noPopupQuery.dialog;
|
||||
|
||||
queryParamsForDialog.forEach((queryParam) => {
|
||||
delete noPopupQuery[queryParam];
|
||||
});
|
||||
|
||||
router.push({
|
||||
query: noPopupQuery,
|
||||
});
|
||||
};
|
||||
|
||||
// Use embed-code as default tab
|
||||
if (!router.query.tabName) {
|
||||
router.query.tabName = "embed-code";
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!embed || !eventType) {
|
||||
close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const calLink = `${eventType.team ? `team/${eventType.team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
}`;
|
||||
|
||||
// TODO: Not sure how to make these template strings look better formatted.
|
||||
// This exact formatting is required to make the code look nicely formatted together.
|
||||
const getEmbedUIInstructionString = () =>
|
||||
`Cal("ui", {
|
||||
${getThemeForSnippet() ? 'theme: "' + previewState.theme + '",\n ' : ""}styles: {
|
||||
branding: ${JSON.stringify(previewState.palette)}
|
||||
}
|
||||
})`;
|
||||
|
||||
const getEmbedTypeSpecificString = () => {
|
||||
if (embedType === "inline") {
|
||||
return `
|
||||
Cal("inline", {
|
||||
elementOrSelector:"#my-cal-inline",
|
||||
calLink: "${calLink}"
|
||||
});
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
} else if (embedType === "floating-popup") {
|
||||
let floatingButtonArg = {
|
||||
calLink,
|
||||
...previewState.floatingPopup,
|
||||
};
|
||||
return `
|
||||
Cal("floatingButton", ${JSON.stringify(floatingButtonArg)});
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
} else if (embedType === "element-click") {
|
||||
return `//Important: Also, add data-cal-link="${calLink}" attribute to the element you want to open Cal on click
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getThemeForSnippet = () => {
|
||||
return previewState.theme !== "auto" ? previewState.theme : null;
|
||||
};
|
||||
|
||||
const getDimension = (dimension: string) => {
|
||||
if (dimension.match(/^\d+$/)) {
|
||||
dimension = `${dimension}%`;
|
||||
}
|
||||
return dimension;
|
||||
};
|
||||
|
||||
const addToPalette = (update: typeof previewState["palette"]) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
palette: {
|
||||
...previewState.palette,
|
||||
...update,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const previewInstruction = (instruction: { name: string; arg: any }) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
mode: "cal:preview",
|
||||
type: "instruction",
|
||||
instruction,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
};
|
||||
|
||||
const inlineEmbedDimensionUpdate = ({ width, height }: { width: string; height: string }) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
mode: "cal:preview",
|
||||
type: "inlineEmbedDimensionUpdate",
|
||||
data: {
|
||||
width: getDimension(width),
|
||||
height: getDimension(height),
|
||||
},
|
||||
},
|
||||
"*"
|
||||
);
|
||||
};
|
||||
|
||||
previewInstruction({
|
||||
name: "ui",
|
||||
arg: {
|
||||
theme: previewState.theme,
|
||||
styles: {
|
||||
branding: {
|
||||
...previewState.palette,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (embedType === "floating-popup") {
|
||||
previewInstruction({
|
||||
name: "floatingButton",
|
||||
arg: {
|
||||
attributes: {
|
||||
id: "my-floating-button",
|
||||
},
|
||||
...previewState.floatingPopup,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (embedType === "inline") {
|
||||
inlineEmbedDimensionUpdate({
|
||||
width: previewState.inline.width,
|
||||
height: previewState.inline.height,
|
||||
});
|
||||
}
|
||||
|
||||
const ThemeOptions = [
|
||||
{ value: "auto", label: "Auto Theme" },
|
||||
{ value: "dark", label: "Dark Theme" },
|
||||
{ value: "light", label: "Light Theme" },
|
||||
];
|
||||
|
||||
const FloatingPopupPositionOptions = [
|
||||
{
|
||||
value: "bottom-right",
|
||||
label: "Bottom Right",
|
||||
},
|
||||
{
|
||||
value: "bottom-left",
|
||||
label: "Bottom Left",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DialogContent size="xl">
|
||||
<div className="flex">
|
||||
<div className="flex w-1/3 flex-col bg-white p-6">
|
||||
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newQuery = { ...router.query };
|
||||
delete newQuery.embedType;
|
||||
delete newQuery.tabName;
|
||||
router.push({
|
||||
query: {
|
||||
...newQuery,
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<ArrowLeftIcon className="mr-4 w-4"></ArrowLeftIcon>
|
||||
</button>
|
||||
{embed.title}
|
||||
</h3>
|
||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")}></hr>
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full items-center text-base font-medium text-neutral-900">
|
||||
<div>
|
||||
{embedType === "inline"
|
||||
? "Inline Embed Customization"
|
||||
: embedType === "floating-popup"
|
||||
? "Floating Popup Customization"
|
||||
: "Element Click Customization"}
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={`${
|
||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="text-sm">
|
||||
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
||||
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
||||
<div className="text-sm">Embed Window Sizing</div>
|
||||
<div className="justify-left flex items-center">
|
||||
<TextField
|
||||
name="width"
|
||||
labelProps={{ className: "hidden" }}
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
let width = e.target.value || "100%";
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
width,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>W</InputLeading>}
|
||||
/>
|
||||
<span className="p-2">x</span>
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
name="height"
|
||||
value={previewState.inline.height}
|
||||
required
|
||||
onChange={(e) => {
|
||||
const height = e.target.value || "100%";
|
||||
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
height,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>H</InputLeading>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div className="text-sm">Button Text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
name="buttonText"
|
||||
labelProps={{ className: "hidden" }}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonText: e.target.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue="Book my Cal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div className="text-sm">Display Calendar Icon Button</div>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
hideButtonIcon: !checked,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></Switch>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Position of Button</div>
|
||||
<Select
|
||||
onChange={(position) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonPosition: position?.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue={FloatingPopupPositionOptions[0]}
|
||||
options={FloatingPopupPositionOptions}></Select>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Button Color</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Text Color</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Button Color on Hover</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
"floating-popup-button-color-hover": color,
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</div> */}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<hr className="mt-4"></hr>
|
||||
<div className="mt-4 font-medium">
|
||||
<Collapsible
|
||||
open={isBookingCustomizationOpen}
|
||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger className="flex w-full" type="button">
|
||||
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
|
||||
<ChevronRightIcon
|
||||
className={`${
|
||||
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<Label className="flex items-center justify-between">
|
||||
<div>Theme</div>
|
||||
<Select
|
||||
className="w-36"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
theme: option.value,
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}></Select>
|
||||
</Label>
|
||||
{[
|
||||
{ name: "brandColor", title: "Brand Color" },
|
||||
// { name: "lightColor", title: "Light Color" },
|
||||
// { name: "lighterColor", title: "Lighter Color" },
|
||||
// { name: "lightestColor", title: "Lightest Color" },
|
||||
// { name: "highlightColor", title: "Highlight Color" },
|
||||
// { name: "medianColor", title: "Median Color" },
|
||||
].map((palette) => (
|
||||
<Label key={palette.name} className="flex items-center justify-between">
|
||||
<div>{palette.title}</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
//@ts-ignore - How to support dynamic palette names?
|
||||
addToPalette({
|
||||
[palette.name]: color,
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2/3 bg-gray-50 p-6">
|
||||
<EmbedNavBar />
|
||||
<div>
|
||||
<div
|
||||
className={classNames(router.query.tabName === "embed-code" ? "block" : "hidden", "h-[75vh]")}>
|
||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
||||
<TextArea
|
||||
data-testid="embed-code"
|
||||
ref={embedCode}
|
||||
name="embed-code"
|
||||
className="h-[36rem]"
|
||||
readOnly
|
||||
value={
|
||||
`<!-- Cal ${embedType} embed code begins -->\n` +
|
||||
(embedType === "inline"
|
||||
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
|
||||
previewState.inline.height
|
||||
)};overflow:scroll" id="my-cal-inline"></div>\n`
|
||||
: "") +
|
||||
`<script type="text/javascript">
|
||||
${getEmbedSnippetString().trim()}
|
||||
${getEmbedTypeSpecificString().trim()}
|
||||
</script>
|
||||
<!-- Cal ${embedType} embed code ends -->`
|
||||
}></TextArea>
|
||||
<p className="hidden text-sm text-gray-500">
|
||||
{t(
|
||||
"Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={router.query.tabName == "embed-preview" ? "block" : "hidden"}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
data-testid="embed-preview"
|
||||
className="border-1 h-[75vh] border"
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`${WEBAPP_URL}/embed/preview.html?embedType=${embedType}&calLink=${calLink}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
if (!embedCode.current) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(embedCode.current.value);
|
||||
showToast(t("code_copied"), "success");
|
||||
}}>
|
||||
{t("copy_code")}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("Close")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmbedDialog = () => {
|
||||
const router = useRouter();
|
||||
const eventTypeId: EventType["id"] = +(router.query.eventTypeId as string);
|
||||
return (
|
||||
<Dialog name="embed" clearQueryParamsOnClose={queryParamsForDialog}>
|
||||
{!router.query.embedType ? (
|
||||
<ChooseEmbedTypesDialogContent />
|
||||
) : (
|
||||
<EmbedTypeCodeAndPreviewDialogContent
|
||||
eventTypeId={eventTypeId}
|
||||
embedType={router.query.embedType as EmbedType}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmbedButton = ({
|
||||
eventTypeId,
|
||||
className = "",
|
||||
dark,
|
||||
...props
|
||||
}: {
|
||||
eventTypeId: EventType["id"];
|
||||
className: string;
|
||||
dark?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
className = classNames(className, "hidden lg:flex");
|
||||
const openEmbedModal = () => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "embed",
|
||||
eventTypeId,
|
||||
};
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className={className}
|
||||
{...props}
|
||||
data-test-eventtype-id={eventTypeId}
|
||||
data-testid={"event-type-embed"}
|
||||
onClick={() => openEmbedModal()}>
|
||||
<CodeIcon
|
||||
className={classNames("h-4 w-4 ltr:mr-2 rtl:ml-2", dark ? "" : "text-neutral-500")}></CodeIcon>
|
||||
{t("Embed")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -134,7 +134,10 @@ export default function ImageUploader({
|
||||
{t("no_target", { target })}
|
||||
</p>
|
||||
)}
|
||||
{imageSrc && <img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />}
|
||||
{imageSrc && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="h-20 w-20 rounded-full" src={imageSrc} alt={target} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
export default function Loader() {
|
||||
return (
|
||||
<div className="loader border-brand dark:border-darkmodebrand">
|
||||
<span className="loader-inner bg-brand dark:bg-darkmodebrand"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { default } from "@calcom/ui/Loader";
|
||||
|
||||
@@ -3,8 +3,10 @@ export default function Logo({ small, icon }: { small?: boolean; icon?: boolean
|
||||
<h1 className="inline">
|
||||
<strong>
|
||||
{icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mx-auto w-9" alt="Cal" title="Cal" src="/cal-com-icon-white.svg" />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={small ? "h-4 w-auto" : "h-5 w-auto"}
|
||||
alt="Cal"
|
||||
|
||||
@@ -1,32 +1,62 @@
|
||||
import { AdminRequired } from "components/ui/AdminRequired";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ElementType, FC, Fragment } from "react";
|
||||
import React, { ElementType, FC, Fragment, MouseEventHandler } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export interface NavTabProps {
|
||||
tabs: {
|
||||
name: string;
|
||||
href: string;
|
||||
/** If you want to change the path as per current tab */
|
||||
href?: string;
|
||||
/** If you want to change query param tabName as per current tab */
|
||||
tabName?: string;
|
||||
icon?: ElementType;
|
||||
adminRequired?: boolean;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
}
|
||||
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps }) => {
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<nav className="-mb-px flex 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"
|
||||
{...props}>
|
||||
{tabs.map((tab) => {
|
||||
const isCurrent = router.asPath === tab.href;
|
||||
let href: string;
|
||||
let isCurrent;
|
||||
if ((tab.tabName && tab.href) || (!tab.tabName && !tab.href)) {
|
||||
throw new Error("Use either tabName or href");
|
||||
}
|
||||
if (tab.href) {
|
||||
href = tab.href;
|
||||
isCurrent = router.asPath === tab.href;
|
||||
} else if (tab.tabName) {
|
||||
href = "";
|
||||
isCurrent = router.query.tabName === tab.tabName;
|
||||
}
|
||||
const onClick: MouseEventHandler = tab.tabName
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
tabName: tab.tabName,
|
||||
},
|
||||
});
|
||||
}
|
||||
: () => {};
|
||||
|
||||
const Component = tab.adminRequired ? AdminRequired : Fragment;
|
||||
return (
|
||||
<Component key={tab.name}>
|
||||
<Link href={tab.href} {...linkProps}>
|
||||
<Link key={tab.name} href={href!} {...linkProps}>
|
||||
<a
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
|
||||
@@ -34,6 +34,7 @@ import HelpMenuItem from "@ee/components/support/HelpMenuItem";
|
||||
import classNames from "@lib/classNames";
|
||||
import { WEBAPP_URL } from "@lib/config/constants";
|
||||
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
@@ -46,16 +47,6 @@ import pkg from "../package.json";
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
import Logo from "./Logo";
|
||||
|
||||
export function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
||||
const { data: session, status } = useSession();
|
||||
const loading = status === "loading";
|
||||
@@ -206,7 +197,7 @@ const Layout = ({
|
||||
</Link>
|
||||
{/* logo icon for tablet */}
|
||||
<Link href="/event-types">
|
||||
<a className="md:inline lg:hidden">
|
||||
<a className="text-center md:inline lg:hidden">
|
||||
<Logo small icon />
|
||||
</a>
|
||||
</Link>
|
||||
@@ -469,7 +460,6 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
},
|
||||
});
|
||||
const utils = trpc.useContext();
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -479,11 +469,14 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
small ? "h-8 w-8" : "h-10 w-10",
|
||||
"relative flex-shrink-0 rounded-full bg-gray-300 ltr:mr-3 rtl:ml-3"
|
||||
)}>
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={process.env.NEXT_PUBLIC_WEBSITE_URL + "/" + user?.username + "/avatar.png"}
|
||||
alt={user?.username || "Nameless User"}
|
||||
/>
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={process.env.NEXT_PUBLIC_WEBSITE_URL + "/" + user?.username + "/avatar.png"}
|
||||
alt={user?.username || "Nameless User"}
|
||||
/>
|
||||
}
|
||||
{!user?.away && (
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-green-500"></div>
|
||||
)}
|
||||
|
||||
@@ -16,11 +16,15 @@ interface AppCardProps {
|
||||
export default function AppCard(props: AppCardProps) {
|
||||
return (
|
||||
<Link href={"/apps/" + props.slug}>
|
||||
<a className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50">
|
||||
<a
|
||||
className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50"
|
||||
data-testid={`app-store-app-card-${props.slug}`}>
|
||||
<div className="flex">
|
||||
<img src={props.logo} alt={props.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={props.logo} alt={props.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
|
||||
}
|
||||
<Button
|
||||
data-testid={`app-store-app-card-${props.slug}`}
|
||||
color="secondary"
|
||||
className="ml-auto flex self-start"
|
||||
onClick={() => {
|
||||
|
||||
@@ -14,9 +14,9 @@ import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/D
|
||||
|
||||
import { defaultDayRange } from "@lib/availability";
|
||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { TimeRange } from "@lib/types/schedule";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
dayjs.extend(utc);
|
||||
@@ -57,7 +57,7 @@ const useOptions = () => {
|
||||
t = t.add(increment, "minutes");
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
}, [timeFormat]);
|
||||
|
||||
const filter = useCallback(
|
||||
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Availability } from "@calcom/prisma/client";
|
||||
import { Button } from "@calcom/ui";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
|
||||
import { inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
export function ScheduleListItem({
|
||||
schedule,
|
||||
deleteFunction,
|
||||
isDeleting = false,
|
||||
}: {
|
||||
schedule: inferQueryOutput<"viewer.availability.list">["schedules"][number];
|
||||
deleteFunction: Function;
|
||||
isDeleting: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
return (
|
||||
<li key={schedule.id}>
|
||||
<div className="flex items-center justify-between py-5 hover:bg-neutral-50 ltr:pl-4 rtl:pr-4 sm:ltr:pl-0 sm:rtl:pr-0">
|
||||
<div className="group flex w-full items-center justify-between hover:bg-neutral-50 sm:px-6">
|
||||
<Link href={"/availability/" + schedule.id}>
|
||||
<a className="flex-grow truncate text-sm" title={schedule.name}>
|
||||
<div>
|
||||
<span className="truncate font-medium text-neutral-900">{schedule.name}</span>
|
||||
{schedule.isDefault && (
|
||||
<span className="ml-2 inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800">
|
||||
{t("default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{schedule.availability.map((availability: Availability) => (
|
||||
<Fragment key={availability.id}>
|
||||
{availabilityAsString(availability, i18n.language)}
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="group mr-5 h-10 w-10 border border-transparent p-0 text-neutral-500 hover:border-gray-200">
|
||||
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
disabled={isDeleting}
|
||||
onClick={() => {
|
||||
deleteFunction({
|
||||
scheduleId: schedule.id,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
color="warn"
|
||||
className="w-full font-normal"
|
||||
StartIcon={isDeleting ? undefined : TrashIcon}
|
||||
loading={isDeleting}>
|
||||
{isDeleting ? t("deleting") : t("delete_schedule")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -31,3 +31,16 @@ function SkeletonItem() {
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const AvailabilitySelectSkeletonLoader = () => {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText width="32" height="4"></SkeletonText>
|
||||
<SkeletonText width="4" height="4"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { Dayjs } from "dayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
@@ -8,6 +8,7 @@ import React, { FC, useEffect, useState } from "react";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useSlots } from "@lib/hooks/useSlots";
|
||||
|
||||
@@ -20,6 +21,7 @@ type AvailableTimesProps = {
|
||||
afterBufferTime: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
recurringCount: number | undefined;
|
||||
eventTypeSlug: string;
|
||||
slotInterval: number | null;
|
||||
date: Dayjs;
|
||||
@@ -36,6 +38,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
eventTypeSlug,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
recurringCount,
|
||||
timeFormat,
|
||||
users,
|
||||
schedulingType,
|
||||
@@ -89,6 +92,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
date: slot.time.format(),
|
||||
type: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -109,7 +114,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}
|
||||
data-testid="time">
|
||||
{slot.time.format(timeFormat)}
|
||||
{dayjs.tz(slot.time, timeZone()).format(timeFormat)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline";
|
||||
import { PaperAirplaneIcon } from "@heroicons/react/outline";
|
||||
import { RefreshIcon } from "@heroicons/react/solid";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { parseRecurringDates } from "@lib/parseDate";
|
||||
import { inferQueryOutput, trpc, inferQueryInput } from "@lib/trpc";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
function BookingListItem(booking: BookingItem) {
|
||||
type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringCount?: number;
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
@@ -30,14 +41,22 @@ function BookingListItem(booking: BookingItem) {
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
async (confirm: boolean) => {
|
||||
let body = {
|
||||
id: booking.id,
|
||||
confirmed: confirm,
|
||||
language: i18n.language,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Upcoming" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if (booking.listingStatus === "upcoming" && booking.recurringEventId !== null) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
const res = await fetch("/api/book/confirm", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
id: booking.id,
|
||||
confirmed: confirm,
|
||||
language: i18n.language,
|
||||
reason: rejectionReason,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -58,14 +77,20 @@ function BookingListItem(booking: BookingItem) {
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: t("reject"),
|
||||
label:
|
||||
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||
? t("reject_all")
|
||||
: t("reject"),
|
||||
onClick: () => setRejectionDialogIsOpen(true),
|
||||
icon: BanIcon,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label: t("confirm"),
|
||||
label:
|
||||
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||
? t("confirm_all")
|
||||
: t("confirm"),
|
||||
onClick: () => mutation.mutate(true),
|
||||
icon: CheckIcon,
|
||||
disabled: mutation.isLoading,
|
||||
@@ -112,6 +137,19 @@ function BookingListItem(booking: BookingItem) {
|
||||
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
|
||||
// Calculate the booking date(s)
|
||||
let recurringStrings: string[] = [];
|
||||
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== null) {
|
||||
[recurringStrings] = parseRecurringDates(
|
||||
{
|
||||
startDate: booking.startTime,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringCount: booking.recurringCount,
|
||||
},
|
||||
i18n
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -154,12 +192,40 @@ function BookingListItem(booking: BookingItem) {
|
||||
</Dialog>
|
||||
|
||||
<tr className="flex">
|
||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell">
|
||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-56">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{booking.recurringCount &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
booking.listingStatus === "upcoming" && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.map((aDate, key) => (
|
||||
<p key={key}>{aDate}</p>
|
||||
))}>
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{`${t("every_for_freq", {
|
||||
freq: t(
|
||||
`${RRuleFrequency[booking.eventType.recurringEvent.freq]
|
||||
.toString()
|
||||
.toLowerCase()}`
|
||||
),
|
||||
})} ${booking.recurringCount} ${t(
|
||||
`${RRuleFrequency[booking.eventType.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: booking.recurringCount }
|
||||
)}`}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
|
||||
<div className="sm:hidden">
|
||||
|
||||
@@ -2,8 +2,6 @@ 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">
|
||||
@@ -22,10 +20,9 @@ function SkeletonItem() {
|
||||
<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" />
|
||||
<SkeletonText width="16" height="5" />
|
||||
<SkeletonText width="32" 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">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreditCardIcon,
|
||||
GlobeIcon,
|
||||
InformationCircleIcon,
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
@@ -15,8 +16,9 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import {
|
||||
useEmbedStyles,
|
||||
@@ -27,11 +29,12 @@ import {
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
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";
|
||||
@@ -73,7 +76,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
||||
router.replace(`/${eventOwner.username}`);
|
||||
}
|
||||
}, [contracts, eventType.metadata.smartContractAddress, router]);
|
||||
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
|
||||
|
||||
const selectedDate = useMemo(() => {
|
||||
const dateString = asStringOrNull(router.query.date);
|
||||
@@ -101,6 +104,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
}
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
@@ -109,32 +113,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
telemetryEventTypes.pageView,
|
||||
collectPageParameters("availability", { isTeamBooking: document.URL.includes("team/") })
|
||||
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
|
||||
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
}, [telemetry]);
|
||||
|
||||
const changeDate = (newDate: Dayjs) => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
date: newDate.format("YYYY-MM-DDZZ"),
|
||||
const changeDate = useCallback(
|
||||
(newDate: Dayjs) => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
date: newDate.tz(timeZone(), true).format("YYYY-MM-DDZZ"),
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedDate != null &&
|
||||
selectedDate?.utcOffset() !== selectedDate.clone().utcOffset(0).tz(timeZone()).utcOffset()
|
||||
) {
|
||||
changeDate(selectedDate.tz(timeZone(), true));
|
||||
}
|
||||
}, [selectedDate, changeDate]);
|
||||
|
||||
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
||||
timeZone(selectedTimeZone);
|
||||
if (selectedDate) {
|
||||
changeDate(selectedDate.tz(selectedTimeZone, true));
|
||||
}
|
||||
timeZone(selectedTimeZone);
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
@@ -142,6 +156,15 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
};
|
||||
|
||||
// Recurring event sidebar requires more space
|
||||
const maxWidth = selectedDate
|
||||
? recurringEventCount
|
||||
? "max-w-6xl"
|
||||
: "max-w-5xl"
|
||||
: recurringEventCount
|
||||
? "max-w-4xl"
|
||||
: "max-w-3xl";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
@@ -158,9 +181,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? 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")
|
||||
? classNames(maxWidth)
|
||||
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
||||
)}>
|
||||
{isReady && (
|
||||
<div
|
||||
@@ -168,7 +190,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||
"border-bookinglightest rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
|
||||
isEmbed ? "mx-auto" : maxWidth
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
@@ -243,7 +265,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
<div
|
||||
className={
|
||||
"hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
||||
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||
(selectedDate ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||
}>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
@@ -267,15 +289,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
{eventType.title}
|
||||
</h1>
|
||||
{eventType?.description && (
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<p className="text-bookinglight mb-3 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="text-bookinglight mb-2 dark:text-white">
|
||||
<p className="text-bookinglight mb-3 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>
|
||||
{!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
|
||||
<div className="mb-3 text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{t("every_for_freq", {
|
||||
freq: t(
|
||||
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-16 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline text-gray-600 dark:text-white">
|
||||
{t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
@@ -302,7 +351,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-2 text-gray-600 dark:text-white"
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_desktop">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
@@ -340,6 +389,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
eventTypeSlug={eventType.slug}
|
||||
slotInterval={eventType.slotInterval}
|
||||
eventLength={eventType.length}
|
||||
recurringCount={recurringEventCount}
|
||||
date={selectedDate}
|
||||
users={eventType.users}
|
||||
schedulingType={eventType.schedulingType ?? null}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
@@ -18,20 +20,17 @@ 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 { Frequency as RRuleFrequency } from "rrule";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
useIsEmbed,
|
||||
useEmbedStyles,
|
||||
useIsBackgroundTransparent,
|
||||
useEmbedType,
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import { useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed } from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { createPaymentLink } from "@calcom/stripe/client";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
@@ -40,7 +39,8 @@ import { ensureArray } from "@lib/ensureArray";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { LocationType } from "@lib/location";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import { parseDate } from "@lib/parseDate";
|
||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
import slugify from "@lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
|
||||
@@ -66,6 +66,7 @@ type BookingFormValues = {
|
||||
locationType?: LocationType;
|
||||
guests?: string[];
|
||||
phone?: string;
|
||||
hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers
|
||||
customInputs?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -76,6 +77,7 @@ const BookingPage = ({
|
||||
booking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
recurringEventCount,
|
||||
locationLabels,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
@@ -88,6 +90,17 @@ const BookingPage = ({
|
||||
const { contracts } = useContracts();
|
||||
const { data: session } = useSession();
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
@@ -97,11 +110,11 @@ const BookingPage = ({
|
||||
/* @ts-ignore */
|
||||
router.replace(`/${eventOwner.username}`);
|
||||
}
|
||||
}, [contracts, eventType.metadata.smartContractAddress, router]);
|
||||
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { attendees, paymentUid } = responseData;
|
||||
const { id, attendees, paymentUid } = responseData;
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
@@ -135,6 +148,39 @@ const BookingPage = ({
|
||||
email: attendees[0].email,
|
||||
location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const recurringMutation = useMutation(createRecurringBooking, {
|
||||
onSuccess: async (responseData = []) => {
|
||||
const { attendees = [], id, recurringEventId } = responseData[0] || {};
|
||||
const location = (function humanReadableLocation(location) {
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (location.includes("integration")) {
|
||||
return t("web_conferencing_details_to_follow");
|
||||
}
|
||||
return location;
|
||||
})(responseData[0].location);
|
||||
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
query: {
|
||||
date,
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
recur: recurringEventId,
|
||||
user: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -148,7 +194,7 @@ const BookingPage = ({
|
||||
|
||||
const eventTypeDetail = { isWeb3Active: false, ...eventType };
|
||||
|
||||
type Location = { type: LocationType; address?: string; link?: string };
|
||||
type Location = { type: LocationType; address?: string; link?: string; hostPhoneNumber?: string };
|
||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||
const locations: Location[] = useMemo(
|
||||
() => (eventType.locations as Location[]) || [],
|
||||
@@ -161,8 +207,6 @@ const BookingPage = ({
|
||||
}
|
||||
}, [router.query.guest]);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
|
||||
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
|
||||
const guestListEmails = !isDynamicGroupBooking
|
||||
@@ -225,7 +269,9 @@ const BookingPage = ({
|
||||
})(),
|
||||
});
|
||||
|
||||
const getLocationValue = (booking: Pick<BookingFormValues, "locationType" | "phone">) => {
|
||||
const getLocationValue = (
|
||||
booking: Pick<BookingFormValues, "locationType" | "phone" | "hostPhoneNumber">
|
||||
) => {
|
||||
const { locationType } = booking;
|
||||
switch (locationType) {
|
||||
case LocationType.Phone: {
|
||||
@@ -237,16 +283,33 @@ const BookingPage = ({
|
||||
case LocationType.Link: {
|
||||
return locationInfo(locationType)?.link || "";
|
||||
}
|
||||
case LocationType.UserPhone: {
|
||||
return locationInfo(locationType)?.hostPhoneNumber || "";
|
||||
}
|
||||
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||
default:
|
||||
return selectedLocation || "";
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the booking date(s)
|
||||
let recurringStrings: string[] = [],
|
||||
recurringDates: Date[] = [];
|
||||
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: parseInt(recurringEventCount.toString()),
|
||||
},
|
||||
i18n
|
||||
);
|
||||
}
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
telemetryEventTypes.bookingConfirmed,
|
||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
@@ -265,7 +328,7 @@ const BookingPage = ({
|
||||
{}
|
||||
);
|
||||
|
||||
let web3Details;
|
||||
let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
|
||||
if (eventTypeDetail.metadata.smartContractAddress) {
|
||||
web3Details = {
|
||||
// @ts-ignore
|
||||
@@ -274,28 +337,59 @@ const BookingPage = ({
|
||||
};
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
if (recurringDates.length) {
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(recurringDate).format(),
|
||||
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
// Added to track down the number of actual occurrences selected by the user
|
||||
recurringCount: recurringDates.length,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}));
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const disableInput = !!rescheduleUid;
|
||||
@@ -331,7 +425,7 @@ const BookingPage = ({
|
||||
"main overflow-hidden",
|
||||
isEmbed ? "" : "border border-gray-200",
|
||||
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
|
||||
"rounded-md sm:border sm:dark:border-gray-600"
|
||||
"rounded-md dark:border-gray-600 sm:border"
|
||||
)}>
|
||||
<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">
|
||||
@@ -375,10 +469,41 @@ const BookingPage = ({
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinghighlight mb-4">
|
||||
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{parseDate(date, i18n)}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="mb-3 text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{`${t("every_for_freq", {
|
||||
freq: t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`),
|
||||
})} ${recurringEventCount} ${t(
|
||||
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: parseInt(recurringEventCount.toString()) }
|
||||
)}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight mb-4 flex">
|
||||
<CalendarIcon className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
<div className="-mt-1">
|
||||
{(rescheduleUid || !eventType.recurringEvent.freq) &&
|
||||
parseDate(dayjs(date).tz(timeZone()), i18n)}
|
||||
{!rescheduleUid &&
|
||||
eventType.recurringEvent.freq &&
|
||||
recurringStrings.slice(0, 5).map((aDate, key) => <p key={key}>{aDate}</p>)}
|
||||
{!rescheduleUid && eventType.recurringEvent.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((aDate, key) => (
|
||||
<p key={key}>{aDate}</p>
|
||||
))}>
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
{t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
|
||||
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
|
||||
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
||||
@@ -429,13 +554,22 @@ const BookingPage = ({
|
||||
{...bookingForm.register("email")}
|
||||
required
|
||||
className={classNames(
|
||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
||||
"focus:border-brand block w-full rounded-sm shadow-sm focus:ring-black dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
||||
disableInput ? "bg-gray-200 dark:text-gray-500" : "",
|
||||
bookingForm.formState.errors.email
|
||||
? "border-red-700 focus:ring-red-700"
|
||||
: " border-gray-300 dark:border-gray-900"
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
|
||||
disabled={disableInput}
|
||||
/>
|
||||
{bookingForm.formState.errors.email && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<ExclamationCircleIcon className="mr-2 h-3 w-3" />
|
||||
<p>{t("email_validation_error")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{locations.length > 1 && (
|
||||
|
||||
@@ -3,15 +3,15 @@ import { CheckIcon } from "@heroicons/react/solid";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
export type ConfirmationDialogContentProps = {
|
||||
confirmBtn?: ReactNode;
|
||||
confirmBtnText?: string;
|
||||
cancelBtnText?: string;
|
||||
isLoading?: boolean;
|
||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
title: string;
|
||||
variety?: "danger" | "warning" | "success";
|
||||
@@ -25,6 +25,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||
confirmBtn = null,
|
||||
confirmBtnText = t("confirm"),
|
||||
cancelBtnText = t("cancel"),
|
||||
isLoading = false,
|
||||
onConfirm,
|
||||
children,
|
||||
} = props;
|
||||
@@ -59,10 +60,14 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
||||
<DialogClose onClick={onConfirm} asChild>
|
||||
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
|
||||
<DialogClose disabled={isLoading} onClick={onConfirm} asChild>
|
||||
{confirmBtn || (
|
||||
<Button color="primary" loading={isLoading}>
|
||||
{isLoading ? t("loading") : confirmBtnText}
|
||||
</Button>
|
||||
)}
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<DialogClose disabled={isLoading} asChild>
|
||||
<Button color="secondary">{cancelBtnText}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ExclamationIcon } from "@heroicons/react/outline";
|
||||
import { CheckIcon } from "@heroicons/react/solid";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
export type DeleteStripeDialogContentProps = {
|
||||
confirmBtn?: ReactNode;
|
||||
cancelAllBookingsBtnText?: string;
|
||||
removeBtnText?: string;
|
||||
cancelBtnText?: string;
|
||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
onRemove?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
title: string;
|
||||
variety?: "danger" | "warning" | "success";
|
||||
};
|
||||
|
||||
export default function DeleteStripeDialogContent(props: PropsWithChildren<DeleteStripeDialogContentProps>) {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
title,
|
||||
variety,
|
||||
confirmBtn = null,
|
||||
cancelAllBookingsBtnText,
|
||||
removeBtnText,
|
||||
cancelBtnText = t("cancel"),
|
||||
onConfirm,
|
||||
onRemove,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<div className="flex">
|
||||
{variety && (
|
||||
<div className="mt-0.5 ltr:mr-3">
|
||||
{variety === "danger" && (
|
||||
<div className="mx-auto rounded-full bg-red-100 p-2 text-center">
|
||||
<ExclamationIcon className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "warning" && (
|
||||
<div className="mx-auto rounded-full bg-orange-100 p-2 text-center">
|
||||
<ExclamationIcon className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "success" && (
|
||||
<div className="mx-auto rounded-full bg-green-100 p-2 text-center">
|
||||
<CheckIcon className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DialogPrimitive.Title className="font-cal text-xl text-gray-900">{title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-sm text-neutral-500">
|
||||
{children}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
||||
<DialogClose onClick={onConfirm} asChild>
|
||||
<Button color="alert">{cancelAllBookingsBtnText}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose onClick={onRemove} asChild>
|
||||
<Button color="alert2">{removeBtnText}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{cancelBtnText}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
|
||||
@@ -104,6 +104,11 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "BAD_REQUEST") {
|
||||
const message = `${err.data.code}: URL already exists.`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
const message = `${err.data.code}: You are not able to create this event`;
|
||||
showToast(message, "error");
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ClockIcon, CreditCardIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
||||
import { ClockIcon, CreditCardIcon, RefreshIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||
select: {
|
||||
@@ -14,6 +16,7 @@ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||
price: true,
|
||||
currency: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
@@ -28,6 +31,11 @@ export type EventTypeDescriptionProps = {
|
||||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const recurringEvent: RecurringEvent = useMemo(
|
||||
() => (eventType.recurringEvent as RecurringEvent) || [],
|
||||
[eventType.recurringEvent]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
||||
@@ -54,6 +62,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||
{t("1_on_1")}
|
||||
</li>
|
||||
)}
|
||||
{recurringEvent?.count && recurringEvent.count > 0 && (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<RefreshIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{t("repeats_up_to", { count: recurringEvent.count })}
|
||||
</li>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<CreditCardIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type RecurringEventControllerProps = {
|
||||
recurringEvent: RecurringEvent;
|
||||
formMethods: UseFormReturn<any, any>;
|
||||
paymentEnabled: boolean;
|
||||
onRecurringEventDefined: Function;
|
||||
};
|
||||
|
||||
export default function RecurringEventController({
|
||||
recurringEvent,
|
||||
formMethods,
|
||||
paymentEnabled,
|
||||
onRecurringEventDefined,
|
||||
}: RecurringEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const [recurringEventDefined, setRecurringEventDefined] = useState(recurringEvent?.count !== undefined);
|
||||
|
||||
const [recurringEventInterval, setRecurringEventInterval] = useState(recurringEvent?.interval || 1);
|
||||
const [recurringEventFrequency, setRecurringEventFrequency] = useState(
|
||||
recurringEvent?.freq || RRuleFrequency.WEEKLY
|
||||
);
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(recurringEvent?.count || 12);
|
||||
|
||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||
const recurringEventFreqOptions = Object.entries(RRuleFrequency)
|
||||
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
|
||||
.map(([key, value]) => ({
|
||||
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventInterval }),
|
||||
value: value.toString(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
<label htmlFor="recurringEvent" className="flex text-sm font-medium text-neutral-700">
|
||||
{t("recurring_event")}
|
||||
</label>
|
||||
</div>
|
||||
<div className={!paymentEnabled ? "w-full" : ""}>
|
||||
{paymentEnabled ? (
|
||||
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
onChange={(event) => {
|
||||
setRecurringEventDefined(event?.target.checked);
|
||||
onRecurringEventDefined(event?.target.checked);
|
||||
if (!event?.target.checked) {
|
||||
formMethods.setValue("recurringEvent", {});
|
||||
} else {
|
||||
formMethods.setValue(
|
||||
"recurringEvent",
|
||||
recurringEventDefined
|
||||
? recurringEvent
|
||||
: {
|
||||
interval: 1,
|
||||
count: 12,
|
||||
freq: RRuleFrequency.WEEKLY,
|
||||
}
|
||||
);
|
||||
}
|
||||
recurringEvent = formMethods.getValues("recurringEvent");
|
||||
}}
|
||||
type="checkbox"
|
||||
className="text-primary-600 h-4 w-4 rounded border-gray-300"
|
||||
defaultChecked={recurringEventDefined}
|
||||
data-testid="recurring-event-check"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-neutral-900">{t("recurring_event_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible
|
||||
open={recurringEventDefined}
|
||||
data-testid="recurring-event-collapsible"
|
||||
onOpenChange={() => setRecurringEventDefined(!recurringEventDefined)}>
|
||||
<CollapsibleContent className="mt-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
defaultValue={recurringEvent?.interval || 1}
|
||||
onChange={(event) => {
|
||||
setRecurringEventInterval(parseInt(event?.target.value));
|
||||
recurringEvent.interval = parseInt(event?.target.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventFrequency]}
|
||||
isSearchable={false}
|
||||
className="w-18 block min-w-0 rounded-sm sm:text-sm"
|
||||
onChange={(e) => {
|
||||
if (e?.value) {
|
||||
setRecurringEventFrequency(parseInt(e?.value));
|
||||
recurringEvent.freq = parseInt(e?.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("max")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
defaultValue={recurringEvent?.count || 12}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
recurringEvent.count = parseInt(event?.target.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
}}
|
||||
/>
|
||||
<p className="mr-2 text-neutral-900">
|
||||
{t(`${RRuleFrequency[recurringEventFrequency].toString().toLowerCase()}`, {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
|
||||
import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent";
|
||||
|
||||
export default function DisconnectStripeIntegration(props: {
|
||||
/** Integration credential id */
|
||||
id: number;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
async (action: string) => {
|
||||
const res = await fetch("/api/integrations", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.id, action }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await props.onOpenChange(modalOpen);
|
||||
},
|
||||
onSuccess(data) {
|
||||
showToast(data.message, "success");
|
||||
setModalOpen(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DeleteStripeDialogContent
|
||||
variety="warning"
|
||||
title="Disconnect Stripe Integration"
|
||||
cancelAllBookingsBtnText="Cancel all bookings"
|
||||
removeBtnText="Remove payment"
|
||||
cancelBtnText="Cancel"
|
||||
onConfirm={() => {
|
||||
mutation.mutate("cancel");
|
||||
}}
|
||||
onRemove={() => {
|
||||
mutation.mutate("remove");
|
||||
}}>
|
||||
If you have unpaid and unconfirmed bookings, you must choose to cancel them or remove the required
|
||||
payment field.
|
||||
</DeleteStripeDialogContent>
|
||||
</Dialog>
|
||||
{props.render({
|
||||
onClick() {
|
||||
setModalOpen(true);
|
||||
},
|
||||
disabled: modalOpen,
|
||||
loading: mutation.isLoading,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,10 @@ function IntegrationListItem(props: {
|
||||
return (
|
||||
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
|
||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
|
||||
{props.imageSrc && <img className="h-10 w-10" src={props.imageSrc} alt={props.title} />}
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
props.imageSrc && <img className="h-10 w-10" src={props.imageSrc} alt={props.title} />
|
||||
}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3">{props.title}</ListItemTitle>
|
||||
<ListItemText component="p">{props.description}</ListItemText>
|
||||
|
||||
@@ -61,8 +61,8 @@ const ChangePasswordSection = () => {
|
||||
<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">
|
||||
<div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0">
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-1/2">
|
||||
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
|
||||
{t("current_password")}
|
||||
</label>
|
||||
@@ -79,7 +79,7 @@ const ChangePasswordSection = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 w-1/2">
|
||||
<div className="w-full sm:w-1/2">
|
||||
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
|
||||
{t("new_password")}
|
||||
</label>
|
||||
@@ -98,7 +98,7 @@ const ChangePasswordSection = () => {
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
<div className="flex justify-end py-8">
|
||||
<div className="flex py-8 sm:justify-end">
|
||||
<Button color="secondary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
@@ -150,7 +150,10 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<img src={dataUri} />
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={dataUri} alt="" />
|
||||
}
|
||||
</div>
|
||||
<p className="text-center font-mono text-xs">{secret}</p>
|
||||
</>
|
||||
|
||||
@@ -17,7 +17,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row justify-between truncate pt-9 pl-2">
|
||||
<div className="flex flex-col justify-between pt-9 pl-2 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
@@ -27,7 +27,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
|
||||
@@ -15,10 +15,11 @@ type MembershipRoleOption = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }];
|
||||
const options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
isOpen: boolean;
|
||||
currentMember: MembershipRole;
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
@@ -57,7 +58,6 @@ export default function MemberChangeRoleModal(props: {
|
||||
role: role.value,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContainer isOpen={props.isOpen} onExit={props.onExit}>
|
||||
<>
|
||||
@@ -76,7 +76,7 @@ export default function MemberChangeRoleModal(props: {
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={options}
|
||||
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
value={role}
|
||||
onChange={(option) => option && setRole(option)}
|
||||
id="role"
|
||||
|
||||
@@ -16,6 +16,7 @@ import Select from "@components/ui/form/Select";
|
||||
type MemberInvitationModalProps = {
|
||||
isOpen: boolean;
|
||||
team: TeamWithMembers | null;
|
||||
currentMember: MembershipRole;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
@@ -24,7 +25,7 @@ type MembershipRoleOption = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }];
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
@@ -100,7 +101,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={options[0]}
|
||||
options={options}
|
||||
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import { PencilIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
@@ -14,12 +15,12 @@ import Dropdown, {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
@@ -49,6 +50,14 @@ export default function MemberListItem(props: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const ownersInTeam = () => {
|
||||
const { members } = props.team;
|
||||
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
|
||||
return owners.length;
|
||||
};
|
||||
|
||||
const currentUserId = useCurrentUserId();
|
||||
|
||||
const name =
|
||||
props.member.name ||
|
||||
(() => {
|
||||
@@ -65,7 +74,7 @@ export default function MemberListItem(props: Props) {
|
||||
<div className="flex w-full flex-col justify-between sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, name)}
|
||||
imageSrc={WEBSITE_URL + "/" + props.member.username + "/avatar.png"}
|
||||
alt={name || ""}
|
||||
className="h-9 w-9 rounded-full"
|
||||
/>
|
||||
@@ -121,8 +130,12 @@ export default function MemberListItem(props: Props) {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{(props.team.membership.role === MembershipRole.OWNER ||
|
||||
props.team.membership.role === MembershipRole.ADMIN) && (
|
||||
{((props.team.membership.role === MembershipRole.OWNER &&
|
||||
(props.member.role !== MembershipRole.OWNER ||
|
||||
ownersInTeam() > 1 ||
|
||||
props.member.id !== currentUserId)) ||
|
||||
(props.team.membership.role === MembershipRole.ADMIN &&
|
||||
props.member.role !== MembershipRole.OWNER)) && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
@@ -165,6 +178,7 @@ export default function MemberListItem(props: Props) {
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
isOpen={showChangeMemberRoleModal}
|
||||
currentMember={props.team.membership.role}
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
@@ -181,7 +195,7 @@ export default function MemberListItem(props: Props) {
|
||||
<div className="space-x-2 border-t py-5 rtl:space-x-reverse">
|
||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||
{props.team.membership.role !== MembershipRole.MEMBER && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`} passHref>
|
||||
<Button color="secondary">{t("Open Team Availability")}</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import showToast from "@calcom/lib/notification";
|
||||
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import TeamListItem from "./TeamListItem";
|
||||
|
||||
@@ -39,7 +39,9 @@ export default function TeamList(props: Props) {
|
||||
<TeamListItem
|
||||
key={team?.id as number}
|
||||
team={team}
|
||||
onActionSelect={(action: string) => selectAction(action, team?.id as number)}></TeamListItem>
|
||||
onActionSelect={(action: string) => selectAction(action, team?.id as number)}
|
||||
isLoading={deleteTeamMutation.isLoading}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { LogoutIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
TrashIcon,
|
||||
LinkIcon,
|
||||
DotsHorizontalIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
@@ -16,15 +16,15 @@ import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.list">[number];
|
||||
key: number;
|
||||
onActionSelect: (text: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function TeamListItem(props: Props) {
|
||||
@@ -175,7 +176,10 @@ export default function TeamListItem(props: Props) {
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => props.onActionSelect("disband")}>
|
||||
isLoading={props.isLoading}
|
||||
onConfirm={() => {
|
||||
props.onActionSelect("disband");
|
||||
}}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -118,6 +118,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
|
||||
)}
|
||||
</div>
|
||||
{props.team?.id && props.role !== MembershipRole.MEMBER && (
|
||||
// eslint-disable-next-line @next/next/link-passhref
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<div className="mt-5 hidden space-y-1 sm:block">
|
||||
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
|
||||
|
||||
@@ -5,9 +5,9 @@ import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
import React from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
@@ -37,6 +37,7 @@ const Team = ({ team }: TeamPageProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/link-passhref
|
||||
<Link key={member.id} href={`/${member.username}`}>
|
||||
<div className={classes}>
|
||||
<ArrowRightIcon
|
||||
@@ -52,7 +53,7 @@ const Team = ({ team }: TeamPageProps) => {
|
||||
<div>
|
||||
<Avatar
|
||||
alt={member.name || ""}
|
||||
imageSrc={getPlaceholderAvatar(member.avatar, member.username)}
|
||||
imageSrc={WEBSITE_URL + "/" + member.username + "/avatar.png"}
|
||||
className="-mt-4 h-12 w-12"
|
||||
/>
|
||||
<section className="mt-2 w-full space-y-1">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
@@ -16,8 +17,9 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-center bg-neutral-50 py-12 sm:px-6 lg:px-8">
|
||||
<HeadSeo title={props.title} description={props.description} />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
|
||||
{props.showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mx-auto h-6" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" />
|
||||
)}
|
||||
{props.heading && (
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function Avatar(props: AvatarProps) {
|
||||
/>
|
||||
<AvatarPrimitive.Fallback delayMs={600}>
|
||||
{gravatarFallbackMd5 && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={defaultAvatarSrc({ md5: gravatarFallbackMd5 })} alt={alt} className={className} />
|
||||
)}
|
||||
</AvatarPrimitive.Fallback>
|
||||
|
||||
@@ -28,5 +28,6 @@ export function AvatarSSR(props: AvatarProps) {
|
||||
} else if (user.emailMd5) {
|
||||
imgSrc = defaultAvatarSrc(user.emailMd5);
|
||||
}
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return imgSrc ? <img alt={alt} className={className} src={imgSrc}></img> : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
export default function InfoBadge({ content }: { content: string }) {
|
||||
return (
|
||||
|
||||
@@ -12,16 +12,22 @@ const PoweredByCal = () => {
|
||||
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
|
||||
<a target="_blank" className="text-bookinglight opacity-50 hover:opacity-100 dark:text-white">
|
||||
{t("powered_by")}{" "}
|
||||
<img
|
||||
className="relative -mt-px inline h-[10px] w-auto dark:hidden"
|
||||
src="https://cal.com/logo.svg"
|
||||
alt="Cal.com Logo"
|
||||
/>
|
||||
<img
|
||||
className="relativ -mt-px hidden h-[10px] w-auto dark:inline"
|
||||
src="https://cal.com/logo-white.svg"
|
||||
alt="Cal.com Logo"
|
||||
/>
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="relative -mt-px inline h-[10px] w-auto dark:hidden"
|
||||
src="/cal-logo-word.svg"
|
||||
alt="Cal.com Logo"
|
||||
/>
|
||||
}
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="relativ -mt-px hidden h-[10px] w-auto dark:inline"
|
||||
src="/cal-logo-word-dark.svg"
|
||||
alt="Cal.com Logo"
|
||||
/>
|
||||
}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ interface WeekdaySelectProps {
|
||||
}
|
||||
|
||||
export const WeekdaySelect = (props: WeekdaySelectProps) => {
|
||||
const { onSelect } = props;
|
||||
const [activeDays, setActiveDays] = useState<boolean[]>(
|
||||
Array.from(Array(7).keys()).map((v, i) => (props.defaultValue || []).includes(i))
|
||||
);
|
||||
@@ -13,8 +14,8 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
|
||||
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
useEffect(() => {
|
||||
props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
|
||||
}, [activeDays]);
|
||||
onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
|
||||
}, [onSelect, activeDays]);
|
||||
|
||||
const toggleDay = (idx: number) => {
|
||||
activeDays[idx] = !activeDays[idx];
|
||||
|
||||
@@ -24,12 +24,13 @@ export type CheckedSelectProps = {
|
||||
};
|
||||
|
||||
export const CheckedSelect = (props: CheckedSelectProps) => {
|
||||
const { onChange } = props;
|
||||
const [selectedOptions, setSelectedOptions] = useState<CheckedSelectValue>(props.defaultValue || []);
|
||||
const { t } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
props.onChange(selectedOptions);
|
||||
}, [selectedOptions]);
|
||||
onChange(selectedOptions);
|
||||
}, [onChange, selectedOptions]);
|
||||
|
||||
const options = props.options.map((option) => ({
|
||||
...option,
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function WebhookDialogForm(props: {
|
||||
subscriberUrl: "",
|
||||
active: true,
|
||||
payloadTemplate: null,
|
||||
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId">,
|
||||
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
|
||||
} = props;
|
||||
|
||||
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
|
||||
@@ -58,7 +58,9 @@ export default function WebhookDialogForm(props: {
|
||||
props.handleClose();
|
||||
}}
|
||||
className="space-y-4">
|
||||
<input type="hidden" {...form.register("id")} />
|
||||
<div>
|
||||
<input type="hidden" {...form.register("id")} />
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
<Controller
|
||||
@@ -76,20 +78,21 @@ export default function WebhookDialogForm(props: {
|
||||
/>
|
||||
</InputGroupBox>
|
||||
</fieldset>
|
||||
<TextField
|
||||
label={t("subscriber_url")}
|
||||
{...form.register("subscriberUrl")}
|
||||
required
|
||||
type="url"
|
||||
onChange={(e) => {
|
||||
form.setValue("subscriberUrl", e.target.value);
|
||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
||||
setUseCustomPayloadTemplate(true);
|
||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={t("subscriber_url")}
|
||||
{...form.register("subscriberUrl")}
|
||||
required
|
||||
type="url"
|
||||
onChange={(e) => {
|
||||
form.setValue("subscriberUrl", e.target.value);
|
||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
||||
setUseCustomPayloadTemplate(true);
|
||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
|
||||
@@ -3,12 +3,12 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { ListItem } from "@components/List";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
|
||||
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
type contractsContextType = Record<string, string>;
|
||||
|
||||
const contractsContextDefaultValue: contractsContextType = {};
|
||||
@@ -21,18 +23,17 @@ interface addContractsPayload {
|
||||
|
||||
export function ContractsProvider({ children }: Props) {
|
||||
const addContract = (payload: addContractsPayload) => {
|
||||
window.localStorage.setItem(
|
||||
localStorage.setItem(
|
||||
"contracts",
|
||||
JSON.stringify({
|
||||
...JSON.parse(window.localStorage.getItem("contracts") || "{}"),
|
||||
...JSON.parse(localStorage.getItem("contracts") || "{}"),
|
||||
[payload.address]: payload.signature,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const value = {
|
||||
contracts:
|
||||
typeof window !== "undefined" ? JSON.parse(window.localStorage.getItem("contracts") || "{}") : {},
|
||||
contracts: typeof window !== "undefined" ? JSON.parse(localStorage.getItem("contracts") || "{}") : {},
|
||||
addContract,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import Button from "@calcom/ui/Button";
|
||||
|
||||
import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
const TrialBanner = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -8,11 +8,11 @@ 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 { Tooltip } from "@calcom/ui/Tooltip";
|
||||
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";
|
||||
@@ -102,17 +102,18 @@ export default function ApiKeyDialogForm(props: {
|
||||
setSuccessfulNewApiKeyModal(true);
|
||||
}}
|
||||
className="space-y-4">
|
||||
<div className=" mb-10 mt-1">
|
||||
<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>
|
||||
<TextField
|
||||
label={t("personal_note")}
|
||||
placeholder={t("personal_note_placeholder")}
|
||||
{...form.register("note")}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -24,12 +24,12 @@ export default function ApiKeyListContainer() {
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
<div className="flex flex-row justify-between truncate pl-2 pr-1 ">
|
||||
<div className="flex flex-col justify-between truncate pl-2 pr-1 sm:flex-row">
|
||||
<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">
|
||||
<div className="mb-9 sm:self-center">
|
||||
<Button StartIcon={PlusIcon} color="secondary" onClick={() => setNewApiKeyModal(true)}>
|
||||
{t("generate_new_api_key")}
|
||||
</Button>
|
||||
|
||||
@@ -7,11 +7,11 @@ 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 { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
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";
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ type Props = {
|
||||
eventType: { id: number };
|
||||
user: { username: string | null };
|
||||
location?: string | null;
|
||||
bookingId: number;
|
||||
};
|
||||
|
||||
type States =
|
||||
@@ -86,6 +87,7 @@ export default function PaymentComponent(props: Props) {
|
||||
type: props.eventType.id,
|
||||
user: props.user.username,
|
||||
name,
|
||||
bookingId: props.bookingId,
|
||||
};
|
||||
|
||||
if (props.location) {
|
||||
|
||||
@@ -49,7 +49,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
embedIframeWidth = e.detail.data.iframeWidth as number;
|
||||
});
|
||||
}
|
||||
}, [isEmbed]);
|
||||
}, [date, isEmbed]);
|
||||
|
||||
const eventName = props.booking.title;
|
||||
|
||||
@@ -137,6 +137,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
eventType={props.eventType}
|
||||
user={props.user}
|
||||
location={props.booking.location}
|
||||
bookingId={props.booking.id}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,8 @@ import utc from "dayjs/plugin/utc";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
@@ -36,7 +37,7 @@ export default function TeamAvailabilityModal(props: Props) {
|
||||
<div className="min-w-64 w-64 space-y-5 p-5 pr-0">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, props.member?.name as string)}
|
||||
imageSrc={WEBSITE_URL + "/" + props.member?.username + "/avatar.png"}
|
||||
alt={props.member?.name || ""}
|
||||
className="h-14 w-14 rounded-full"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,8 @@ import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
@@ -45,7 +46,7 @@ export default function TeamAvailabilityScreen(props: Props) {
|
||||
HeaderComponent={
|
||||
<div className="mb-6 flex items-center">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(member?.avatar, member?.name as string)}
|
||||
imageSrc={WEBSITE_URL + "/" + member.username + "/avatar.png"}
|
||||
alt={member?.name || ""}
|
||||
className="min-w-10 min-h-10 mt-1 h-10 w-10 rounded-full"
|
||||
/>
|
||||
|
||||
@@ -89,12 +89,15 @@ const CryptoSection = (props: CryptoSectionProps) => {
|
||||
}
|
||||
|
||||
return <div />;
|
||||
}, [props.verified]);
|
||||
}, [props.pathname, props.verified]);
|
||||
|
||||
const verifyButton = useMemo(() => {
|
||||
return (
|
||||
<Button color="secondary" onClick={verifyWallet} type="button" id="hasToken" name="hasToken">
|
||||
<img className="mr-1 h-5" src="/apps/metamask.svg" />
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mr-1 h-5" src="/apps/metamask.svg" alt="MetaMask" />
|
||||
}
|
||||
{t("verify_wallet")}
|
||||
</Button>
|
||||
);
|
||||
@@ -103,7 +106,10 @@ const CryptoSection = (props: CryptoSectionProps) => {
|
||||
const connectButton = useMemo(() => {
|
||||
return (
|
||||
<Button color="secondary" onClick={connectMetamask} type="button">
|
||||
<img className="mr-1 h-5" src="/apps/metamask.svg" />
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mr-1 h-5" src="/apps/metamask.svg" alt="MetaMask" />
|
||||
}
|
||||
{t("connect_metamask")}
|
||||
</Button>
|
||||
);
|
||||
@@ -118,7 +124,10 @@ const CryptoSection = (props: CryptoSectionProps) => {
|
||||
await connectMetamask();
|
||||
await verifyWallet();
|
||||
}}>
|
||||
<img className="mr-1 h-5" src="/apps/metamask.svg" />
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mr-1 h-5" src="/apps/metamask.svg" alt="MetaMask" />
|
||||
}
|
||||
{t("verify_wallet")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PaymentType, Prisma } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { createPaymentLink } from "@calcom/stripe/client";
|
||||
@@ -16,8 +17,8 @@ export type PaymentInfo = {
|
||||
id?: string | null;
|
||||
};
|
||||
|
||||
const paymentFeePercentage = process.env.PAYMENT_FEE_PERCENTAGE!;
|
||||
const paymentFeeFixed = process.env.PAYMENT_FEE_FIXED!;
|
||||
let paymentFeePercentage: number | undefined;
|
||||
let paymentFeeFixed: number | undefined;
|
||||
|
||||
export async function handlePayment(
|
||||
evt: CalendarEvent,
|
||||
@@ -33,6 +34,10 @@ export async function handlePayment(
|
||||
uid: string;
|
||||
}
|
||||
) {
|
||||
const appKeys = await getAppKeysFromSlug("stripe");
|
||||
if (typeof appKeys.payment_fee_fixed === "number") paymentFeePercentage = appKeys.payment_fee_fixed;
|
||||
if (typeof appKeys.payment_fee_percentage === "number") paymentFeeFixed = appKeys.payment_fee_percentage;
|
||||
|
||||
const paymentFee = Math.round(
|
||||
selectedEventType.price * parseFloat(`${paymentFeePercentage}`) + parseInt(`${paymentFeeFixed}`)
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import EventManager from "@calcom/core/EventManager";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import stripe from "@calcom/stripe/server";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { IS_PRODUCTION } from "@lib/config/constants";
|
||||
import { HttpError as HttpCode } from "@lib/core/http/error";
|
||||
@@ -49,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
confirmed: true,
|
||||
attendees: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
@@ -70,6 +71,23 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
|
||||
if (!booking) throw new Error("No booking found");
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({ recurringEvent: true });
|
||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({ select: eventTypeSelect });
|
||||
type EventTypeRaw = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: booking.eventTypeId,
|
||||
},
|
||||
select: eventTypeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
const eventType = {
|
||||
recurringEvent: (eventTypeRaw?.recurringEvent || {}) as RecurringEvent,
|
||||
};
|
||||
|
||||
const { user } = booking;
|
||||
|
||||
if (!user) throw new Error("No user found");
|
||||
@@ -137,7 +155,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
|
||||
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
||||
|
||||
await sendScheduledEmails({ ...evt });
|
||||
await sendScheduledEmails({ ...evt }, eventType.recurringEvent);
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
|
||||
@@ -21,6 +21,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
bookingId: true,
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
description: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Alert } from "@calcom/ui/Alert";
|
||||
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import Shell, { useMeQuery } from "@components/Shell";
|
||||
import Shell from "@components/Shell";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
export function TeamAvailabilityPage() {
|
||||
|
||||
@@ -3,6 +3,8 @@ import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
import { isBrowserLocale24h } from "./timeFormat";
|
||||
|
||||
dayjs.extend(utc);
|
||||
@@ -21,11 +23,11 @@ const timeOptions: TimeOptions = {
|
||||
const isInitialized = false;
|
||||
|
||||
const initClock = () => {
|
||||
if (typeof localStorage === "undefined" || isInitialized) {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
// This only sets browser locale if there's no preference on localStorage.
|
||||
if (!localStorage || !localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
|
||||
if (!localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
|
||||
timeOptions.is24hClock = localStorage.getItem("timeOption.is24hClock") === "true";
|
||||
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
import { recurringEvent } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent, Person, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
|
||||
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
|
||||
@@ -17,14 +18,14 @@ import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-reschedul
|
||||
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
|
||||
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
||||
|
||||
export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendScheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
@@ -36,7 +37,7 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerScheduledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerScheduledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
||||
@@ -47,14 +48,14 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendRescheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
@@ -66,7 +67,7 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerRescheduledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerRescheduledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
||||
@@ -77,10 +78,13 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerRequestEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const organizerRequestEmail = new OrganizerRequestEmail(calEvent);
|
||||
const organizerRequestEmail = new OrganizerRequestEmail(calEvent, recurringEvent);
|
||||
resolve(organizerRequestEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestEmail.sendEmail failed", e));
|
||||
@@ -88,10 +92,14 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => {
|
||||
export const sendAttendeeRequestEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
attendee: Person,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee);
|
||||
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(attendeeRequestEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendRequestEmail.sendEmail failed", e));
|
||||
@@ -99,14 +107,14 @@ export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee
|
||||
});
|
||||
};
|
||||
|
||||
export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendDeclinedEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee);
|
||||
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(declinedEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
@@ -118,14 +126,14 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendCancelledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
|
||||
@@ -137,7 +145,7 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerCancelledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerCancelledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
|
||||
@@ -148,10 +156,13 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerRequestReminderEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent);
|
||||
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent, recurringEvent);
|
||||
resolve(organizerRequestReminderEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", e));
|
||||
@@ -159,14 +170,17 @@ export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent)
|
||||
});
|
||||
};
|
||||
|
||||
export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendAwaitingPaymentEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee);
|
||||
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(paymentEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
|
||||
@@ -178,10 +192,13 @@ export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerPaymentRefundFailedEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent);
|
||||
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent, recurringEvent);
|
||||
resolve(paymentRefundFailedEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
|
||||
@@ -213,14 +230,19 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
|
||||
|
||||
export const sendRequestRescheduleEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
metadata: { rescheduleLink: string }
|
||||
metadata: { rescheduleLink: string },
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
|
||||
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(
|
||||
calEvent,
|
||||
metadata,
|
||||
recurringEvent
|
||||
);
|
||||
resolve(requestRescheduleEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
|
||||
@@ -231,7 +253,11 @@ export const sendRequestRescheduleEmail = async (
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata);
|
||||
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(
|
||||
calEvent,
|
||||
metadata,
|
||||
recurringEvent
|
||||
);
|
||||
resolve(requestRescheduleEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
|
||||
export default class BaseEmail {
|
||||
name: string = "";
|
||||
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
public sendEmail() {
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) return new Promise((r) => r("Skipped sendEmail for E2E"));
|
||||
new Promise((resolve, reject) =>
|
||||
nodemailer
|
||||
.createTransport(this.getMailerOptions().transport)
|
||||
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
|
||||
if (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
this.printNodeMailerError(err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(info);
|
||||
}
|
||||
})
|
||||
).catch((e) => console.error("sendEmail", e));
|
||||
return new Promise((resolve) => resolve("send mail async"));
|
||||
}
|
||||
|
||||
protected getMailerOptions() {
|
||||
return {
|
||||
transport: serverConfig.transport,
|
||||
from: serverConfig.from,
|
||||
};
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
/** Don't clog the logs with unsent emails in E2E */
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) return;
|
||||
console.error(`${this.name}_ERROR`, error);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,9 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
|
||||
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.attendee.language.translate("event_request_declined")}
|
||||
${this.attendee.language.translate(
|
||||
this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
|
||||
)}
|
||||
${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
|
||||
${this.getWhat()}
|
||||
${this.getWhen()}
|
||||
@@ -75,7 +77,9 @@ ${this.getRejectionReason()}
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("xCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.attendee.language.translate("event_request_declined"),
|
||||
this.attendee.language.translate(
|
||||
this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
|
||||
),
|
||||
this.attendee.language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
|
||||
@@ -87,10 +87,17 @@ ${this.getAdditionalNotes()}
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("calendarCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("booking_submitted"),
|
||||
this.calEvent.organizer.language.translate("user_needs_to_confirm_or_reject_booking", {
|
||||
user: this.calEvent.organizer.name,
|
||||
})
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
|
||||
),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent.count
|
||||
? "user_needs_to_confirm_or_reject_booking_recurring"
|
||||
: "user_needs_to_confirm_or_reject_booking",
|
||||
{
|
||||
user: this.calEvent.organizer.name,
|
||||
}
|
||||
)
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
@@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
|
||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -24,8 +24,8 @@ dayjs.extend(toArray);
|
||||
|
||||
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
|
||||
private metadata: { rescheduleLink: string };
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
|
||||
super(calEvent);
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
|
||||
super(calEvent, recurringEvent);
|
||||
this.metadata = metadata;
|
||||
}
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
|
||||
@@ -4,20 +4,20 @@ import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray } from "ics";
|
||||
import nodemailer from "nodemailer";
|
||||
import rrule from "rrule";
|
||||
|
||||
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 type { CalendarEvent, Person, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import BaseEmail from "@lib/emails/templates/_base-email";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
emailSchedulingBodyHeader,
|
||||
emailBodyLogo,
|
||||
emailHead,
|
||||
emailScheduledBodyHeaderContent,
|
||||
emailSchedulingBodyDivider,
|
||||
emailSchedulingBodyHeader,
|
||||
linkIcon,
|
||||
} from "./common";
|
||||
|
||||
@@ -26,33 +26,25 @@ dayjs.extend(timezone);
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(toArray);
|
||||
|
||||
export default class AttendeeScheduledEmail {
|
||||
export default class AttendeeScheduledEmail extends BaseEmail {
|
||||
calEvent: CalendarEvent;
|
||||
attendee: Person;
|
||||
recurringEvent: RecurringEvent;
|
||||
|
||||
constructor(calEvent: CalendarEvent, attendee: Person) {
|
||||
constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
|
||||
super();
|
||||
this.name = "SEND_BOOKING_CONFIRMATION";
|
||||
this.calEvent = calEvent;
|
||||
this.attendee = attendee;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
new Promise((resolve, reject) =>
|
||||
nodemailer
|
||||
.createTransport(this.getMailerOptions().transport)
|
||||
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
|
||||
if (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
this.printNodeMailerError(err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(info);
|
||||
}
|
||||
})
|
||||
).catch((e) => console.error("sendEmail", e));
|
||||
return new Promise((resolve) => resolve("send mail async"));
|
||||
this.recurringEvent = recurringEvent;
|
||||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
// Taking care of recurrence rule beforehand
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (this.recurringEvent?.count) {
|
||||
recurrenceRule = new rrule(this.recurringEvent).toString().replace("RRULE:", "");
|
||||
}
|
||||
const icsEvent = createEvent({
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
@@ -72,6 +64,7 @@ export default class AttendeeScheduledEmail {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
})),
|
||||
...{ recurrenceRule },
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
@@ -105,12 +98,6 @@ export default class AttendeeScheduledEmail {
|
||||
};
|
||||
}
|
||||
|
||||
protected getMailerOptions() {
|
||||
return {
|
||||
transport: serverConfig.transport,
|
||||
from: serverConfig.from,
|
||||
};
|
||||
}
|
||||
protected getDescription(): string {
|
||||
if (!this.calEvent.description) return "";
|
||||
return `
|
||||
@@ -125,17 +112,15 @@ export default class AttendeeScheduledEmail {
|
||||
}
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled")}
|
||||
${this.calEvent.attendees[0].language.translate(
|
||||
this.recurringEvent?.count ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled"
|
||||
)}
|
||||
${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
|
||||
|
||||
${getRichDescription(this.calEvent)}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.attendee.email, error);
|
||||
}
|
||||
|
||||
protected getHtmlBody(): string {
|
||||
const headerContent = this.calEvent.attendees[0].language.translate("confirmed_event_type_subject", {
|
||||
eventType: this.calEvent.type,
|
||||
@@ -157,7 +142,11 @@ ${getRichDescription(this.calEvent)}
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("checkCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled"),
|
||||
this.calEvent.attendees[0].language.translate(
|
||||
this.recurringEvent?.count
|
||||
? "your_event_has_been_scheduled_recurring"
|
||||
: "your_event_has_been_scheduled"
|
||||
),
|
||||
this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
@@ -250,12 +239,30 @@ ${getRichDescription(this.calEvent)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected getRecurringWhen(): string {
|
||||
if (this.recurringEvent?.freq) {
|
||||
return ` - ${this.calEvent.attendees[0].language.translate("every_for_freq", {
|
||||
freq: this.calEvent.attendees[0].language.translate(
|
||||
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`
|
||||
),
|
||||
})} ${this.recurringEvent.count} ${this.calEvent.attendees[0].language.translate(
|
||||
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: this.recurringEvent.count }
|
||||
)}`;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected getWhen(): string {
|
||||
return `
|
||||
<p style="height: 6px"></p>
|
||||
<div style="line-height: 6px;">
|
||||
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("when")}</p>
|
||||
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("when")}${
|
||||
this.recurringEvent?.count ? this.getRecurringWhen() : ""
|
||||
}</p>
|
||||
<p style="color: #494949; font-weight: 400; line-height: 24px;">
|
||||
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
|
||||
${this.calEvent.attendees[0].language.translate(
|
||||
this.getInviteeStart().format("dddd").toLowerCase()
|
||||
)}, ${this.calEvent.attendees[0].language.translate(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { TFunction } from "next-i18next";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import BaseEmail from "@lib/emails/templates/_base-email";
|
||||
|
||||
import { emailHead, linkIcon, emailBodyLogo } from "./common";
|
||||
import { emailBodyLogo, emailHead, linkIcon } from "./common";
|
||||
|
||||
export type PasswordReset = {
|
||||
language: TFunction;
|
||||
@@ -17,37 +15,15 @@ export type PasswordReset = {
|
||||
|
||||
export const PASSWORD_RESET_EXPIRY_HOURS = 6;
|
||||
|
||||
export default class ForgotPasswordEmail {
|
||||
export default class ForgotPasswordEmail extends BaseEmail {
|
||||
passwordEvent: PasswordReset;
|
||||
|
||||
constructor(passwordEvent: PasswordReset) {
|
||||
super();
|
||||
this.name = "SEND_PASSWORD_RESET_EMAIL";
|
||||
this.passwordEvent = passwordEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
new Promise((resolve, reject) =>
|
||||
nodemailer
|
||||
.createTransport(this.getMailerOptions().transport)
|
||||
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
|
||||
if (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
this.printNodeMailerError(err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(info);
|
||||
}
|
||||
})
|
||||
).catch((e) => console.error("sendEmail", e));
|
||||
return new Promise((resolve) => resolve("send mail async"));
|
||||
}
|
||||
|
||||
protected getMailerOptions() {
|
||||
return {
|
||||
transport: serverConfig.transport,
|
||||
from: serverConfig.from,
|
||||
};
|
||||
}
|
||||
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {
|
||||
to: `${this.passwordEvent.user.name} <${this.passwordEvent.user.email}>`,
|
||||
@@ -58,10 +34,6 @@ export default class ForgotPasswordEmail {
|
||||
};
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_PASSWORD_RESET_EMAIL_ERROR", this.passwordEvent.user.email, error);
|
||||
}
|
||||
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.passwordEvent.language("reset_password_subject")}
|
||||
|
||||
@@ -86,7 +86,9 @@ ${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming"
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("calendarCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("event_awaiting_approval"),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "event_awaiting_approval_recurring" : "event_awaiting_approval"
|
||||
),
|
||||
this.calEvent.organizer.language.translate("someone_requested_an_event")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
|
||||
@@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
|
||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -24,8 +24,8 @@ dayjs.extend(toArray);
|
||||
|
||||
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
|
||||
private metadata: { rescheduleLink: string };
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
|
||||
super(calEvent);
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
|
||||
super(calEvent, recurringEvent);
|
||||
this.metadata = metadata;
|
||||
}
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
|
||||
@@ -4,20 +4,20 @@ import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
import nodemailer from "nodemailer";
|
||||
import rrule from "rrule";
|
||||
|
||||
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 type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import BaseEmail from "@lib/emails/templates/_base-email";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
emailSchedulingBodyHeader,
|
||||
emailBodyLogo,
|
||||
emailHead,
|
||||
emailScheduledBodyHeaderContent,
|
||||
emailSchedulingBodyDivider,
|
||||
emailSchedulingBodyHeader,
|
||||
linkIcon,
|
||||
} from "./common";
|
||||
|
||||
@@ -26,31 +26,23 @@ dayjs.extend(timezone);
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(toArray);
|
||||
|
||||
export default class OrganizerScheduledEmail {
|
||||
export default class OrganizerScheduledEmail extends BaseEmail {
|
||||
calEvent: CalendarEvent;
|
||||
recurringEvent: RecurringEvent;
|
||||
|
||||
constructor(calEvent: CalendarEvent) {
|
||||
constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
|
||||
super();
|
||||
this.name = "SEND_BOOKING_CONFIRMATION";
|
||||
this.calEvent = calEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
new Promise((resolve, reject) =>
|
||||
nodemailer
|
||||
.createTransport(this.getMailerOptions().transport)
|
||||
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
|
||||
if (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
this.printNodeMailerError(err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(info);
|
||||
}
|
||||
})
|
||||
).catch((e) => console.error("sendEmail", e));
|
||||
return new Promise((resolve) => resolve("send mail async"));
|
||||
this.recurringEvent = recurringEvent;
|
||||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
// Taking care of recurrence rule beforehand
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (this.recurringEvent?.count) {
|
||||
recurrenceRule = new rrule(this.recurringEvent).toString().replace("RRULE:", "");
|
||||
}
|
||||
const icsEvent = createEvent({
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
@@ -66,6 +58,7 @@ export default class OrganizerScheduledEmail {
|
||||
description: this.getTextBody(),
|
||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||
...{ recurrenceRule },
|
||||
attendees: this.calEvent.attendees.map((attendee: Person) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
@@ -112,26 +105,17 @@ export default class OrganizerScheduledEmail {
|
||||
};
|
||||
}
|
||||
|
||||
protected getMailerOptions() {
|
||||
return {
|
||||
transport: serverConfig.transport,
|
||||
from: serverConfig.from,
|
||||
};
|
||||
}
|
||||
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.calEvent.organizer.language.translate("new_event_scheduled")}
|
||||
${this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
|
||||
)}
|
||||
${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
|
||||
|
||||
${getRichDescription(this.calEvent)}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.organizer.email, error);
|
||||
}
|
||||
|
||||
protected getHtmlBody(): string {
|
||||
const headerContent = this.calEvent.organizer.language.translate("confirmed_event_type_subject", {
|
||||
eventType: this.calEvent.type,
|
||||
@@ -153,7 +137,9 @@ ${getRichDescription(this.calEvent)}
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("checkCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("new_event_scheduled"),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
|
||||
),
|
||||
this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
@@ -240,12 +226,30 @@ ${getRichDescription(this.calEvent)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected getRecurringWhen(): string {
|
||||
if (this.recurringEvent?.freq) {
|
||||
return ` - ${this.calEvent.attendees[0].language.translate("every_for_freq", {
|
||||
freq: this.calEvent.attendees[0].language.translate(
|
||||
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`
|
||||
),
|
||||
})} ${this.recurringEvent.count} ${this.calEvent.attendees[0].language.translate(
|
||||
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: this.recurringEvent.count }
|
||||
)}`;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected getWhen(): string {
|
||||
return `
|
||||
<p style="height: 6px"></p>
|
||||
<div style="line-height: 6px;">
|
||||
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}</p>
|
||||
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}${
|
||||
this.recurringEvent?.count ? this.getRecurringWhen() : ""
|
||||
}</p>
|
||||
<p style="color: #494949; font-weight: 400; line-height: 24px;">
|
||||
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
|
||||
${this.calEvent.organizer.language.translate(
|
||||
this.getOrganizerStart().format("dddd").toLowerCase()
|
||||
)}, ${this.calEvent.organizer.language.translate(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { TFunction } from "next-i18next";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import BaseEmail from "@lib/emails/templates/_base-email";
|
||||
|
||||
import { emailHead, linkIcon, emailBodyLogo } from "./common";
|
||||
import { emailBodyLogo, emailHead, linkIcon } from "./common";
|
||||
|
||||
export type TeamInvite = {
|
||||
language: TFunction;
|
||||
@@ -14,37 +12,15 @@ export type TeamInvite = {
|
||||
joinLink: string;
|
||||
};
|
||||
|
||||
export default class TeamInviteEmail {
|
||||
export default class TeamInviteEmail extends BaseEmail {
|
||||
teamInviteEvent: TeamInvite;
|
||||
|
||||
constructor(teamInviteEvent: TeamInvite) {
|
||||
super();
|
||||
this.name = "SEND_TEAM_INVITE_EMAIL";
|
||||
this.teamInviteEvent = teamInviteEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
new Promise((resolve, reject) =>
|
||||
nodemailer
|
||||
.createTransport(this.getMailerOptions().transport)
|
||||
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
|
||||
if (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
this.printNodeMailerError(err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(info);
|
||||
}
|
||||
})
|
||||
).catch((e) => console.error("sendEmail", e));
|
||||
return new Promise((resolve) => resolve("send mail async"));
|
||||
}
|
||||
|
||||
protected getMailerOptions() {
|
||||
return {
|
||||
transport: serverConfig.transport,
|
||||
from: serverConfig.from,
|
||||
};
|
||||
}
|
||||
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {
|
||||
to: this.teamInviteEvent.to,
|
||||
@@ -58,10 +34,6 @@ export default class TeamInviteEmail {
|
||||
};
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_TEAM_INVITE_EMAIL_ERROR", this.teamInviteEvent.to, error);
|
||||
}
|
||||
|
||||
protected getTextBody(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Credential, SelectedCalendar } from "@prisma/client";
|
||||
|
||||
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
||||
import { getBusyVideoTimes } from "@calcom/core/videoClient";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
async function getBusyTimes(params: {
|
||||
credentials: Credential[];
|
||||
userId: number;
|
||||
eventTypeId?: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
selectedCalendars: SelectedCalendar[];
|
||||
}) {
|
||||
const { credentials, userId, eventTypeId, startTime, endTime, selectedCalendars } = params;
|
||||
const busyTimes: EventBusyDate[] = await prisma.booking
|
||||
.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
userId,
|
||||
eventTypeId,
|
||||
startTime: { gte: new Date(startTime) },
|
||||
endTime: { lte: new Date(endTime) },
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
{
|
||||
status: "PENDING",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
},
|
||||
})
|
||||
.then((bookings) => bookings.map((booking) => ({ end: booking.endTime, start: booking.startTime })));
|
||||
|
||||
if (credentials) {
|
||||
const calendarBusyTimes = await getBusyCalendarTimes(credentials, startTime, endTime, selectedCalendars);
|
||||
busyTimes.push(...calendarBusyTimes);
|
||||
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
|
||||
busyTimes.push(...videoBusyTimes);
|
||||
}
|
||||
|
||||
return busyTimes;
|
||||
}
|
||||
|
||||
export default getBusyTimes;
|
||||
@@ -0,0 +1,9 @@
|
||||
import useMeQuery from "./useMeQuery";
|
||||
|
||||
export const useCurrentUserId = () => {
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
return user?.id;
|
||||
};
|
||||
|
||||
export default useCurrentUserId;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { trpc } from "../trpc";
|
||||
|
||||
export function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
export default useMeQuery;
|
||||
@@ -122,6 +122,31 @@ export const useSlots = (props: UseSlotsProps) => {
|
||||
const dateTo = date.endOf("day").format();
|
||||
const query = stringify({ dateFrom, dateTo, eventTypeId });
|
||||
|
||||
const handleAvailableSlots = async (res: Response) => {
|
||||
const responseBody: AvailabilityUserResponse = await res.json();
|
||||
const times = getSlots({
|
||||
frequency: slotInterval || eventLength,
|
||||
inviteeDate: date,
|
||||
workingHours: responseBody.workingHours,
|
||||
minimumBookingNotice,
|
||||
eventLength,
|
||||
});
|
||||
const filterTimeProps = {
|
||||
times,
|
||||
busy: responseBody.busy,
|
||||
eventLength,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
};
|
||||
const filteredTimes = getFilteredTimes(filterTimeProps);
|
||||
// temporary
|
||||
const user = res.url.substring(res.url.lastIndexOf("/") + 1, res.url.indexOf("?"));
|
||||
return filteredTimes.map((time) => ({
|
||||
time,
|
||||
users: [user],
|
||||
}));
|
||||
};
|
||||
|
||||
Promise.all<Slot[]>(
|
||||
users.map((user) => fetch(`/api/availability/${user.username}?${query}`).then(handleAvailableSlots))
|
||||
)
|
||||
@@ -173,32 +198,17 @@ export const useSlots = (props: UseSlotsProps) => {
|
||||
console.error(e);
|
||||
setError(e);
|
||||
});
|
||||
}, [date]);
|
||||
|
||||
const handleAvailableSlots = async (res: Response) => {
|
||||
const responseBody: AvailabilityUserResponse = await res.json();
|
||||
const times = getSlots({
|
||||
frequency: slotInterval || eventLength,
|
||||
inviteeDate: date,
|
||||
workingHours: responseBody.workingHours,
|
||||
minimumBookingNotice,
|
||||
eventLength,
|
||||
});
|
||||
const filterTimeProps = {
|
||||
times,
|
||||
busy: responseBody.busy,
|
||||
eventLength,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
};
|
||||
const filteredTimes = getFilteredTimes(filterTimeProps);
|
||||
// temporary
|
||||
const user = res.url.substring(res.url.lastIndexOf("/") + 1, res.url.indexOf("?"));
|
||||
return filteredTimes.map((time) => ({
|
||||
time,
|
||||
users: [user],
|
||||
}));
|
||||
};
|
||||
}, [
|
||||
afterBufferTime,
|
||||
beforeBufferTime,
|
||||
eventLength,
|
||||
minimumBookingNotice,
|
||||
slotInterval,
|
||||
eventTypeId,
|
||||
props.schedulingType,
|
||||
users,
|
||||
date,
|
||||
]);
|
||||
|
||||
return {
|
||||
slots,
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function useTheme(theme?: Maybe<string>) {
|
||||
// TODO: isReady doesn't seem required now. This is also impacting PSI Score for pages which are using isReady.
|
||||
setIsReady(true);
|
||||
setTheme(theme);
|
||||
}, []);
|
||||
}, [theme]);
|
||||
|
||||
function Theme() {
|
||||
const code = applyThemeAndAddListener.toString();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
||||
|
||||
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
|
||||
|
||||
const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
|
||||
return Promise.all(
|
||||
data.map((booking, key) => {
|
||||
// We only want to send the first occurrence of the meeting at the moment, not all at once
|
||||
if (key === 0) {
|
||||
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", booking);
|
||||
} else {
|
||||
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", {
|
||||
...booking,
|
||||
noEmail: true,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export default createRecurringBooking;
|
||||
@@ -1,14 +1,42 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { I18n } from "next-i18next";
|
||||
import { RRule } from "rrule";
|
||||
|
||||
import { recurringEvent } from "@calcom/prisma/zod-utils";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
|
||||
import { parseZone } from "./parseZone";
|
||||
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
if (!date) return "No date";
|
||||
const processDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
const parsedZone = parseZone(date);
|
||||
if (!parsedZone?.isValid()) return "Invalid date";
|
||||
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
|
||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
||||
};
|
||||
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
if (!date) return ["No date"];
|
||||
return processDate(date, i18n);
|
||||
};
|
||||
|
||||
export const parseRecurringDates = (
|
||||
{
|
||||
startDate,
|
||||
recurringEvent,
|
||||
recurringCount,
|
||||
}: { startDate: string | null | Dayjs; recurringEvent: RecurringEvent; recurringCount: number },
|
||||
i18n: I18n
|
||||
): [string[], Date[]] => {
|
||||
const { count, ...restRecurringEvent } = recurringEvent;
|
||||
const rule = new RRule({
|
||||
...restRecurringEvent,
|
||||
count: recurringCount,
|
||||
dtstart: dayjs(startDate).toDate(),
|
||||
});
|
||||
const dateStrings = rule.all().map((r) => {
|
||||
return processDate(dayjs(r), i18n);
|
||||
});
|
||||
return [dateStrings, rule.all()];
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import getBusyTimes from "@lib/getBusyTimes";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
export async function getUserAvailability(query: {
|
||||
@@ -60,12 +59,14 @@ export async function getUserAvailability(query: {
|
||||
|
||||
const { selectedCalendars, ...currentUser } = rawUser;
|
||||
|
||||
const busyTimes = await getBusyCalendarTimes(
|
||||
currentUser.credentials,
|
||||
dateFrom.format(),
|
||||
dateTo.format(),
|
||||
selectedCalendars
|
||||
);
|
||||
const busyTimes = await getBusyTimes({
|
||||
credentials: currentUser.credentials,
|
||||
startTime: dateFrom.format(),
|
||||
endTime: dateTo.format(),
|
||||
eventTypeId: query.eventTypeId,
|
||||
userId: currentUser.id,
|
||||
selectedCalendars,
|
||||
});
|
||||
|
||||
const bufferedBusyTimes = busyTimes.map((a) => ({
|
||||
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
||||
|
||||
@@ -11,7 +11,6 @@ export type TeamWithMembers = AsyncReturnType<typeof getTeamWithMembers>;
|
||||
export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
username: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
name: true,
|
||||
id: true,
|
||||
@@ -44,6 +43,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||
length: true,
|
||||
slug: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
users: {
|
||||
@@ -72,7 +72,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||
...obj.user,
|
||||
isMissingSeat: obj.user.plan === UserPlan.FREE,
|
||||
role: membership?.role,
|
||||
accepted: membership?.role === "OWNER" ? true : membership?.accepted,
|
||||
accepted: membership?.accepted,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+26
-3
@@ -32,7 +32,7 @@ const splitAvailableTime = (
|
||||
const periodTime = initialTime + frequency;
|
||||
const slotEndTime = initialTime + eventLength;
|
||||
/*
|
||||
check if the slot end time surpasses availability end time of the user
|
||||
check if the slot end time surpasses availability end time of the user
|
||||
1 minute is added to round up the hour mark so that end of the slot is considered in the check instead of x9
|
||||
eg: if finalization time is 11:59, slotEndTime is 12:00, we ideally want the slot to be available
|
||||
*/
|
||||
@@ -64,9 +64,32 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours,
|
||||
const slots: Dayjs[] = [];
|
||||
|
||||
const slotsTimeFrameAvailable = [] as Array<WorkingHoursTimeFrame>;
|
||||
|
||||
// Here we split working hour in chunks for every frequency available that can fit in whole working hour
|
||||
// Here we split working hour in chunks for every frequency available that can fit in whole working hours
|
||||
const computedLocalWorkingHours: WorkingHoursTimeFrame[] = [];
|
||||
let tempComputeTimeFrame: WorkingHoursTimeFrame | undefined;
|
||||
const computeLength = localWorkingHours.length - 1;
|
||||
const makeTimeFrame = (item: typeof localWorkingHours[0]): WorkingHoursTimeFrame => ({
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
});
|
||||
localWorkingHours.forEach((item, index) => {
|
||||
if (!tempComputeTimeFrame) {
|
||||
tempComputeTimeFrame = makeTimeFrame(item);
|
||||
} else {
|
||||
// please check the comment in splitAvailableTime func for the added 1 minute
|
||||
if (tempComputeTimeFrame.endTime + 1 === item.startTime) {
|
||||
// to deal with time that across the day, e.g. from 11:59 to to 12:01
|
||||
tempComputeTimeFrame.endTime = item.endTime;
|
||||
} else {
|
||||
computedLocalWorkingHours.push(tempComputeTimeFrame);
|
||||
tempComputeTimeFrame = makeTimeFrame(item);
|
||||
}
|
||||
}
|
||||
if (index == computeLength) {
|
||||
computedLocalWorkingHours.push(tempComputeTimeFrame);
|
||||
}
|
||||
});
|
||||
computedLocalWorkingHours.forEach((item, index) => {
|
||||
slotsTimeFrameAvailable.push(...splitAvailableTime(item.startTime, item.endTime, frequency, eventLength));
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user