Compare commits
235 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2988870d5 | ||
|
|
f0ea8d30ca | ||
|
|
c3909ccc70 | ||
|
|
01e88b3807 | ||
|
|
effb9d56d9 | ||
|
|
3bbbc80511 | ||
|
|
19128fb08e | ||
|
|
0945bbe5cf | ||
|
|
ced6975fc8 | ||
|
|
fb436996c0 | ||
|
|
50f1fe544e | ||
|
|
746643bf8e | ||
|
|
65a69ef1e4 | ||
|
|
6483182ef6 | ||
|
|
784a91709c | ||
|
|
82a52e065f | ||
|
|
a1f6738cf1 | ||
|
|
a231945842 | ||
|
|
a507d5963c | ||
|
|
92806d5257 | ||
|
|
9440df4445 | ||
|
|
4e0efb76cd | ||
|
|
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 | ||
|
|
de0883b14b | ||
|
|
acc6db901c | ||
|
|
7f463830bd | ||
|
|
6a27fb2959 | ||
|
|
21867c9cd4 | ||
|
|
276821e0b5 | ||
|
|
8028b1ddad | ||
|
|
5abbd818d3 | ||
|
|
43944a7d31 | ||
|
|
8bdc137917 | ||
|
|
02fb15228b | ||
|
|
59a1db9068 | ||
|
|
8e956893ca | ||
|
|
d960e03acf | ||
|
|
99666440cf | ||
|
|
f274c0bde3 | ||
|
|
d1082e55a4 | ||
|
|
af0d1980c6 | ||
|
|
a6183e0ccf | ||
|
|
eea40c69f7 | ||
|
|
13ae773868 | ||
|
|
6f0fcc9d1b | ||
|
|
7d98c0bb1c | ||
|
|
82d77dc10f | ||
|
|
ae1f35f515 | ||
|
|
66f3fd2e07 | ||
|
|
cf346f6aa3 | ||
|
|
34d3aac4b0 | ||
|
|
c22b6ca670 | ||
|
|
fa1b29a99f | ||
|
|
d61238c832 | ||
|
|
28b432058a | ||
|
|
4360ada3e4 | ||
|
|
5336bf3fe2 | ||
|
|
6d5db1cb3a | ||
|
|
9fffaa20a2 | ||
|
|
fd73a4ac92 | ||
|
|
29a6c70fc3 | ||
|
|
96f6c644bd | ||
|
|
7c12bb1e20 | ||
|
|
10e796f956 | ||
|
|
071077f2dc | ||
|
|
afe957674c | ||
|
|
307b098f83 | ||
|
|
95a793dd5a | ||
|
|
a0057911c1 | ||
|
|
93c75b5fef | ||
|
|
53d7e57142 | ||
|
|
2c4a891a89 | ||
|
|
8e0c7759be | ||
|
|
41dc01ea3c | ||
|
|
9c985edb6b | ||
|
|
69ef309cb5 | ||
|
|
f10bf38292 | ||
|
|
02f68b104b | ||
|
|
8bc5a75249 | ||
|
|
97e4cca252 | ||
|
|
18d41b52a2 | ||
|
|
26c0f82edf | ||
|
|
c12436afb0 | ||
|
|
fead885aa4 | ||
|
|
e680bb1548 | ||
|
|
6e82d38249 | ||
|
|
9f63299a1a | ||
|
|
702f31c935 | ||
|
|
08db282a07 | ||
|
|
080a394bb3 | ||
|
|
8fb429e073 | ||
|
|
00a3ff89e4 | ||
|
|
8f3b854559 | ||
|
|
05edb144b2 | ||
|
|
8c173c840b | ||
|
|
b540f44d6c | ||
|
|
7493093a1a | ||
|
|
cf68541520 | ||
|
|
b4ee4413cc | ||
|
|
f214830d0f | ||
|
|
c92070a5a2 | ||
|
|
102ca5403d | ||
|
|
7fd57b88dc | ||
|
|
5f57694148 | ||
|
|
73c97e85d4 | ||
|
|
ccde0c20ab | ||
|
|
d2d3c67144 | ||
|
|
6d5af81f68 | ||
|
|
2e9d4125ed | ||
|
|
56c32beebc | ||
|
|
faa67e0bb6 | ||
|
|
ffebe8e901 | ||
|
|
2cafe2d98e | ||
|
|
d03038d976 | ||
|
|
7e392da78a | ||
|
|
f8f3456b92 | ||
|
|
3b637eefaa | ||
|
|
46e1d28881 | ||
|
|
f23cc8b99f | ||
|
|
6843347dd7 |
89
.env.appStore.example
Normal file
89
.env.appStore.example
Normal file
@@ -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=""
|
||||
# *********************************************************************************************************
|
||||
68
.env.example
68
.env.example
@@ -5,14 +5,6 @@
|
||||
# - SHARED
|
||||
# - NEXTAUTH
|
||||
# - E-MAIL SETTINGS
|
||||
# - APP STORE
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
# - TANDEM
|
||||
# - ZOOM
|
||||
|
||||
# - LICENSE *************************************************************************************************
|
||||
# Set this value to 'agree' to accept our license:
|
||||
@@ -33,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
|
||||
@@ -47,10 +40,13 @@ PGSSLMODE=
|
||||
|
||||
# - NEXTAUTH
|
||||
# @see: https://github.com/calendso/calendso/issues/263
|
||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_url
|
||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your NEXT_PUBLIC_WEBAPP_URL
|
||||
# NEXTAUTH_URL='http://localhost:3000'
|
||||
NEXTAUTH_URL=
|
||||
JWT_SECRET='secret'
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||
# You can use: `openssl rand -base64 32` to generate one
|
||||
NEXTAUTH_SECRET=
|
||||
# Used for cross-domain cookie authentication
|
||||
NEXTAUTH_COOKIE_DOMAIN=.example.com
|
||||
|
||||
@@ -83,6 +79,9 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT=
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - E-MAIL SETTINGS *****************************************************************************************
|
||||
@@ -107,52 +106,3 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
## @see https://support.google.com/accounts/answer/185833
|
||||
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
||||
# **********************************************************************************************************
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# - 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
|
||||
|
||||
# - 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 to 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=
|
||||
# *********************************************************************************************************
|
||||
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -4,6 +4,10 @@
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
<!-- Please provide a loom video for visual changes to speed up reviews
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete options that are not relevant. -->
|
||||
|
||||
4
.github/workflows/e2e.yml
vendored
4
.github/workflows/e2e.yml
vendored
@@ -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:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
JWT_SECRET: secret
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,6 +39,7 @@ yarn-error.log*
|
||||
.env.production.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.appStore.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,3 +1,7 @@
|
||||
[submodule "apps/admin"]
|
||||
path = apps/admin
|
||||
url = https://github.com/calcom/admin.git
|
||||
branch = main
|
||||
[submodule "apps/api"]
|
||||
path = apps/api
|
||||
url = https://github.com/calcom/api.git
|
||||
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
bailey@cal.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
73
README.md
73
README.md
@@ -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 -->
|
||||
@@ -102,19 +102,14 @@ Here is what you need to be able to run Cal.
|
||||
cd cal.com
|
||||
```
|
||||
|
||||
1. Copy `apps/web/.env.example` to `apps/web/.env`
|
||||
|
||||
```sh
|
||||
cp apps/web/.env.example apps/web/.env
|
||||
cp packages/prisma/.env.example packages/prisma/.env
|
||||
```
|
||||
|
||||
1. Install packages with yarn
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the .env file.
|
||||
|
||||
#### Quick start with `yarn dx`
|
||||
|
||||
> - **Requires Docker and Docker Compose to be installed**
|
||||
@@ -126,10 +121,10 @@ yarn dx
|
||||
|
||||
#### Development tip
|
||||
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `apps/web/.env` to get logging information for all the queries and mutations driven by **trpc**.
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **trpc**.
|
||||
|
||||
```sh
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
```
|
||||
|
||||
#### Manual setup
|
||||
@@ -195,11 +190,11 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.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 first terminal. Must run on port 3000.
|
||||
yarn dx
|
||||
# In second terminal
|
||||
yarn workspace @calcom/web test-e2e
|
||||
# In a terminal just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
yarn workspace @calcom/web playwright-report
|
||||
@@ -213,7 +208,13 @@ yarn workspace @calcom/web playwright-report
|
||||
git pull
|
||||
```
|
||||
|
||||
2. Apply database migrations by running <b>one of</b> the following commands:
|
||||
1. Check if dependencies got added/updated/removed
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Apply database migrations by running <b>one of</b> the following commands:
|
||||
|
||||
In a development environment, run:
|
||||
|
||||
@@ -229,16 +230,13 @@ yarn workspace @calcom/web playwright-report
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
|
||||
3. Check the `.env.example` and compare it to your current `.env` file. In case there are any fields not present
|
||||
in your current `.env`, add them there.
|
||||
1. Check for `.env` variables changes
|
||||
|
||||
For the current version, especially check if the variable `BASE_URL` is present and properly set in your environment, for example:
|
||||
|
||||
```
|
||||
BASE_URL='https://yourdomain.com'
|
||||
```sh
|
||||
yarn predev
|
||||
```
|
||||
|
||||
4. Start the server. In a development environment, just do:
|
||||
1. Start the server. In a development environment, just do:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
@@ -251,7 +249,7 @@ yarn workspace @calcom/web playwright-report
|
||||
yarn start
|
||||
```
|
||||
|
||||
5. Enjoy the new version.
|
||||
1. Enjoy the new version.
|
||||
<!-- DEPLOYMENT -->
|
||||
|
||||
## Deployment
|
||||
@@ -349,6 +347,7 @@ oauth_config:
|
||||
bot:
|
||||
- chat:write
|
||||
- commands
|
||||
- chat:write.public
|
||||
settings:
|
||||
interactivity:
|
||||
is_enabled: true
|
||||
@@ -391,6 +390,30 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
5. If you have the [Daily Scale Plan](https://www.daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
|
||||
### Obtaining HubSpot Client ID and Secret
|
||||
|
||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||
2. From within the home of the Developer account page, go to "Manage apps".
|
||||
3. Click "Create app" button top right.
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot othercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
### 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
|
||||
@@ -410,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.
|
||||
|
||||
2
app.json
2
app.json
@@ -18,7 +18,7 @@
|
||||
"description": "Application Key for symmetric encryption and decryption. Must be 32 bytes for AES256 encryption algorithm.",
|
||||
"value": "secret"
|
||||
},
|
||||
"JWT_SECRET": "secret"
|
||||
"NEXTAUTH_SECRET": "secret"
|
||||
},
|
||||
"scripts": {
|
||||
"postdeploy": "cd packages/prisma && npx prisma migrate deploy"
|
||||
|
||||
1
apps/admin
Submodule
1
apps/admin
Submodule
Submodule apps/admin added at 943cd10de1
2
apps/api
2
apps/api
Submodule apps/api updated: 187b97afa1...be2d4338ee
@@ -5,7 +5,7 @@
|
||||
</a>
|
||||
<a href="https://cal.com">Website</a>
|
||||
·
|
||||
<a href="https://github.com/calcom/docs/issues">Community Support</a>
|
||||
<a href="https://github.com/calcom/cal.com/issues">Community Support</a>
|
||||
</div>
|
||||
|
||||
# Cal.com Documentation
|
||||
|
||||
21
apps/docs/components/Anchor.tsx
Normal file
21
apps/docs/components/Anchor.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
33
apps/docs/lib/useWindowSize.ts
Normal file
33
apps/docs/lib/useWindowSize.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -3,4 +3,14 @@ const withNextra = require("nextra")({
|
||||
themeConfig: "./theme.config.js",
|
||||
unstable_staticImage: true,
|
||||
});
|
||||
module.exports = withNextra();
|
||||
module.exports = withNextra({
|
||||
async rewrites() {
|
||||
return [
|
||||
// This redirects requests recieved at /api to /public-api to workaround nextjs default use of /api.
|
||||
{
|
||||
source: "/api",
|
||||
destination: "/public-api",
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,6 @@ This is also the home of our design system documentation and developer docs.
|
||||
|
||||
If you don't already know what Cal.com is about, please head over to [our website](https://cal.com), where you can learn more about the product before venturing into the documentation.
|
||||
|
||||
Want to help make these docs even better? This site is fully open source, and the source code is available on [GitHub](https://github.com/calcom/docs). You can also click the edit button at the bottom of any page to start editing the source code and start a pull request.
|
||||
Want to help make these docs even better? This site is fully open source, and the source code is available on [GitHub](https://github.com/calcom/cal.com/tree/main/apps/docs). You can also click the edit button at the bottom of any page to start editing the source code and start a pull request.
|
||||
|
||||
<Bleed></Bleed>
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
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
|
||||
|
||||
TODO: Mention possibility of installation through tag managers as well
|
||||
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.
|
||||
|
||||
- _Step-1._ Install the Vanilla JS Snippet
|
||||
|
||||
```javascript
|
||||
```html
|
||||
<script>
|
||||
(function (C, A, L) {
|
||||
let p = function (a, ar) {
|
||||
a.q.push(ar);
|
||||
@@ -40,14 +41,10 @@ TODO: Mention possibility of installation through tag managers as well
|
||||
}
|
||||
p(cal, ar);
|
||||
};
|
||||
})(window, "https://cal.com/embed.js", "init");
|
||||
```
|
||||
|
||||
- _Step-2_. Initialize it
|
||||
|
||||
```javascript
|
||||
Cal("init)
|
||||
```
|
||||
})(window, "https://cal.com/embed.js", "init");
|
||||
Cal("init")
|
||||
</script>
|
||||
```
|
||||
|
||||
## Install with a Framework
|
||||
|
||||
@@ -63,9 +60,9 @@ 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 add show the embed in following ways:
|
||||
Assuming that you have followed the steps of installing and initializing the snippet, you can show the embed in following ways:
|
||||
|
||||
### Inline
|
||||
|
||||
@@ -74,20 +71,29 @@ Show the embed inline inside a container element. It would take the width and he
|
||||
<details>
|
||||
<summary>_Vanilla JS_</summary>
|
||||
|
||||
```javascript
|
||||
Cal("inline", {
|
||||
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
|
||||
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
|
||||
config: {
|
||||
name: "John Doe", // Prefill Name
|
||||
email: "johndoe@gmail.com", // Prefill Email
|
||||
notes: "Test Meeting", // Prefill Notes
|
||||
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
|
||||
theme: "dark", // "dark" or "light" theme
|
||||
},
|
||||
});
|
||||
```
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", {
|
||||
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
|
||||
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
|
||||
config: {
|
||||
name: "John Doe", // Prefill Name
|
||||
email: "johndoe@gmail.com", // Prefill Email
|
||||
notes: "Test Meeting", // Prefill Notes
|
||||
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
|
||||
theme: "dark", // "dark" or "light" theme
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
*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>
|
||||
|
||||
####
|
||||
@@ -112,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
|
||||
@@ -124,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
|
||||
@@ -135,11 +156,36 @@ 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>
|
||||
### Full Screen
|
||||
|
||||
### 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
|
||||
|
||||
@@ -149,8 +195,10 @@ Consider an instruction as a function with that name and that would be called wi
|
||||
|
||||
Appends embed inline as the child of the element.
|
||||
|
||||
```javascript
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", { elementOrSelector, calLink });
|
||||
</script>
|
||||
````
|
||||
|
||||
- `elementOrSelector` - Give it either a valid CSS selector or an HTMLElement instance directly
|
||||
@@ -161,8 +209,10 @@ Cal("inline", { elementOrSelector, calLink });
|
||||
|
||||
Configure UI for embed. Make it look part of your webpage.
|
||||
|
||||
```javascript
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", { styles });
|
||||
</script>
|
||||
```
|
||||
|
||||
- `styles` - It supports styling for `body` and `eventTypeListItem`. Right now we support just background on these two.
|
||||
@@ -173,15 +223,18 @@ Usage:
|
||||
|
||||
If you want to open cal link on some action. Make it pop open instantly by preloading it.
|
||||
|
||||
```javascript
|
||||
```html
|
||||
<script>
|
||||
Cal("preload", { calLink });
|
||||
</script>
|
||||
```
|
||||
|
||||
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john]()
|
||||
|
||||
## Actions
|
||||
You can listen to an action that occurs in embedded cal link as follows. You can think of them as DOM events. We are avoiding the term events to not confuse it with Cal Events.
|
||||
```javascript
|
||||
You can listen to an action that occurs in embedded cal link as follows. You can think of them as DOM events. We are avoiding the term "events" to not confuse it with Cal Events.
|
||||
```html
|
||||
<script>
|
||||
Cal("on", {
|
||||
action: "ANY_ACTION_NAME",
|
||||
callback: (e)=>{
|
||||
@@ -191,11 +244,12 @@ Cal("on", {
|
||||
const {data, type, namespace} = e.detail;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Following are the list of supported actions.
|
||||
-
|
||||
| action | description | properties |
|
||||
| Action | Description | Properties |
|
||||
|----------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| eventTypeSelected | When user chooses an event-type from the listing. | eventType:object // Event Type that has been selected" |
|
||||
| bookingSuccessful | When the booking is successfully done. It might not be confirmed. | confirmed: boolean; //Whether confirmation from organizer is pending or not <br/><br/>eventType: "Object for Event Type that has been booked"; <br/><br/>date: string; // Date of Event <br/><br/>duration: number; //Duration of booked Event <br/><br/>organizer: object //Organizer details like name, timezone, email |
|
||||
|
||||
21
apps/docs/pages/integrations/slack.mdx
Normal file
21
apps/docs/pages/integrations/slack.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
Title: Slack
|
||||
---
|
||||
|
||||
# Slack
|
||||
|
||||
## Connecting
|
||||
Connecting the bot is easy - If you are a workspace admin, the install button will add the bot to the workspace and also authorize your account with the bot. If you are a normal user, the install button will connect your Slack account with Cal.com. This will allow you to perform commands in Slack.
|
||||
|
||||
## Commands
|
||||
`/today` - This command will display all meetings you have in your Cal.com profile for the current day. This will send a hidden message (not visible to anyone other than you) to the channel you issued the command in.
|
||||
|
||||
`/create-event` - It will display a modal allowing you to simply create a meeting invite with anyone in Slack. Success/Error information will be displayed in a private direct message from the bot.
|
||||
|
||||
`/links` - This command will post all your Cal.com meeting links into the current Slack channel you are in. **Note**: The bot needs to have permission to talk in the channel you are sending the message in. Otherwise, you won't be able to send your links.
|
||||
|
||||
As this is the beggining stage of our Slack integration, we plan on adding more commands in the future that will further improve your Cal.com experience.
|
||||
|
||||
## Self-Hosted
|
||||
If you are using our self-hosted version, please refer to our documentation in
|
||||
[cal.com/README.md](https://github.com/calcom/cal.com/blob/main/README.md#obtaining-slack-client-id-and-secret-and-signing-secret)
|
||||
@@ -6,6 +6,7 @@
|
||||
"event-types": "Event Types",
|
||||
"teams": "Teams",
|
||||
"integrations": "Integrations",
|
||||
"public-api": "API",
|
||||
"webhooks": "Webhooks",
|
||||
"settings": "Settings",
|
||||
"import": "Import",
|
||||
|
||||
20
apps/docs/pages/public-api.mdx
Normal file
20
apps/docs/pages/public-api.mdx
Normal file
@@ -0,0 +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>
|
||||
<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
apps/swagger/.env.example
Normal file
1
apps/swagger/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs
|
||||
353
apps/swagger/lib/snippets.ts
Normal file
353
apps/swagger/lib/snippets.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import * as OpenAPISnippet from "openapi-snippet";
|
||||
|
||||
export const requestSnippets = {
|
||||
generators: {
|
||||
curl_bash: {
|
||||
title: "cURL (bash)",
|
||||
syntax: "bash",
|
||||
},
|
||||
curl_powershell: {
|
||||
title: "cURL (PowerShell)",
|
||||
syntax: "powershell",
|
||||
},
|
||||
curl_cmd: {
|
||||
title: "cURL (CMD)",
|
||||
syntax: "bash",
|
||||
},
|
||||
node: {
|
||||
title: "Node",
|
||||
syntax: "node",
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["node", "curl_bash"],
|
||||
};
|
||||
// Since swagger-ui-react was not configured to change the request snippets some workarounds required
|
||||
// configuration will be added programatically
|
||||
// Custom Plugin
|
||||
export const SnippedGenerator = {
|
||||
statePlugins: {
|
||||
// extend some internals to gain information about current path, method and spec in the generator function metioned later
|
||||
spec: {
|
||||
wrapSelectors: {
|
||||
requestFor: (ori, system) => (state, path, method) => {
|
||||
return ori(path, method)
|
||||
?.set("spec", state.get("json", {}))
|
||||
?.setIn(["oasPathMethod", "path"], path)
|
||||
?.setIn(["oasPathMethod", "method"], method);
|
||||
},
|
||||
mutatedRequestFor: (ori) => (state, path, method) => {
|
||||
return ori(path, method)
|
||||
?.set("spec", state.get("json", {}))
|
||||
?.setIn(["oasPathMethod", "path"], path)
|
||||
?.setIn(["oasPathMethod", "method"], method);
|
||||
},
|
||||
},
|
||||
},
|
||||
// extend the request snippets core plugin
|
||||
requestSnippets: {
|
||||
wrapSelectors: {
|
||||
// add additional snippet generators here
|
||||
getSnippetGenerators:
|
||||
(ori, system) =>
|
||||
(state, ...args) =>
|
||||
ori(state, ...args)
|
||||
// add node native snippet generator
|
||||
// .set(
|
||||
// // key
|
||||
// "node_native",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "NodeJs Native",
|
||||
// syntax: "javascript",
|
||||
// hostname: "test",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["node_native"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// // Since I don't know why hostname was undefinedundefined, I harcoded it here
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
.set(
|
||||
// key
|
||||
"node_fetch",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "NodeJS",
|
||||
syntax: "javascript",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["node_fetch"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"shell_httpie",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "HTTPie",
|
||||
syntax: "bash",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["shell_httpie"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"php_curl",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "PHP",
|
||||
syntax: "php",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["php_curl"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"java_okhttp",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Java",
|
||||
syntax: "java",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
console.log(spec, oasPathMethod, path, method);
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["java_okhttp"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
// .set(
|
||||
// // key
|
||||
// "java",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "Java (Unirest)",
|
||||
// syntax: "java",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["java"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
// .set(
|
||||
// // key
|
||||
// "c_libcurl",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "C (libcurl) ",
|
||||
// syntax: "bash",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["c_libcurl"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
.set(
|
||||
// key
|
||||
"go_native",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Go",
|
||||
syntax: "bash",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["go_native"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"ruby",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Ruby",
|
||||
syntax: "ruby",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["ruby"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"python",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Python",
|
||||
syntax: "python",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["python"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3,19 +3,21 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "PORT=4200 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "PORT=4200 next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.5.1",
|
||||
"isarray": "2.0.5",
|
||||
"next": "12.1.4",
|
||||
"next": "12.1.5",
|
||||
"openapi-snippet": "^0.13.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"swagger-ui-react": "4.8.1"
|
||||
"swagger-ui-react": "4.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "16.11.26",
|
||||
"@types/node": "17.0.27",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/react-dom": "17.0.14",
|
||||
"typescript": "4.6.3"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "highlight.js/styles/default.css";
|
||||
import "swagger-ui-react/swagger-ui.css";
|
||||
|
||||
import "../styles/globals.css";
|
||||
import "../styles/swagger-cal.css";
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />;
|
||||
|
||||
@@ -1,45 +1,26 @@
|
||||
import Head from "next/head";
|
||||
import SwaggerUI from "swagger-ui-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { SwaggerUI } from "swagger-ui-react";
|
||||
|
||||
import { SnippedGenerator, requestSnippets } from "@lib/snippets";
|
||||
|
||||
const SwaggerUIDynamic: SwaggerUI & { url: string } = dynamic(() => import("swagger-ui-react"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const requestSnippets = {
|
||||
generators: {
|
||||
curl_bash: {
|
||||
title: "cURL (bash)",
|
||||
syntax: "bash",
|
||||
},
|
||||
curl_powershell: {
|
||||
title: "cURL (PowerShell)",
|
||||
syntax: "powershell",
|
||||
},
|
||||
curl_cmd: {
|
||||
title: "cURL (CMD)",
|
||||
syntax: "bash",
|
||||
},
|
||||
node: {
|
||||
title: "Node",
|
||||
syntax: "node",
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["curl_bash"],
|
||||
// e.g. only show curl bash = ["curl_bash"]
|
||||
};
|
||||
export default function APIDocs() {
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Cal.com - Docs - SwaggerUI</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<SwaggerUI
|
||||
requestSnippets={requestSnippets}
|
||||
requestSnippetsEnabled={true}
|
||||
docExpansion="none"
|
||||
operationsSorter="method"
|
||||
filter={true}
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/api/docs"}
|
||||
/>
|
||||
</div>
|
||||
<SwaggerUIDynamic
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/docs"}
|
||||
persistAuthorization={true}
|
||||
supportedSubmitMethods={["get", "post", "delete", "put", "options", "patch"]}
|
||||
requestSnippetsEnabled={true}
|
||||
requestSnippets={requestSnippets}
|
||||
plugins={[SnippedGenerator]}
|
||||
tryItOutEnabled={true}
|
||||
syntaxHighlight={true}
|
||||
enableCORS={false} // Doesn't seem to work either
|
||||
docExpansion="list"
|
||||
filter={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,11 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
3
apps/web/.gitignore
vendored
3
apps/web/.gitignore
vendored
@@ -61,3 +61,6 @@ yarn-error.log*
|
||||
|
||||
# Typescript
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Autogenerated embed content
|
||||
public/embed
|
||||
|
||||
@@ -8,14 +8,13 @@ import {
|
||||
} from "@heroicons/react/outline";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
//import NavTabs from "@components/NavTabs";
|
||||
import Shell from "@components/Shell";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
@@ -60,10 +59,32 @@ export default function App({
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price);
|
||||
|
||||
const [installedApp, setInstalledApp] = useState(false);
|
||||
useEffect(() => {
|
||||
async function getInstalledApp(appCredentialType: string) {
|
||||
const queryParam = new URLSearchParams();
|
||||
queryParam.set("app-credential-type", appCredentialType);
|
||||
try {
|
||||
const result = await fetch(`/api/app-store/installed?${queryParam.toString()}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (result.status === 200) {
|
||||
setInstalledApp(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
getInstalledApp(type);
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Shell large>
|
||||
<Shell large isPublic>
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="bg-gray-50 px-4">
|
||||
<Link href="/apps">
|
||||
@@ -83,7 +104,7 @@ export default function App({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
||||
{isGlobal ? (
|
||||
{isGlobal || installedApp ? (
|
||||
<Button color="secondary" disabled title="This app is globally installed">
|
||||
{t("installed")}
|
||||
</Button>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AppsShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="mb-12 block lg:hidden">
|
||||
{status === "authenticated" && <NavTabs tabs={tabs} linkProps={{ shallow: true }} />}
|
||||
</div>
|
||||
<main>{children}</main>
|
||||
<main className="pb-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
900
apps/web/components/Embed.tsx
Normal file
900
apps/web/components/Embed.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -1,49 +1,82 @@
|
||||
import { AdminRequired } from "components/ui/AdminRequired";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ElementType, FC } from "react";
|
||||
import React, { ElementType, FC, Fragment, MouseEventHandler } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
interface Props {
|
||||
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<Props> = ({ tabs, linkProps }) => {
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="-mb-px flex space-x-2 space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse"
|
||||
aria-label="Tabs">
|
||||
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 (
|
||||
<Link key={tab.name} href={tab.href} {...linkProps}>
|
||||
<a
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Component key={tab.name}>
|
||||
<Link key={tab.name} href={href!} {...linkProps}>
|
||||
<a
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Component>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import { CreditCardIcon, KeyIcon, LockClosedIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
import NavTabs, { NavTabProps } from "./NavTabs";
|
||||
|
||||
export default function SettingsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
@@ -29,6 +29,12 @@ export default function SettingsShell({ children }: { children: React.ReactNode
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
name: t("admin"),
|
||||
href: "/settings/admin",
|
||||
icon: LockClosedIcon,
|
||||
adminRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MoonIcon,
|
||||
ViewGridIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { SessionContextValue, signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -19,7 +20,6 @@ import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { UserPlan } from "@calcom/prisma/client";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
@@ -34,27 +34,19 @@ 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";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import Loader from "@components/Loader";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import ImpersonatingBanner from "@components/ui/ImpersonatingBanner";
|
||||
|
||||
import pkg from "../package.json";
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
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";
|
||||
@@ -69,7 +61,7 @@ function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
||||
router.replace({
|
||||
pathname: "/auth/login",
|
||||
query: {
|
||||
callbackUrl: `${WEBAPP_URL}/${location.pathname}${location.search}`,
|
||||
callbackUrl: `${WEBAPP_URL}${location.pathname}${location.search}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -125,9 +117,10 @@ const Layout = ({
|
||||
status,
|
||||
plan,
|
||||
...props
|
||||
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan }) => {
|
||||
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => {
|
||||
const isEmbed = useIsEmbed();
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useLocale();
|
||||
const navigation = [
|
||||
{
|
||||
@@ -311,6 +304,7 @@ const Layout = ({
|
||||
props.flexChildrenContainer && "flex flex-1 flex-col",
|
||||
!props.large && "py-8"
|
||||
)}>
|
||||
<ImpersonatingBanner />
|
||||
{!!props.backPath && (
|
||||
<div className="mx-3 mb-8 sm:mx-8">
|
||||
<Button
|
||||
@@ -329,10 +323,21 @@ const Layout = ({
|
||||
)}>
|
||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="mb-8 w-full">
|
||||
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
|
||||
{props.heading}
|
||||
</h1>
|
||||
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<div className="mb-1 h-6 w-24 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className="mb-1 h-6 w-32 animate-pulse rounded-md bg-gray-200"></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
|
||||
{props.heading}
|
||||
</h1>
|
||||
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">
|
||||
{props.subtitle}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{props.CTA && <div className="mb-4 flex-shrink-0">{props.CTA}</div>}
|
||||
</div>
|
||||
@@ -342,7 +347,7 @@ const Layout = ({
|
||||
"px-4 sm:px-6 md:px-8",
|
||||
props.flexChildrenContainer && "flex flex-1 flex-col"
|
||||
)}>
|
||||
{props.children}
|
||||
{!props.isLoading ? props.children : props.customLoader}
|
||||
</div>
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
{status === "authenticated" && (
|
||||
@@ -403,6 +408,7 @@ type LayoutProps = {
|
||||
// use when content needs to expand with flex
|
||||
flexChildrenContainer?: boolean;
|
||||
isPublic?: boolean;
|
||||
customLoader?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Shell(props: LayoutProps) {
|
||||
@@ -423,8 +429,10 @@ export default function Shell(props: LayoutProps) {
|
||||
const i18n = useViewerI18n();
|
||||
const { status } = useSession();
|
||||
|
||||
if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) {
|
||||
// show spinner whilst i18n is loading to avoid language flicker
|
||||
const isLoading =
|
||||
i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
|
||||
<Loader />
|
||||
@@ -437,7 +445,7 @@ export default function Shell(props: LayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||
<MemoizedLayout plan={user?.plan} status={status} {...props} />
|
||||
<MemoizedLayout plan={user?.plan} status={status} {...props} isLoading={isLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function AppStoreCategories({
|
||||
return (
|
||||
<div className="mb-16">
|
||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("popular_categories")}</h2>
|
||||
<div className="grid-col-1 grid gap-3 md:grid-flow-col">
|
||||
<div className="grid-col-1 grid w-full gap-3 overflow-scroll sm:grid-flow-col">
|
||||
{categories.map((category) => (
|
||||
<Link key={category.name} href={"/apps/categories/" + category.name}>
|
||||
<a
|
||||
|
||||
41
apps/web/components/apps/SkeletonLoader.tsx
Normal file
41
apps/web/components/apps/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonAvatar, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<>
|
||||
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100"></div>} />
|
||||
<ul className="-mx-4 animate-pulse divide-y divide-neutral-200 rounded-sm border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between p-3">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-start space-x-2">
|
||||
<SkeletonText width="10" height="10"></SkeletonText>
|
||||
<div className="space-y-2">
|
||||
<SkeletonText height="4" width="32"></SkeletonText>
|
||||
<SkeletonText height="4" width="16"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="32" height="11"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -29,8 +29,15 @@ export default function TwoFactor() {
|
||||
<div className="mx-auto !mt-0 max-w-sm">
|
||||
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
|
||||
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
|
||||
<div className="flex flex-row space-x-1">
|
||||
<Input className={className} name="2fa1" inputMode="decimal" {...digits[0]} autoFocus />
|
||||
<div className="flex flex-row justify-between">
|
||||
<Input
|
||||
className={className}
|
||||
name="2fa1"
|
||||
inputMode="decimal"
|
||||
{...digits[0]}
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<Input className={className} name="2fa2" inputMode="decimal" {...digits[1]} />
|
||||
<Input className={className} name="2fa3" inputMode="decimal" {...digits[2]} />
|
||||
<Input className={className} name="2fa4" inputMode="decimal" {...digits[3]} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
46
apps/web/components/availability/SkeletonLoader.tsx
Normal file
46
apps/web/components/availability/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-2 py-[23px] sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText width="32" height="4"></SkeletonText>
|
||||
<SkeletonText width="32" height="2"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="12" height="6"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 } from "@heroicons/react/outline";
|
||||
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,
|
||||
@@ -87,11 +112,13 @@ function BookingListItem(booking: BookingItem) {
|
||||
actions: [
|
||||
{
|
||||
id: "edit",
|
||||
icon: PencilAltIcon,
|
||||
label: t("edit_booking"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
icon: ClockIcon,
|
||||
label: t("send_reschedule_request"),
|
||||
onClick: () => setIsOpenRescheduleDialog(true),
|
||||
},
|
||||
@@ -110,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
|
||||
@@ -152,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">
|
||||
|
||||
36
apps/web/components/booking/SkeletonLoader.tsx
Normal file
36
apps/web/components/booking/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="mt-6 animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-2 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText width="16" height="5" />
|
||||
<SkeletonText width="32" height="4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="16" height="6" />
|
||||
<SkeletonText width="32" height="6" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -17,14 +18,23 @@ import utc from "dayjs/plugin/utc";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager } from "@calcom/embed-core";
|
||||
import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
useIsBackgroundTransparent,
|
||||
sdkActionManager,
|
||||
useEmbedType,
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { 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 } from "@lib/config/constants";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
@@ -56,6 +66,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
const { t, i18n } = useLocale();
|
||||
const { contracts } = useContracts();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
let isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
useExposePlanGlobally(plan);
|
||||
useEffect(() => {
|
||||
@@ -92,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();
|
||||
|
||||
@@ -100,8 +113,8 @@ 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]);
|
||||
@@ -133,6 +146,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 />
|
||||
@@ -146,23 +168,23 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<div>
|
||||
<main
|
||||
className={
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? classNames("m-auto", selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
: "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
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||
"border-bookinglightest rounded-sm md:border",
|
||||
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
|
||||
"border-bookinglightest rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : maxWidth
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
<div className="block items-center sm:flex sm:space-x-4">
|
||||
<div>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
@@ -180,20 +202,22 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
<div className="mt-4 sm:-mt-2">
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
||||
<div className="mt-2 flex gap-2 text-xl font-medium dark:text-gray-100">
|
||||
{eventType.title}
|
||||
<div className="mt-2 gap-2 dark:text-gray-100">
|
||||
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
{eventType?.description && (
|
||||
<p className="mb-2 text-gray-600 dark:text-white">
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<ClockIcon className="mr-[10px] -mt-1 inline-block h-4 w-4" />
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</div>
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
|
||||
@@ -206,6 +230,22 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
</IntlProvider>
|
||||
</div>
|
||||
)}
|
||||
<div className="md:hidden">
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-8 mb-2 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_mobile">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-white">
|
||||
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{typeof booking.startTime === "string" &&
|
||||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +255,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"
|
||||
@@ -239,15 +279,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
{eventType.title}
|
||||
</h1>
|
||||
{eventType?.description && (
|
||||
<p className="mb-2 text-gray-600 dark:text-white">
|
||||
<p className="text-bookinglight mb-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="mb-1 -ml-2 px-2 py-1 text-gray-600 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" />
|
||||
@@ -262,7 +329,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
)}
|
||||
|
||||
<TimezoneDropdown />
|
||||
{previousPage === `${BASE_URL}/${profile.slug}` && (
|
||||
{previousPage === `${WEBAPP_URL}/${profile.slug}` && (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<ArrowLeftIcon
|
||||
className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white"
|
||||
@@ -273,7 +340,9 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
)}
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mt-8 mb-2 text-gray-600 dark:text-white" data-testid="former_time_p">
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_desktop">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-white">
|
||||
@@ -283,6 +352,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
periodType={eventType?.periodType}
|
||||
@@ -309,6 +379,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,9 +2,12 @@ import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs from "dayjs";
|
||||
@@ -17,13 +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, useIsBackgroundTransparent } 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";
|
||||
@@ -32,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";
|
||||
|
||||
@@ -41,6 +49,7 @@ import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
||||
|
||||
import { BookPageProps } from "../../../pages/[user]/book";
|
||||
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
@@ -48,7 +57,7 @@ const PhoneInput = dynamic(
|
||||
() => import("@components/ui/form/PhoneInput")
|
||||
) as unknown as typeof PhoneInputType;
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
|
||||
|
||||
type BookingFormValues = {
|
||||
name: string;
|
||||
@@ -67,15 +76,29 @@ const BookingPage = ({
|
||||
booking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
recurringEventCount,
|
||||
locationLabels,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const router = useRouter();
|
||||
const { contracts } = useContracts();
|
||||
const { data: session } = useSession();
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
const eventOwner = eventType.users[0];
|
||||
@@ -88,7 +111,7 @@ const BookingPage = ({
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { attendees, paymentUid } = responseData;
|
||||
const { id, attendees, paymentUid } = responseData;
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
@@ -122,6 +145,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,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -187,8 +243,16 @@ const BookingPage = ({
|
||||
};
|
||||
};
|
||||
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const bookingForm = useForm<BookingFormValues>({
|
||||
defaultValues: defaultValues(),
|
||||
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
||||
});
|
||||
|
||||
const selectedLocation = useWatch({
|
||||
@@ -222,10 +286,24 @@ const BookingPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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/") })
|
||||
)
|
||||
);
|
||||
@@ -244,7 +322,7 @@ const BookingPage = ({
|
||||
{}
|
||||
);
|
||||
|
||||
let web3Details;
|
||||
let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
|
||||
if (eventTypeDetail.metadata.smartContractAddress) {
|
||||
web3Details = {
|
||||
// @ts-ignore
|
||||
@@ -253,26 +331,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],
|
||||
})),
|
||||
});
|
||||
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;
|
||||
@@ -298,16 +409,17 @@ const BookingPage = ({
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<main
|
||||
className={classNames(
|
||||
isEmbed ? "mx-auto" : "mx-auto my-0 rounded-sm sm:my-24",
|
||||
"max-w-3xl sm:border sm:dark:border-gray-600"
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "" : "sm:my-24",
|
||||
"my-0 max-w-3xl "
|
||||
)}>
|
||||
{isReady && (
|
||||
<div
|
||||
className={classNames(
|
||||
"overflow-hidden",
|
||||
"main overflow-hidden",
|
||||
isEmbed ? "" : "border border-gray-200",
|
||||
isBackgroundTransparent ? "" : "bg-white dark:border-0 dark:bg-gray-800",
|
||||
"sm:rounded-sm"
|
||||
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
|
||||
"rounded-md sm:border sm:dark:border-gray-600"
|
||||
)}>
|
||||
<div className="px-4 py-5 sm:flex sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
|
||||
@@ -351,10 +463,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.tz(date, 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}
|
||||
@@ -372,7 +515,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<div className="mt-8 sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
@@ -380,7 +523,7 @@ const BookingPage = ({
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
{...bookingForm.register("name")}
|
||||
{...bookingForm.register("name", { required: true })}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
@@ -405,13 +548,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 && (
|
||||
@@ -427,7 +579,6 @@ const BookingPage = ({
|
||||
{...bookingForm.register("locationType", { required: true })}
|
||||
value={location.type}
|
||||
defaultChecked={selectedLocation === location.type}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
|
||||
{locationLabels[location.type]}
|
||||
|
||||
@@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
|
||||
@@ -56,23 +56,32 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
: undefined;
|
||||
const pageSlug = router.query.eventPage || props.options[0].slug;
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
const title: string =
|
||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
||||
const length: number =
|
||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
||||
const description: string =
|
||||
typeof router.query.description === "string" && router.query.description ? router.query.description : "";
|
||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
||||
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
||||
|
||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||
resolver: zodResolver(createEventTypeInput),
|
||||
});
|
||||
const { setValue, watch, register } = form;
|
||||
setValue("title", title);
|
||||
setValue("length", length);
|
||||
setValue("description", description);
|
||||
setValue("slug", slug);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
|
||||
const title: string =
|
||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
||||
const length: number =
|
||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
||||
const description: string =
|
||||
typeof router.query.description === "string" && router.query.description
|
||||
? router.query.description
|
||||
: "";
|
||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
||||
|
||||
setValue("title", title);
|
||||
setValue("length", length);
|
||||
setValue("description", description);
|
||||
setValue("slug", slug);
|
||||
// If query params change, update the form
|
||||
}, [router.isReady, router.query, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name, type }) => {
|
||||
@@ -86,7 +95,7 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
|
||||
const createMutation = trpc.useMutation("viewer.eventTypes.create", {
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.push("/event-types/" + eventType.id);
|
||||
await router.replace("/event-types/" + eventType.id);
|
||||
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
132
apps/web/components/eventtype/RecurringEventController.tsx
Normal file
132
apps/web/components/eventtype/RecurringEventController.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
|
||||
import React, { 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 Select from "@components/ui/form/Select";
|
||||
|
||||
type RecurringEventControllerProps = { recurringEvent: RecurringEvent; formMethods: UseFormReturn<any, any> };
|
||||
|
||||
export default function RecurringEventController({
|
||||
recurringEvent,
|
||||
formMethods,
|
||||
}: 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="w-full">
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
onChange={(event) => {
|
||||
setRecurringEventDefined(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>
|
||||
);
|
||||
}
|
||||
63
apps/web/components/eventtype/SkeletonLoader.tsx
Normal file
63
apps/web/components/eventtype/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { LinkIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-4 flex items-center">
|
||||
<SkeletonAvatar width="8" height="8"></SkeletonAvatar>
|
||||
<div className="space-y-1">
|
||||
<SkeletonText height="4" width="16"></SkeletonText>
|
||||
<SkeletonText height="4" width="24"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div>
|
||||
<SkeletonText width="32" height="5"></SkeletonText>
|
||||
</div>
|
||||
<div className="">
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></ClockIcon>
|
||||
<SkeletonText width="12" height="4"></SkeletonText>
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></UserIcon>
|
||||
<SkeletonText width="16" height="4"></SkeletonText>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 sm:flex">
|
||||
<div className="flex justify-between rtl:space-x-reverse">
|
||||
<div className="btn-icon appearance-none">
|
||||
<ExternalLinkIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="btn-icon appearance-none">
|
||||
<LinkIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="btn-icon appearance-none">
|
||||
<DotsHorizontalIcon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Something went wrong"
|
||||
title={t("calendar_error")}
|
||||
message={item.error?.message}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
|
||||
@@ -56,13 +56,13 @@ const ChangePasswordSection = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="flex">
|
||||
<div className="w-1/2 ltr:mr-2 rtl:ml-2">
|
||||
<div className="py-6 lg:pb-5">
|
||||
<div className="my-3">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<div className="flex 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,10 +98,11 @@ const ChangePasswordSection = () => {
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
<div className="flex justify-end py-8">
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
<div className="flex py-8 sm:justify-end">
|
||||
<Button color="secondary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -173,6 +173,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
inputMode="numeric"
|
||||
onInput={(e) => setTotpCode(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,21 +17,25 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
<div className="flex flex-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>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
|
||||
<Button
|
||||
className="mt-6"
|
||||
type="submit"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")} {t("2fa")}
|
||||
</Button>
|
||||
|
||||
{enableModalOpen && (
|
||||
<EnableTwoFactorModal
|
||||
onEnable={() => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -19,12 +19,12 @@ import Dropdown, {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} 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 { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -52,7 +52,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">
|
||||
|
||||
14
apps/web/components/ui/AdminRequired.tsx
Normal file
14
apps/web/components/ui/AdminRequired.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { FC, Fragment } from "react";
|
||||
|
||||
type AdminRequiredProps = {
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
};
|
||||
|
||||
export const AdminRequired: FC<AdminRequiredProps> = ({ children, as, ...rest }) => {
|
||||
const session = useSession();
|
||||
|
||||
if (session.data?.user.role !== "ADMIN") return null;
|
||||
const Component = as ?? Fragment;
|
||||
return <Component {...rest}>{children}</Component>;
|
||||
};
|
||||
34
apps/web/components/ui/ImpersonatingBanner.tsx
Normal file
34
apps/web/components/ui/ImpersonatingBanner.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Trans } from "next-i18next";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
||||
type Props = {};
|
||||
|
||||
function ImpersonatingBanner({}: Props) {
|
||||
const { t } = useLocale();
|
||||
const { data } = useSession();
|
||||
|
||||
if (!data?.user.impersonatedByUID) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={
|
||||
<>
|
||||
{t("impersonating_user_warning", { user: data.user.username })}{" "}
|
||||
<Trans i18nKey="impersonating_stop_instructions">
|
||||
<a href="/auth/logout" className="underline">
|
||||
Click Here To stop
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</>
|
||||
}
|
||||
className="mx-4 mb-2 sm:mx-6 md:mx-8"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpersonatingBanner;
|
||||
@@ -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 (
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function ModalContainer(props: Props) {
|
||||
{
|
||||
"sm:w-full sm:max-w-lg ": !props.wide,
|
||||
"sm:w-4xl sm:max-w-4xl": props.wide,
|
||||
"overflow-scroll": props.scroll,
|
||||
"overflow-auto": props.scroll,
|
||||
"!p-0": props.noPadding,
|
||||
}
|
||||
)}>
|
||||
|
||||
@@ -52,6 +52,12 @@ const DropdownActions = ({ actions, actionTrigger }: { actions: ActionType[]; ac
|
||||
};
|
||||
|
||||
const TableActions: FC<Props> = ({ actions }) => {
|
||||
const mobileActions = actions.flatMap((action) => {
|
||||
if (action.actions) {
|
||||
return action.actions;
|
||||
}
|
||||
return action;
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="hidden space-x-2 rtl:space-x-reverse lg:block">
|
||||
@@ -72,12 +78,11 @@ const TableActions: FC<Props> = ({ actions }) => {
|
||||
if (!action.actions) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return <DropdownActions key={action.id} actions={action.actions} actionTrigger={button} />;
|
||||
})}
|
||||
</div>
|
||||
<div className="inline-block text-left lg:hidden">
|
||||
<DropdownActions actions={actions} />
|
||||
<DropdownActions actions={mobileActions} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,11 @@ type Props = {
|
||||
date: Date;
|
||||
onDatesChange?: ((date: Date) => void) | undefined;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
minDate?: Date;
|
||||
};
|
||||
|
||||
export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
||||
export const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => {
|
||||
return (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
@@ -22,6 +24,8 @@ export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
||||
clearIcon={null}
|
||||
calendarIcon={<CalendarIcon className="h-5 w-5 text-gray-500" />}
|
||||
value={date}
|
||||
minDate={minDate}
|
||||
disabled={disabled}
|
||||
onChange={onDatesChange}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function LicenseBanner() {
|
||||
- Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_LICENSE_CONSENT === "agree") {
|
||||
if (process.env.NEXT_PUBLIC_LICENSE_CONSENT === "agree" || process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
151
apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
Normal file
151
apps/web/ee/components/apiKeys/ApiKeyDialogForm.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { ClipboardCopyIcon } from "@heroicons/react/solid";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||
|
||||
import { TApiKeys } from "./ApiKeyListItem";
|
||||
|
||||
export default function ApiKeyDialogForm(props: {
|
||||
title: string;
|
||||
defaultValues?: Omit<TApiKeys, "userId" | "createdAt" | "lastUsedAt"> & { neverExpires: boolean };
|
||||
handleClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const {
|
||||
defaultValues = {
|
||||
note: "",
|
||||
neverExpires: false,
|
||||
expiresAt: dayjs().add(1, "month").toDate(),
|
||||
},
|
||||
} = props;
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [successfulNewApiKeyModal, setSuccessfulNewApiKeyModal] = useState(false);
|
||||
const [apiKeyDetails, setApiKeyDetails] = useState({
|
||||
id: "",
|
||||
hashedKey: "",
|
||||
expiresAt: null as Date | null,
|
||||
note: "" as string | null,
|
||||
neverExpires: false,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
const watchNeverExpires = form.watch("neverExpires");
|
||||
|
||||
return (
|
||||
<>
|
||||
{successfulNewApiKeyModal ? (
|
||||
<>
|
||||
<div className="mb-10">
|
||||
<h2 className="font-semi-bold font-cal mb-2 text-xl tracking-wide text-gray-900">
|
||||
{apiKeyDetails ? t("success_api_key_edited") : t("success_api_key_created")}
|
||||
</h2>
|
||||
<div className="text-sm text-gray-900">
|
||||
<span className="font-semibold">{t("success_api_key_created_bold_tagline")}</span>{" "}
|
||||
{t("you_will_only_view_it_once")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex">
|
||||
<code className="my-2 mr-1 w-full truncate rounded-sm bg-gray-100 py-2 px-3 align-middle font-mono text-gray-800">
|
||||
{apiKey}
|
||||
</code>
|
||||
<Tooltip content={t("copy_to_clipboard")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
showToast(t("api_key_copied"), "success");
|
||||
}}
|
||||
type="button"
|
||||
className=" my-2 px-4 text-base">
|
||||
<ClipboardCopyIcon className="mr-2 h-5 w-5 text-neutral-100" />
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{apiKeyDetails.neverExpires
|
||||
? t("never_expire_key")
|
||||
: `${t("expires")} ${apiKeyDetails?.expiresAt?.toLocaleDateString()}`}
|
||||
</span>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<Form<Omit<TApiKeys, "userId" | "createdAt" | "lastUsedAt"> & { neverExpires: boolean }>
|
||||
form={form}
|
||||
handleSubmit={async (event) => {
|
||||
const apiKey = await utils.client.mutation("viewer.apiKeys.create", event);
|
||||
setApiKey(apiKey);
|
||||
setApiKeyDetails({ ...event });
|
||||
await utils.invalidateQueries(["viewer.apiKeys.list"]);
|
||||
setSuccessfulNewApiKeyModal(true);
|
||||
}}
|
||||
className="space-y-4">
|
||||
<div className="mb-10 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">{props.title}</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("api_key_modal_subtitle")}</p>
|
||||
</div>
|
||||
<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>
|
||||
<Controller
|
||||
name="neverExpires"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch label={t("never_expire_key")} onCheckedChange={onChange} checked={value} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="expiresAt"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DatePicker
|
||||
disabled={watchNeverExpires}
|
||||
minDate={new Date()}
|
||||
date={value}
|
||||
onDatesChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{apiKeyDetails ? t("save") : t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
Normal file
77
apps/web/ee/components/apiKeys/ApiKeyListContainer.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { PlusIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
import ApiKeyDialogForm from "@ee/components/apiKeys/ApiKeyDialogForm";
|
||||
import ApiKeyListItem, { TApiKeys } from "@ee/components/apiKeys/ApiKeyListItem";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { List } from "@components/List";
|
||||
|
||||
export default function ApiKeyListContainer() {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.apiKeys.list"]);
|
||||
|
||||
const [newApiKeyModal, setNewApiKeyModal] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [apiKeyToEdit, setApiKeyToEdit] = useState<(TApiKeys & { neverExpires: boolean }) | null>(null);
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
<div className="flex flex-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="mb-9 sm:self-center">
|
||||
<Button StartIcon={PlusIcon} color="secondary" onClick={() => setNewApiKeyModal(true)}>
|
||||
{t("generate_new_api_key")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.length > 0 && (
|
||||
<List className="pb-6">
|
||||
{data.map((item: any) => (
|
||||
<ApiKeyListItem
|
||||
key={item.id}
|
||||
apiKey={item}
|
||||
onEditApiKey={() => {
|
||||
setApiKeyToEdit(item);
|
||||
setEditModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* New api key dialog */}
|
||||
<Dialog open={newApiKeyModal} onOpenChange={(isOpen) => !isOpen && setNewApiKeyModal(false)}>
|
||||
<DialogContent>
|
||||
<ApiKeyDialogForm title={t("create_api_key")} handleClose={() => setNewApiKeyModal(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit api key dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent>
|
||||
{apiKeyToEdit && (
|
||||
<ApiKeyDialogForm
|
||||
title={t("edit_api_key")}
|
||||
key={apiKeyToEdit.id}
|
||||
handleClose={() => setEditModalOpen(false)}
|
||||
defaultValues={apiKeyToEdit}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
Normal file
107
apps/web/ee/components/apiKeys/ApiKeyListItem.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { ListItem } from "@components/List";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export type TApiKeys = inferQueryOutput<"viewer.apiKeys.list">[number];
|
||||
|
||||
export default function ApiKeyListItem(props: { apiKey: TApiKeys; onEditApiKey: () => void }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const isExpired = props?.apiKey?.expiresAt ? props.apiKey.expiresAt < new Date() : null;
|
||||
const neverExpires = props?.apiKey?.expiresAt === null;
|
||||
const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.apiKeys.list"]);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<ListItem className="-mt-px flex w-full p-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex max-w-full flex-col truncate">
|
||||
<div className="flex space-x-2">
|
||||
<span className="text-gray-900">
|
||||
{props?.apiKey?.note ? props.apiKey.note : t("api_key_no_note")}
|
||||
</span>
|
||||
{!neverExpires && isExpired && (
|
||||
<Badge className="-p-2" variant="default">
|
||||
{t("expired")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex">
|
||||
<span
|
||||
className={classNames(
|
||||
"flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse",
|
||||
isExpired ? "text-red-600" : "text-gray-500",
|
||||
neverExpires ? "text-yellow-600" : ""
|
||||
)}>
|
||||
{neverExpires ? (
|
||||
<div className="flex flex-row space-x-3 text-gray-500">
|
||||
<ExclamationIcon className="w-4" />
|
||||
{t("api_key_never_expires")}
|
||||
</div>
|
||||
) : (
|
||||
`${isExpired ? t("expired") : t("expires")} ${dayjs(
|
||||
props?.apiKey?.expiresAt?.toString()
|
||||
).fromNow()}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Tooltip content={t("edit_api_key")}>
|
||||
<Button
|
||||
onClick={() => props.onEditApiKey()}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={PencilAltIcon}
|
||||
className="ml-4 w-full self-center p-2"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dialog>
|
||||
<Tooltip content={t("delete_api_key")}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={TrashIcon}
|
||||
className="ml-2 w-full self-center p-2"
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</Tooltip>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("confirm_delete_api_key")}
|
||||
confirmBtnText={t("revoke_api_key")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={() =>
|
||||
deleteApiKey.mutate({
|
||||
id: props.apiKey.id,
|
||||
})
|
||||
}>
|
||||
{t("delete_api_key_confirm_title")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CreditCardIcon } from "@heroicons/react/solid";
|
||||
import { Elements } from "@stripe/react-stripe-js";
|
||||
import classNames from "classnames";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
@@ -8,6 +9,7 @@ import Head from "next/head";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core";
|
||||
import getStripe from "@calcom/stripe/client";
|
||||
import PaymentComponent from "@ee/components/stripe/Payment";
|
||||
import { PaymentPageProps } from "@ee/pages/payment/[uid]";
|
||||
@@ -26,16 +28,33 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
||||
const [date, setDate] = useState(dayjs.utc(props.booking.startTime));
|
||||
const { isReady, Theme } = useTheme(props.profile.theme);
|
||||
|
||||
const isEmbed = useIsEmbed();
|
||||
useEffect(() => {
|
||||
let embedIframeWidth = 0;
|
||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
||||
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
|
||||
}, []);
|
||||
if (isEmbed) {
|
||||
requestAnimationFrame(function fixStripeIframe() {
|
||||
// HACK: Look for stripe iframe and center position it just above the embed content
|
||||
const stripeIframeWrapper = document.querySelector(
|
||||
'iframe[src*="https://js.stripe.com/v3/authorize-with-url-inner"]'
|
||||
)?.parentElement;
|
||||
if (stripeIframeWrapper) {
|
||||
stripeIframeWrapper.style.margin = "0 auto";
|
||||
stripeIframeWrapper.style.width = embedIframeWidth + "px";
|
||||
}
|
||||
requestAnimationFrame(fixStripeIframe);
|
||||
});
|
||||
sdkActionManager?.on("__dimensionChanged", (e) => {
|
||||
embedIframeWidth = e.detail.data.iframeWidth as number;
|
||||
});
|
||||
}
|
||||
}, [isEmbed]);
|
||||
|
||||
const eventName = props.booking.title;
|
||||
|
||||
return isReady ? (
|
||||
<div className="h-screen bg-neutral-50 dark:bg-neutral-900">
|
||||
<div className="h-screen">
|
||||
<Theme />
|
||||
<Head>
|
||||
<title>
|
||||
@@ -51,7 +70,10 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className="inline-block transform overflow-hidden rounded-sm border border-neutral-200 bg-white px-8 pt-5 pb-4 text-left align-bottom transition-all dark:border-neutral-700 dark:bg-gray-800 sm:my-8 sm:w-full sm:max-w-lg sm:py-6 sm:align-middle"
|
||||
className={classNames(
|
||||
"main inline-block transform overflow-hidden rounded-lg border border-neutral-200 bg-white px-8 pt-5 pb-4 text-left align-bottom transition-all dark:border-neutral-700 dark:bg-gray-800 sm:w-full sm:max-w-lg sm:py-6 sm:align-middle",
|
||||
isEmbed ? "" : "sm:my-8"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
@@ -80,7 +81,7 @@ export default function TeamAvailabilityModal(props: Props) {
|
||||
</div>
|
||||
{props.team && props.member && (
|
||||
<TeamAvailabilityTimes
|
||||
className="overflow-scroll"
|
||||
className="overflow-auto"
|
||||
teamId={props.team.id}
|
||||
memberId={props.member.id}
|
||||
frequency={frequency}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
62
apps/web/ee/lib/impersonation/ImpersonationProvider.ts
Normal file
62
apps/web/ee/lib/impersonation/ImpersonationProvider.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
const ImpersonationProvider = CredentialsProvider({
|
||||
id: "impersonation-auth",
|
||||
name: "Impersonation",
|
||||
type: "credentials",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text " },
|
||||
},
|
||||
async authorize(creds, req) {
|
||||
// @ts-ignore need to figure out how to correctly type this
|
||||
const session = await getSession({ req });
|
||||
if (session?.user.role !== "ADMIN") {
|
||||
throw new Error("You do not have permission to do this.");
|
||||
}
|
||||
|
||||
if (session?.user.username === creds?.username) {
|
||||
throw new Error("You cannot impersonate yourself.");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: creds?.username,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("This user does not exist");
|
||||
}
|
||||
|
||||
// Log impersonations for audit purposes
|
||||
await prisma.impersonations.create({
|
||||
data: {
|
||||
impersonatedBy: {
|
||||
connect: {
|
||||
id: session.user.id,
|
||||
},
|
||||
},
|
||||
impersonatedUser: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const obj = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
impersonatedByUID: session?.user.id,
|
||||
};
|
||||
return obj;
|
||||
},
|
||||
});
|
||||
|
||||
export default ImpersonationProvider;
|
||||
@@ -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";
|
||||
@@ -32,7 +32,9 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
bookingId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment?.bookingId) {
|
||||
console.log(JSON.stringify(paymentIntent), JSON.stringify(payment));
|
||||
}
|
||||
if (!payment?.bookingId) throw new Error("Payment not found");
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
@@ -47,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
confirmed: true,
|
||||
attendees: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
@@ -68,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");
|
||||
@@ -135,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,
|
||||
@@ -172,6 +192,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
|
||||
if (event.account) {
|
||||
throw new HttpCode({ statusCode: 202, message: "Incoming connected account" });
|
||||
}
|
||||
|
||||
const handler = webhookHandlers[event.type];
|
||||
if (handler) {
|
||||
await handler(event);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import {
|
||||
QueryObserverIdleResult,
|
||||
QueryObserverLoadingErrorResult,
|
||||
@@ -31,6 +32,7 @@ type JSXElementOrNull = JSX.Element | null;
|
||||
|
||||
interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
|
||||
query: UseQueryResult<TData, TError>;
|
||||
customLoader?: ReactNode;
|
||||
error?: (
|
||||
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
|
||||
) => JSXElementOrNull;
|
||||
@@ -62,7 +64,6 @@ export function QueryCell<TData, TError extends ErrorLike>(
|
||||
opts: QueryCellOptionsNoEmpty<TData, TError> | QueryCellOptionsWithEmpty<TData, TError>
|
||||
) {
|
||||
const { query } = opts;
|
||||
|
||||
if (query.status === "success") {
|
||||
if ("empty" in opts && (query.data == null || (Array.isArray(query.data) && query.data.length === 0))) {
|
||||
return opts.empty(query);
|
||||
@@ -76,11 +77,13 @@ export function QueryCell<TData, TError extends ErrorLike>(
|
||||
)
|
||||
);
|
||||
}
|
||||
const StatusLoader = opts.customLoader || <Loader />; // Fixes edge case where this can return null form query cell
|
||||
|
||||
if (query.status === "loading") {
|
||||
return opts.loading?.(query) ?? <Loader />;
|
||||
return opts.loading?.(query) ?? StatusLoader;
|
||||
}
|
||||
if (query.status === "idle") {
|
||||
return opts.idle?.(query) ?? <Loader />;
|
||||
return opts.idle?.(query) ?? StatusLoader;
|
||||
}
|
||||
// impossible state
|
||||
return null;
|
||||
@@ -108,6 +111,7 @@ const withQuery = <TPath extends keyof TQueryValues & string>(
|
||||
>
|
||||
) {
|
||||
const query = trpc.useQuery(pathAndInput, params);
|
||||
|
||||
return <QueryCell query={query} {...opts} />;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Attendee } from "@prisma/client";
|
||||
import { TFunction } from "next-i18next";
|
||||
|
||||
import { Attendee } from "@calcom/prisma/client";
|
||||
import { Person } from "@calcom/types/Calendar";
|
||||
|
||||
export const attendeeToPersonConversionType = (attendees: Attendee[], t: TFunction): Person[] => {
|
||||
|
||||
85
apps/web/lib/auth/next-auth-custom-adapter.ts
Normal file
85
apps/web/lib/auth/next-auth-custom-adapter.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Account, IdentityProvider, Prisma, PrismaClient, User, VerificationToken } from "@prisma/client";
|
||||
|
||||
import { identityProviderNameMap } from "@lib/auth";
|
||||
|
||||
/** @return { import("next-auth/adapters").Adapter } */
|
||||
export default function CalComAdapter(prismaClient: PrismaClient) {
|
||||
return {
|
||||
createUser: (data: Prisma.UserCreateInput) => prismaClient.user.create({ data }),
|
||||
getUser: (id: User["id"]) => prismaClient.user.findUnique({ where: { id } }),
|
||||
getUserByEmail: (email: User["email"]) => prismaClient.user.findUnique({ where: { email } }),
|
||||
async getUserByAccount(provider_providerAccountId: {
|
||||
providerAccountId: Account["providerAccountId"];
|
||||
provider: User["identityProvider"];
|
||||
}) {
|
||||
let _account;
|
||||
const account = await prismaClient.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId,
|
||||
},
|
||||
select: { user: true },
|
||||
});
|
||||
if (account) {
|
||||
return (_account = account === null || account === void 0 ? void 0 : account.user) !== null &&
|
||||
_account !== void 0
|
||||
? _account
|
||||
: null;
|
||||
}
|
||||
|
||||
// NOTE: this code it's our fallback to users without Account but credentials in User Table
|
||||
// We should remove this code after all googles tokens have expired
|
||||
const provider = provider_providerAccountId?.provider.toUpperCase() as IdentityProvider;
|
||||
if (["GOOGLE", "SAML"].indexOf(provider) < 0) {
|
||||
return null;
|
||||
}
|
||||
const obtainProvider = identityProviderNameMap[provider].toUpperCase() as IdentityProvider;
|
||||
const user = await prismaClient.user.findFirst({
|
||||
where: {
|
||||
identityProviderId: provider_providerAccountId?.providerAccountId,
|
||||
identityProvider: obtainProvider,
|
||||
},
|
||||
});
|
||||
return user || null;
|
||||
},
|
||||
updateUser: ({ id, ...data }: Prisma.UserUncheckedCreateInput) =>
|
||||
prismaClient.user.update({ where: { id }, data }),
|
||||
deleteUser: (id: User["id"]) => prismaClient.user.delete({ where: { id } }),
|
||||
async createVerificationToken(data: VerificationToken) {
|
||||
const { id: _, ...verificationToken } = await prismaClient.verificationToken.create({
|
||||
data,
|
||||
});
|
||||
return verificationToken;
|
||||
},
|
||||
async useVerificationToken(identifier_token: Prisma.VerificationTokenIdentifierTokenCompoundUniqueInput) {
|
||||
try {
|
||||
const { id: _, ...verificationToken } = await prismaClient.verificationToken.delete({
|
||||
where: { identifier_token },
|
||||
});
|
||||
return verificationToken;
|
||||
} catch (error) {
|
||||
// If token already used/deleted, just return null
|
||||
// https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
|
||||
// @ts-ignore
|
||||
if (error.code === "P2025") return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
linkAccount: (data: Prisma.AccountCreateInput) => prismaClient.account.create({ data }),
|
||||
// @NOTE: All methods below here are not being used but leaved if they are required
|
||||
unlinkAccount: (provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput) =>
|
||||
prismaClient.account.delete({ where: { provider_providerAccountId } }),
|
||||
async getSessionAndUser(sessionToken: string) {
|
||||
const userAndSession = await prismaClient.session.findUnique({
|
||||
where: { sessionToken },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!userAndSession) return null;
|
||||
const { user, ...session } = userAndSession;
|
||||
return { user, session };
|
||||
},
|
||||
createSession: (data: Prisma.SessionCreateInput) => prismaClient.session.create({ data }),
|
||||
updateSession: (data: Prisma.SessionWhereUniqueInput) =>
|
||||
prismaClient.session.update({ where: { sessionToken: data.sessionToken }, data }),
|
||||
deleteSession: (sessionToken: string) => prismaClient.session.delete({ where: { sessionToken } }),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,8 +6,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
|
||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||
import { Attendee } from "@calcom/prisma/client";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -25,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,14 +4,15 @@ import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray } from "ics";
|
||||
import { DatasetJsonLdProps } from "next-seo";
|
||||
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 type { Person, CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { serverConfig } from "@lib/serverConfig";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import type { Person, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -30,10 +31,12 @@ dayjs.extend(toArray);
|
||||
export default class AttendeeScheduledEmail {
|
||||
calEvent: CalendarEvent;
|
||||
attendee: Person;
|
||||
recurringEvent: RecurringEvent;
|
||||
|
||||
constructor(calEvent: CalendarEvent, attendee: Person) {
|
||||
constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
|
||||
this.calEvent = calEvent;
|
||||
this.attendee = attendee;
|
||||
this.recurringEvent = recurringEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
@@ -54,6 +57,11 @@ export default class AttendeeScheduledEmail {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -73,6 +81,7 @@ export default class AttendeeScheduledEmail {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
})),
|
||||
...{ recurrenceRule },
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
@@ -126,7 +135,9 @@ 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)}
|
||||
@@ -158,7 +169,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()}
|
||||
@@ -251,12 +266,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(
|
||||
@@ -321,7 +354,8 @@ ${getRichDescription(this.calEvent)}
|
||||
}
|
||||
|
||||
protected getLocation(): string {
|
||||
let providerName = this.calEvent.location ? getAppName(this.calEvent.location) : "";
|
||||
let providerName = this.calEvent.location && getAppName(this.calEvent.location);
|
||||
|
||||
if (this.calEvent.location && this.calEvent.location.includes("integrations:")) {
|
||||
const location = this.calEvent.location.split(":")[1];
|
||||
providerName = location[0].toUpperCase() + location.slice(1);
|
||||
|
||||
573
apps/web/lib/emails/templates/confirm-email.html
Normal file
573
apps/web/lib/emails/templates/confirm-email.html
Normal file
@@ -0,0 +1,573 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<!-- <head> -->
|
||||
<title>${headerContent}</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Roboto:400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width:480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- </head> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<title></title>
|
||||
<style type="text/css" rel="stylesheet" media="all">
|
||||
/* Base ------------------------------ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css?family=Inter:400,700&display=swap');
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
-webkit-text-size-adjust: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
a {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preheader {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
mso-hide: all;
|
||||
font-size: 1px;
|
||||
line-height: 1px;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Type ------------------------------ */
|
||||
|
||||
body,
|
||||
td,
|
||||
th {
|
||||
font-family: 'Roboto', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: 0.4em 0 1.1875em;
|
||||
font-size: 16px;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
p.sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Utilities ------------------------------ */
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
/* Buttons ------------------------------ */
|
||||
|
||||
.button {
|
||||
background-color: #000;
|
||||
border-top: 10px solid #000;
|
||||
border-right: 18px solid #000;
|
||||
border-bottom: 10px solid #000;
|
||||
border-left: 18px solid #000;
|
||||
display: inline-block;
|
||||
color: #fff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 0;
|
||||
/* box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); */
|
||||
-webkit-text-size-adjust: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
/* Attribute list ------------------------------ */
|
||||
|
||||
.attributes {
|
||||
margin: 0 0 21px;
|
||||
}
|
||||
|
||||
.attributes_content {
|
||||
background-color: #f4f4f7;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.attributes_item {
|
||||
padding: 0;
|
||||
}
|
||||
/* Related Items ------------------------------ */
|
||||
|
||||
.related {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 25px 0 0 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.related_item {
|
||||
padding: 10px 0;
|
||||
color: #cbcccf;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.related_item-title {
|
||||
display: block;
|
||||
margin: 0.5em 0 0;
|
||||
}
|
||||
|
||||
.related_item-thumb {
|
||||
display: block;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.related_heading {
|
||||
border-top: 1px solid #cbcccf;
|
||||
text-align: center;
|
||||
padding: 25px 0 10px;
|
||||
}
|
||||
/* Data table ------------------------------ */
|
||||
|
||||
body {
|
||||
background-color: #f2f4f6;
|
||||
color: #51545e;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #51545e;
|
||||
}
|
||||
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #f2f4f6;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
/* Masthead ----------------------- */
|
||||
|
||||
.email-masthead {
|
||||
padding: 25px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-masthead_logo {
|
||||
width: 94px;
|
||||
}
|
||||
|
||||
.email-masthead_name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #a8aaaf;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 1px 0 white;
|
||||
}
|
||||
/* Body ------------------------------ */
|
||||
|
||||
.email-body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.email-body_inner {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.email-footer {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-footer p {
|
||||
color: #a8aaaf;
|
||||
}
|
||||
|
||||
.body-action {
|
||||
width: 100%;
|
||||
margin: 30px auto;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.body-sub {
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid #eaeaec;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
padding: 45px;
|
||||
}
|
||||
/*Media Queries ------------------------------ */
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-body_inner,
|
||||
.email-footer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body,
|
||||
.email-body,
|
||||
.email-body_inner,
|
||||
.email-content,
|
||||
.email-wrapper,
|
||||
.email-masthead,
|
||||
.email-footer {
|
||||
background-color: #333333 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
span,
|
||||
.purchase_item {
|
||||
color: #fff !important;
|
||||
}
|
||||
.attributes_content {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
.email-masthead_name {
|
||||
text-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
.f-fallback {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<span class="preheader">This link will expire in 10 min.</span>
|
||||
<table
|
||||
class="email-wrapper"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table
|
||||
class="email-content"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<!-- <tr>
|
||||
<td class="email-masthead">
|
||||
<a href="{{base_url}}" class="f-fallback email-masthead_name">
|
||||
Cal.com
|
||||
</a>
|
||||
</td>
|
||||
</tr> -->
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:32px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:89px;">
|
||||
<a href="{{base_url}}" target="_blank">
|
||||
<img height="19" src="https://app.cal.com/emails/CalLogo@2x.png" style="border:0;display:block;outline:none;text-decoration:none;height:19px;width:100%;font-size:13px;" width="89" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Email Body -->
|
||||
<tr>
|
||||
<td
|
||||
class="email-body"
|
||||
width="570"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
>
|
||||
<table
|
||||
class="email-body_inner"
|
||||
align="center"
|
||||
width="570"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<!-- Body content -->
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
<div class="f-fallback">
|
||||
<p>
|
||||
Click the button below to log in to Cal.com<br />
|
||||
This link will expire in 10 minutes.
|
||||
</p>
|
||||
<!-- Action -->
|
||||
<table
|
||||
class="body-action"
|
||||
align="center"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Border based button
|
||||
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="{{signin_url}}"
|
||||
class="f-fallback button"
|
||||
target="_blank"
|
||||
>Log into Cal.com</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
Confirming this request will securely log you in using
|
||||
{{email}}.
|
||||
</p>
|
||||
<p>Enjoy your new scheduling soultion by,<br />The Cal.com Team</p>
|
||||
<!-- Sub copy -->
|
||||
<table class="body-sub" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="f-fallback sub">
|
||||
If you’re having trouble with the button above,
|
||||
copy and paste the URL below into your web
|
||||
browser.
|
||||
</p>
|
||||
<p class="f-fallback sub">{{signin_url}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table
|
||||
class="email-footer"
|
||||
align="center"
|
||||
width="570"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
>
|
||||
<tr>
|
||||
<td class="content-cell" align="center">
|
||||
<p class="f-fallback sub align-center">
|
||||
© 2022 Cal.com. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,8 +2,7 @@ import { TFunction } from "next-i18next";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
|
||||
import { serverConfig } from "@lib/serverConfig";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
|
||||
import { emailHead, linkIcon, emailBodyLogo } from "./common";
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -5,13 +5,13 @@ 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 type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { serverConfig } from "@lib/serverConfig";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -29,9 +29,11 @@ dayjs.extend(toArray);
|
||||
|
||||
export default class OrganizerScheduledEmail {
|
||||
calEvent: CalendarEvent;
|
||||
recurringEvent: RecurringEvent;
|
||||
|
||||
constructor(calEvent: CalendarEvent) {
|
||||
constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
|
||||
this.calEvent = calEvent;
|
||||
this.recurringEvent = recurringEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
@@ -52,6 +54,11 @@ export default class OrganizerScheduledEmail {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -67,6 +74,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,
|
||||
@@ -122,7 +130,9 @@ export default class OrganizerScheduledEmail {
|
||||
|
||||
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)}
|
||||
@@ -154,7 +164,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()}
|
||||
@@ -241,12 +253,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(
|
||||
@@ -314,7 +344,7 @@ ${getRichDescription(this.calEvent)}
|
||||
}
|
||||
|
||||
protected getLocation(): string {
|
||||
let providerName = this.calEvent.location ? getAppName(this.calEvent.location) : "";
|
||||
let providerName = this.calEvent.location && getAppName(this.calEvent.location); // This returns null if nothing is found
|
||||
|
||||
if (this.calEvent.location && this.calEvent.location.includes("integrations:")) {
|
||||
const location = this.calEvent.location.split(":")[1];
|
||||
|
||||
@@ -2,8 +2,7 @@ import { TFunction } from "next-i18next";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
|
||||
import { serverConfig } from "@lib/serverConfig";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
|
||||
import { emailHead, linkIcon, emailBodyLogo } from "./common";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user