Compare commits
285 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0883b14b | ||
|
|
acc6db901c | ||
|
|
7f463830bd | ||
|
|
6a27fb2959 | ||
|
|
21867c9cd4 | ||
|
|
276821e0b5 | ||
|
|
8028b1ddad | ||
|
|
5abbd818d3 | ||
|
|
43944a7d31 | ||
|
|
8bdc137917 | ||
|
|
02fb15228b | ||
|
|
59a1db9068 | ||
|
|
8e956893ca | ||
|
|
d960e03acf | ||
|
|
99666440cf | ||
|
|
f274c0bde3 | ||
|
|
d1082e55a4 | ||
|
|
af0d1980c6 | ||
|
|
a6183e0ccf | ||
|
|
eea40c69f7 | ||
|
|
13ae773868 | ||
|
|
6f0fcc9d1b | ||
|
|
7d98c0bb1c | ||
|
|
82d77dc10f | ||
|
|
ae1f35f515 | ||
|
|
66f3fd2e07 | ||
|
|
cf346f6aa3 | ||
|
|
34d3aac4b0 | ||
|
|
c22b6ca670 | ||
|
|
fa1b29a99f | ||
|
|
d61238c832 | ||
|
|
28b432058a | ||
|
|
4360ada3e4 | ||
|
|
5336bf3fe2 | ||
|
|
6d5db1cb3a | ||
|
|
9fffaa20a2 | ||
|
|
fd73a4ac92 | ||
|
|
29a6c70fc3 | ||
|
|
96f6c644bd | ||
|
|
7c12bb1e20 | ||
|
|
10e796f956 | ||
|
|
071077f2dc | ||
|
|
afe957674c | ||
|
|
307b098f83 | ||
|
|
95a793dd5a | ||
|
|
a0057911c1 | ||
|
|
93c75b5fef | ||
|
|
53d7e57142 | ||
|
|
2c4a891a89 | ||
|
|
8e0c7759be | ||
|
|
41dc01ea3c | ||
|
|
9c985edb6b | ||
|
|
69ef309cb5 | ||
|
|
f10bf38292 | ||
|
|
02f68b104b | ||
|
|
8bc5a75249 | ||
|
|
97e4cca252 | ||
|
|
18d41b52a2 | ||
|
|
26c0f82edf | ||
|
|
c12436afb0 | ||
|
|
fead885aa4 | ||
|
|
e680bb1548 | ||
|
|
6e82d38249 | ||
|
|
9f63299a1a | ||
|
|
702f31c935 | ||
|
|
08db282a07 | ||
|
|
080a394bb3 | ||
|
|
8fb429e073 | ||
|
|
00a3ff89e4 | ||
|
|
8f3b854559 | ||
|
|
05edb144b2 | ||
|
|
8c173c840b | ||
|
|
b540f44d6c | ||
|
|
7493093a1a | ||
|
|
cf68541520 | ||
|
|
b4ee4413cc | ||
|
|
f214830d0f | ||
|
|
c92070a5a2 | ||
|
|
102ca5403d | ||
|
|
7fd57b88dc | ||
|
|
5f57694148 | ||
|
|
73c97e85d4 | ||
|
|
ccde0c20ab | ||
|
|
d2d3c67144 | ||
|
|
6d5af81f68 | ||
|
|
2e9d4125ed | ||
|
|
56c32beebc | ||
|
|
faa67e0bb6 | ||
|
|
ffebe8e901 | ||
|
|
2cafe2d98e | ||
|
|
d03038d976 | ||
|
|
7e392da78a | ||
|
|
f8f3456b92 | ||
|
|
3b637eefaa | ||
|
|
46e1d28881 | ||
|
|
f23cc8b99f | ||
|
|
6843347dd7 | ||
|
|
063d40aa0a | ||
|
|
bee5c83eed | ||
|
|
8132b04a27 | ||
|
|
dabf5367bc | ||
|
|
33287d6944 | ||
|
|
f229bb6513 | ||
|
|
c16aabd9e8 | ||
|
|
c06d8164bc | ||
|
|
080f2bb845 | ||
|
|
25e4e28c2a | ||
|
|
5b90ace8cf | ||
|
|
ba73960a02 | ||
|
|
94f64f9730 | ||
|
|
21d183e661 | ||
|
|
699d910ab4 | ||
|
|
3c6ac395cc | ||
|
|
6bb4b2e938 | ||
|
|
d1b063d59d | ||
|
|
cfbf419f57 | ||
|
|
5fdc5078cc | ||
|
|
d91f667d0c | ||
|
|
9ed666a475 | ||
|
|
39935306fc | ||
|
|
ce476bf90f | ||
|
|
b0d8eac2a2 | ||
|
|
e1df207f5d | ||
|
|
75c2ccff96 | ||
|
|
9d86039987 | ||
|
|
cde131a351 | ||
|
|
31d1bde52a | ||
|
|
4c5ae567e4 | ||
|
|
a3e0d0aec9 | ||
|
|
95af0fb631 | ||
|
|
a5522c98a0 | ||
|
|
81f3e824ff | ||
|
|
61c60fc319 | ||
|
|
b6da0f0553 | ||
|
|
5e3da4d178 | ||
|
|
66aeadffbb | ||
|
|
399f4978f8 | ||
|
|
0b9f6124e9 | ||
|
|
df4a41127f | ||
|
|
eceba51020 | ||
|
|
01eee52849 | ||
|
|
385421d250 | ||
|
|
c63d81719b | ||
|
|
96f6294542 | ||
|
|
5e7d34b9c4 | ||
|
|
472b295c93 | ||
|
|
5577a60f26 | ||
|
|
fefc314d35 | ||
|
|
06df6c9e91 | ||
|
|
e6587efd27 | ||
|
|
a551919152 | ||
|
|
173e7846e8 | ||
|
|
63635fc110 | ||
|
|
31a8f25bb6 | ||
|
|
652b15c9e7 | ||
|
|
f9bd93197e | ||
|
|
0e93af912e | ||
|
|
d1ffd1edae | ||
|
|
d340ee62bb | ||
|
|
c07b9b96fe | ||
|
|
551892fa30 | ||
|
|
7fd65ceb8a | ||
|
|
4fc8e2a2ac | ||
|
|
eebc1bce1a | ||
|
|
02dbb88e6b | ||
|
|
41755c8c90 | ||
|
|
9d512e70c4 | ||
|
|
f8b7e17fda | ||
|
|
4d58281d6f | ||
|
|
6932d3600e | ||
|
|
d76b9b0d01 | ||
|
|
b3f9921dd8 | ||
|
|
1e071126fe | ||
|
|
d7ce4fb983 | ||
|
|
2d6cb1eb73 | ||
|
|
ffff59dd00 | ||
|
|
a7f5250b4a | ||
|
|
4ff21deb89 | ||
|
|
5138c676b1 | ||
|
|
7c08e946c6 | ||
|
|
5dbb60dc85 | ||
|
|
26e76df6c8 | ||
|
|
c094d05913 | ||
|
|
9bbaf1a7fa | ||
|
|
3fc49a8cee | ||
|
|
95aa5fe308 | ||
|
|
9948f9d854 | ||
|
|
279b4d57f1 | ||
|
|
966a5f30ec | ||
|
|
d997aef4f8 | ||
|
|
be6ca25f08 | ||
|
|
f2436d2a04 | ||
|
|
2d055327c2 | ||
|
|
b33a3d5652 | ||
|
|
f71c0ddfc3 | ||
|
|
f293f8b5c4 | ||
|
|
0494fccb8e | ||
|
|
4a58da62d6 | ||
|
|
4e9c3be598 | ||
|
|
6b32f03027 | ||
|
|
4e102d8b30 | ||
|
|
d89271759a | ||
|
|
63800492a9 | ||
|
|
c58e3791d1 | ||
|
|
ef97f4115d | ||
|
|
6b0e8db496 | ||
|
|
80af5dd236 | ||
|
|
0390ae9ee1 | ||
|
|
2d2df2d4db | ||
|
|
cc5537dd1f | ||
|
|
bd66ca183f | ||
|
|
4f1a380969 | ||
|
|
4ca5bd58ee | ||
|
|
b1d804405b | ||
|
|
14ba410352 | ||
|
|
b0bb894e1a | ||
|
|
83a395bf55 | ||
|
|
ec58a9dd70 | ||
|
|
2d7e1ccc05 | ||
|
|
7490f07a32 | ||
|
|
6c62918c1f | ||
|
|
897d255676 | ||
|
|
1b8132eb2f | ||
|
|
49bb80eeb4 | ||
|
|
2104624633 | ||
|
|
94006156d7 | ||
|
|
92534c7e6d | ||
|
|
296697370d | ||
|
|
37a10a9638 | ||
|
|
05a7babd56 | ||
|
|
3341074bb2 | ||
|
|
1a77e4046e | ||
|
|
367da36660 | ||
|
|
552751ffcf | ||
|
|
785b156f95 | ||
|
|
a15b93c276 | ||
|
|
767d1fb186 | ||
|
|
f7a2e1e7ac | ||
|
|
1a27edd462 | ||
|
|
87dcdec044 | ||
|
|
e1964553c4 | ||
|
|
f536d1040c | ||
|
|
caeb2412de | ||
|
|
77266535e5 | ||
|
|
7716b4c15f | ||
|
|
91f13122eb | ||
|
|
3c967ab280 | ||
|
|
9f2e71beae | ||
|
|
ab2542501a | ||
|
|
6ed945943a | ||
|
|
b8980ced8e | ||
|
|
8671255d5c | ||
|
|
94b210329b | ||
|
|
a8e137a55c | ||
|
|
91a6e199a1 | ||
|
|
46f515a19f | ||
|
|
47ce2feb3c | ||
|
|
ae27601405 | ||
|
|
94f6c80d57 | ||
|
|
3c845cb226 | ||
|
|
ea72ecc9e5 | ||
|
|
e59d29a429 | ||
|
|
c558c880f2 | ||
|
|
77879bc193 | ||
|
|
1b813b0ee3 | ||
|
|
55587e92c1 | ||
|
|
39d395bf62 | ||
|
|
95f92cac28 | ||
|
|
deffb77875 | ||
|
|
6a211dd5b3 | ||
|
|
bcbf8390e0 | ||
|
|
df64af2aba | ||
|
|
39ecf914ed | ||
|
|
82ab6f7a5b | ||
|
|
faa74dae39 | ||
|
|
558897fe53 | ||
|
|
f9f856d7ea | ||
|
|
6e4f8e67b6 | ||
|
|
c9484172a4 | ||
|
|
fdc99b346a | ||
|
|
06cec35522 | ||
|
|
18c21d9b97 | ||
|
|
e94594d0b1 | ||
|
|
e21813ba96 | ||
|
|
c8505cd71c |
180
.env.example
180
.env.example
@@ -1,2 +1,178 @@
|
||||
# It now lives at `apps/web/.env.example`
|
||||
# DATABASE_URL got moved to `packages/prisma/.env.example`
|
||||
# ********** INDEX **********
|
||||
#
|
||||
# - LICENSE
|
||||
# - DATABASE
|
||||
# - SHARED
|
||||
# - NEXTAUTH
|
||||
# - E-MAIL SETTINGS
|
||||
# - APP STORE
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - HUBSPOT
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
# - TANDEM
|
||||
# - ZOOM
|
||||
# - GIPHY
|
||||
|
||||
# - LICENSE *************************************************************************************************
|
||||
# Set this value to 'agree' to accept our license:
|
||||
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
|
||||
#
|
||||
# Summary of terms:
|
||||
# - The codebase has to stay open source, whether it was modified or not
|
||||
# - You can not repackage or sell the codebase
|
||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ DATABASE_URL got moved to `packages/prisma/.env.example` ⚠️ ⚠️ ⚠️
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - SHARED **************************************************************************************************
|
||||
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
||||
# Change to 'http://localhost:3001' if running the website simultaneously
|
||||
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
||||
|
||||
# To enable SAML login, set both these variables
|
||||
# @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login
|
||||
# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml"
|
||||
SAML_DATABASE_URL=
|
||||
# SAML_ADMINS='pro@example.com'
|
||||
SAML_ADMINS=
|
||||
# If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line.
|
||||
# @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js
|
||||
# PGSSLMODE='no-verify'
|
||||
PGSSLMODE=
|
||||
|
||||
# - NEXTAUTH
|
||||
# @see: https://github.com/calendso/calendso/issues/263
|
||||
# @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=
|
||||
# @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
|
||||
|
||||
# Remove this var if you don't want Cal to collect anonymous usage
|
||||
NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r
|
||||
|
||||
# ApiKey for cronjobs
|
||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
CALENDSO_ENCRYPTION_KEY=
|
||||
|
||||
# Intercom Config
|
||||
NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
|
||||
# Zendesk Config
|
||||
NEXT_PUBLIC_ZENDESK_KEY=
|
||||
|
||||
# Help Scout Config
|
||||
NEXT_PUBLIC_HELPSCOUT_KEY=
|
||||
|
||||
# This is used so we can bypass emails in auth flows for E2E testing
|
||||
# Set it to "1" if you need to run E2E tests locally
|
||||
NEXT_PUBLIC_IS_E2E=
|
||||
|
||||
# Used for internal billing system
|
||||
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 *****************************************************************************************
|
||||
# Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to
|
||||
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
|
||||
# Configures the global From: header whilst sending emails.
|
||||
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
||||
|
||||
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
||||
# Note: The below configuration for Office 365 has been verified to work.
|
||||
EMAIL_SERVER_HOST='smtp.office365.com'
|
||||
EMAIL_SERVER_PORT=587
|
||||
EMAIL_SERVER_USER='<office365_emailAddress>'
|
||||
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
|
||||
EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
|
||||
# The following configuration for Gmail has been verified to work.
|
||||
# EMAIL_SERVER_HOST='smtp.gmail.com'
|
||||
# EMAIL_SERVER_PORT=465
|
||||
# EMAIL_SERVER_USER='<gmail_emailAddress>'
|
||||
## You will need to provision an App Password.
|
||||
## @see https://support.google.com/accounts/answer/185833
|
||||
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
||||
# **********************************************************************************************************
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
# - DAILY.CO VIDEO
|
||||
DAILY_API_KEY=
|
||||
DAILY_SCALE_PLAN=''
|
||||
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS='{}'
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
||||
GOOGLE_LOGIN_ENABLED=false
|
||||
|
||||
# - HUBSPOT
|
||||
# Used for the HubSpot integration
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-hubspot-client-id-and-secret
|
||||
HUBSPOT_CLIENT_ID=""
|
||||
HUBSPOT_CLIENT_SECRET=""
|
||||
|
||||
# - OFFICE 365
|
||||
# Used for the Office 365 / Outlook.com Calendar / MS Teams integration
|
||||
# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret
|
||||
MS_GRAPH_CLIENT_ID=
|
||||
MS_GRAPH_CLIENT_SECRET=
|
||||
|
||||
# - SLACK
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret
|
||||
SLACK_SIGNING_SECRET=
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
|
||||
# - STRIPE
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
STRIPE_CLIENT_ID= # ca_...
|
||||
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||
|
||||
# - TANDEM
|
||||
# Used for the Tandem integration -- contact support@tandem.chat for API access.
|
||||
TANDEM_CLIENT_ID=""
|
||||
TANDEM_CLIENT_SECRET=""
|
||||
TANDEM_BASE_URL="https://tandem.chat"
|
||||
|
||||
# - ZOOM
|
||||
# Used for the Zoom integration
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
|
||||
# - GIPHY
|
||||
# Used for the Giphy integration
|
||||
# @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key
|
||||
GIPHY_API_KEY=
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
packages/prisma/zod
|
||||
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. -->
|
||||
|
||||
1
.github/workflows/check-types.yml
vendored
1
.github/workflows/check-types.yml
vendored
@@ -6,7 +6,6 @@ on:
|
||||
jobs:
|
||||
types:
|
||||
name: Check types
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
|
||||
40
.github/workflows/e2e.yml
vendored
40
.github/workflows/e2e.yml
vendored
@@ -1,19 +1,25 @@
|
||||
name: E2E test
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request_target: # So we can test on forks
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- public/static/locales/**
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
BASE_URL: http://localhost:3000
|
||||
JWT_SECRET: secret
|
||||
PLAYWRIGHT_SECRET: ${{ secrets.CI_PLAYWRIGHT_SECRET }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
@@ -26,12 +32,13 @@ jobs:
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
# NEXTAUTH_URL: xxx
|
||||
EMAIL_FROM: e2e@cal.com
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
NEXT_PUBLIC_IS_E2E: 1
|
||||
# EMAIL_FROM: e2e@cal.com
|
||||
# EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
# EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
# EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
# EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||
# MS_GRAPH_CLIENT_ID: xxx
|
||||
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# ZOOM_CLIENT_ID: xxx
|
||||
@@ -44,25 +51,20 @@ jobs:
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
os: [ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache-dependency-path: yarn.lock
|
||||
node-version: ${{ matrix.node }}
|
||||
# cache: "yarn"
|
||||
# cache-dependency-path: yarn.lock
|
||||
|
||||
- name: Turbo Cache
|
||||
id: turbo-cache
|
||||
|
||||
20
.github/workflows/lint.yml
vendored
20
.github/workflows/lint.yml
vendored
@@ -1,23 +1,29 @@
|
||||
name: Lint
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Use Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
cache: "yarn"
|
||||
cache-dependency-path: yarn.lock
|
||||
node-version: ${{ matrix.node }}
|
||||
# cache: "yarn"
|
||||
# cache-dependency-path: yarn.lock
|
||||
|
||||
- name: Install deps
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
|
||||
21
.github/workflows/submodule-sync.yml
vendored
Normal file
21
.github/workflows/submodule-sync.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Submodule Sync
|
||||
on:
|
||||
schedule:
|
||||
- cron: "15 */4 * * *"
|
||||
workflow_dispatch: ~
|
||||
|
||||
jobs:
|
||||
submodule-sync:
|
||||
name: Submodule update
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: run action
|
||||
uses: releasehub-com/github-action-create-pr-parent-submodule@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
parent_repository: "calcom/cal.com"
|
||||
checkout_branch: "main"
|
||||
pr_against_branch: "main"
|
||||
owner: "calcom"
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -11,11 +11,11 @@ node_modules
|
||||
# testing
|
||||
coverage
|
||||
/test-results/
|
||||
playwright/videos
|
||||
playwright/screenshots
|
||||
playwright/artifacts
|
||||
playwright/results
|
||||
playwright/reports/*
|
||||
**/playwright/videos
|
||||
**/playwright/screenshots
|
||||
**/playwright/artifacts
|
||||
**/playwright/results
|
||||
**/playwright/reports/*
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
|
||||
10
.gitmodules
vendored
10
.gitmodules
vendored
@@ -1,6 +1,12 @@
|
||||
[submodule "apps/admin"]
|
||||
path = apps/admin
|
||||
url = https://github.com/calcom/admin.git
|
||||
branch = main
|
||||
[submodule "apps/api"]
|
||||
path = apps/api
|
||||
url = git@github.com:calcom/api.git
|
||||
url = https://github.com/calcom/api.git
|
||||
branch = main
|
||||
[submodule "apps/website"]
|
||||
path = apps/website
|
||||
url = git@github.com:calcom/website.git
|
||||
url = https://github.com/calcom/website.git
|
||||
branch = main
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.formatOnSave": true,
|
||||
// Auto-fix issues with ESLint when you save code changes
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"eslint.run": "onSave",
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"spellright.language": ["en"],
|
||||
"spellright.documentTypes": ["markdown"]
|
||||
|
||||
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.
|
||||
110
README.md
110
README.md
@@ -80,7 +80,7 @@ To get a local copy up and running, please follow these simple steps.
|
||||
|
||||
Here is what you need to be able to run Cal.
|
||||
|
||||
- Node.js
|
||||
- Node.js (Version: >=14.x <15)
|
||||
- PostgreSQL
|
||||
- Yarn _(recommended)_
|
||||
|
||||
@@ -90,7 +90,7 @@ Here is what you need to be able to run Cal.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone the repo
|
||||
1. Clone the repo into a public GitHub repository (to comply with AGPLv3. To clone in a private repository, [acquire a commercial license](https://cal.com/sales))
|
||||
|
||||
```sh
|
||||
git clone https://github.com/calcom/cal.com.git
|
||||
@@ -102,18 +102,13 @@ Here is what you need to be able to run Cal.
|
||||
cd cal.com
|
||||
```
|
||||
|
||||
1. Copy `apps/web/.env.example` to `apps/web/.env`
|
||||
|
||||
```sh
|
||||
cp apps/web/.env.example apps/web/.env
|
||||
cp packages/prisma/.env.example packages/prisma/.env
|
||||
```
|
||||
|
||||
1. Install packages with yarn
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the .env file.
|
||||
|
||||
#### Quick start with `yarn dx`
|
||||
|
||||
@@ -126,10 +121,10 @@ yarn dx
|
||||
|
||||
#### Development tip
|
||||
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `apps/web/.env` to get logging information for all the queries and mutations driven by **trpc**.
|
||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **trpc**.
|
||||
|
||||
```sh
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
||||
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
```
|
||||
|
||||
#### Manual setup
|
||||
@@ -196,10 +191,8 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
||||
### E2E-Testing
|
||||
|
||||
```sh
|
||||
# In first terminal
|
||||
yarn dx
|
||||
# In second terminal
|
||||
yarn workspace @calcom/web test-e2e
|
||||
# In a terminal. Just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
yarn workspace @calcom/web playwright-report
|
||||
@@ -213,7 +206,13 @@ yarn workspace @calcom/web playwright-report
|
||||
git pull
|
||||
```
|
||||
|
||||
2. Apply database migrations by running <b>one of</b> the following commands:
|
||||
1. Check if dependencies got added/updated/removed
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
1. Apply database migrations by running <b>one of</b> the following commands:
|
||||
|
||||
In a development environment, run:
|
||||
|
||||
@@ -229,16 +228,13 @@ yarn workspace @calcom/web playwright-report
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
```
|
||||
|
||||
3. Check the `.env.example` and compare it to your current `.env` file. In case there are any fields not present
|
||||
in your current `.env`, add them there.
|
||||
1. Check for `.env` variables changes
|
||||
|
||||
For the current version, especially check if the variable `BASE_URL` is present and properly set in your environment, for example:
|
||||
```sh
|
||||
yarn predev
|
||||
```
|
||||
|
||||
```
|
||||
BASE_URL='https://yourdomain.com'
|
||||
```
|
||||
|
||||
4. Start the server. In a development environment, just do:
|
||||
1. Start the server. In a development environment, just do:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
@@ -251,7 +247,7 @@ yarn workspace @calcom/web playwright-report
|
||||
yarn start
|
||||
```
|
||||
|
||||
5. Enjoy the new version.
|
||||
1. Enjoy the new version.
|
||||
<!-- DEPLOYMENT -->
|
||||
|
||||
## Deployment
|
||||
@@ -317,6 +313,57 @@ We have a list of [good first issues](https://github.com/calcom/cal.com/labels/
|
||||
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
|
||||
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
|
||||
|
||||
### Obtaining Slack Client ID and Secret and Signing Secret
|
||||
|
||||
To test this you will need to create a Slack app for yourself on [their apps website](https://api.slack.com/apps).
|
||||
|
||||
Copy and paste the app manifest below into the setting on your slack app. Be sure to replace `YOUR_DOMAIN` with your own domain or your proxy host if you're testing locally.
|
||||
|
||||
<details>
|
||||
<summary>App Manifest</summary>
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Cal.com Slack
|
||||
features:
|
||||
bot_user:
|
||||
display_name: Cal.com Slack
|
||||
always_online: false
|
||||
slash_commands:
|
||||
- command: /create-event
|
||||
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
|
||||
description: Create an event within Cal!
|
||||
should_escape: false
|
||||
- command: /today
|
||||
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
|
||||
description: View all your bookings for today
|
||||
should_escape: false
|
||||
oauth_config:
|
||||
redirect_urls:
|
||||
- https://YOUR_DOMAIN/api/integrations/slackmessaging/callback
|
||||
scopes:
|
||||
bot:
|
||||
- chat:write
|
||||
- commands
|
||||
- chat:write.public
|
||||
settings:
|
||||
interactivity:
|
||||
is_enabled: true
|
||||
request_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
|
||||
message_menu_options_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Add the integration as normal - slack app - add. Follow the oauth flow to add it to a server.
|
||||
|
||||
Next make sure you have your app running `yarn dx`. Then in the slack chat type one of these commands: `/create-event` or `/today`
|
||||
|
||||
> NOTE: Next you will need to setup a proxy server like [ngrok](https://ngrok.com/) to allow your local host machine to be hosted on a public https server.
|
||||
|
||||
### Obtaining Zoom Client ID and Secret
|
||||
|
||||
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
||||
@@ -341,6 +388,19 @@ We have a list of [good first issues](https://github.com/calcom/cal.com/labels/
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
5. If you have the [Daily Scale Plan](https://www.daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording.
|
||||
|
||||
### Obtaining HubSpot Client ID and Secret
|
||||
|
||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||
2. From within the home of the Developer account page, go to "Manage apps".
|
||||
3. Click "Create app" button top right.
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot othercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
2
app.json
2
app.json
@@ -18,7 +18,7 @@
|
||||
"description": "Application Key for symmetric encryption and decryption. Must be 32 bytes for AES256 encryption algorithm.",
|
||||
"value": "secret"
|
||||
},
|
||||
"JWT_SECRET": "secret"
|
||||
"NEXTAUTH_SECRET": "secret"
|
||||
},
|
||||
"scripts": {
|
||||
"postdeploy": "cd packages/prisma && npx prisma migrate deploy"
|
||||
|
||||
1
apps/admin
Submodule
1
apps/admin
Submodule
Submodule apps/admin added at cf71a8b47e
2
apps/api
2
apps/api
Submodule apps/api updated: 378cbf8f3a...6124577bc2
@@ -5,7 +5,7 @@
|
||||
</a>
|
||||
<a href="https://cal.com">Website</a>
|
||||
·
|
||||
<a href="https://github.com/calcom/docs/issues">Community Support</a>
|
||||
<a href="https://github.com/calcom/cal.com/issues">Community Support</a>
|
||||
</div>
|
||||
|
||||
# Cal.com Documentation
|
||||
|
||||
@@ -3,4 +3,14 @@ const withNextra = require("nextra")({
|
||||
themeConfig: "./theme.config.js",
|
||||
unstable_staticImage: true,
|
||||
});
|
||||
module.exports = withNextra();
|
||||
module.exports = withNextra({
|
||||
async rewrites() {
|
||||
return [
|
||||
// This redirects requests recieved at /api to /public-api to workaround nextjs default use of /api.
|
||||
{
|
||||
source: "/api",
|
||||
destination: "/public-api",
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "PORT=4000 next",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/docs.json",
|
||||
"start": "PORT=4000 next start",
|
||||
"build": "next build"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AppProps } from "next/app";
|
||||
import "nextra-theme-docs/style.css";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
export default function Nextra({ Component, pageProps }) {
|
||||
export default function Nextra({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
@@ -12,20 +12,26 @@ All apps can be found under `packages/app-store`. In this folder is `_example` w
|
||||
|
||||
```sh
|
||||
├──_example
|
||||
| ├──index.ts
|
||||
| ├──package.json
|
||||
| ├──.env.example
|
||||
|
|
||||
| ├──api
|
||||
| | ├──example.ts
|
||||
| | ├──index.ts
|
||||
|
|
||||
| ├──components
|
||||
| | ├──InstallAppButton.tsx
|
||||
| | ├──index.ts
|
||||
|
|
||||
| ├──lib
|
||||
| | ├──adaptor.ts
|
||||
| | ├──index.ts
|
||||
|
|
||||
| ├──static
|
||||
| | ├──icon.svg
|
||||
|
|
||||
| ├──index.ts
|
||||
| ├──package.json
|
||||
| ├──.env.example
|
||||
| ├──README.mdx
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
@@ -38,8 +44,17 @@ In `index.js` fill out the meta data that will be rendered on the app page. Unde
|
||||
|
||||
Under the `/api` folder, this is where any API calls that are associated with your app will be handled. Since cal.com uses Next.js we use dynamic API routes. In this example if we want to hit `/api/example.ts` the route would be `{BASE_URL}/api/integrations/_example/example`. Export your endpoints in an `index.ts` file under `/api` folder and import them in your main `index.ts` file.
|
||||
|
||||
Under the `/components` folder, this is where the install button for your app should live. Follow the template under `_example` to add your on click action (ex. Redirecting to a log in page or opening a modal).
|
||||
|
||||
The `/lib` folder is where the functions of your app live. For example, when creating a booking with a MS Teams link the function to make the call to grab the link lives in the `/lib` folder. Export your endpoints in an `index.ts` file under `/lib` folder and import them in your main `index.ts` file.
|
||||
|
||||
The `/static` folder is where your assets live.
|
||||
On the app store page you can customize your apps description by adding a markdown file called `README.mdx`. If you do not add one then the description from you `package.json` will be used instead.
|
||||
|
||||
The `/static` folder is where you can store your app icon and any images that your `README.mdx` may use.
|
||||
|
||||
## Adding Your App to the App Store
|
||||
To render your app on the app store page, go to `packages/app-store/index.ts`. Import your app into the file and add it to the `appStore` object.
|
||||
|
||||
Under `packages/app-store/components.tsx`, in the `InstallAppButtonMap` object dynamically import your install button. Your install button should live under `{your_app}/components`.
|
||||
|
||||
If you need any help feel free to join us on [Slack](https://cal.com/slack)
|
||||
|
||||
@@ -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>
|
||||
|
||||
211
apps/docs/pages/integrations/embed.mdx
Normal file
211
apps/docs/pages/integrations/embed.mdx
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
title: Embed
|
||||
---
|
||||
|
||||
# Embed
|
||||
|
||||
The Embed allows your website visitors to book a meeting with you directly from your website.
|
||||
|
||||
## Install on any website
|
||||
|
||||
- _Step-1._ Install the Vanilla JS Snippet
|
||||
```html
|
||||
<script>
|
||||
(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, "https://cal.com/embed.js", "init");
|
||||
Cal("init")
|
||||
</script>
|
||||
```
|
||||
|
||||
## Install with a Framework
|
||||
|
||||
### embed-react
|
||||
|
||||
It provides a react component `<Cal>` that can be used to show the embed inline at that place.
|
||||
|
||||
```bash
|
||||
yarn add @calcom/embed-react
|
||||
```
|
||||
|
||||
### Any XYZ Framework
|
||||
|
||||
You can use Vanilla JS Snippet to install
|
||||
|
||||
## Popular ways in which you can embed on your website
|
||||
|
||||
Assuming that you have followed the steps of installing and initializing the snippet, you can show the embed in following ways:
|
||||
|
||||
### Inline
|
||||
|
||||
Show the embed inline inside a container element. It would take the width and height of the container element.
|
||||
|
||||
<details>
|
||||
<summary>_Vanilla JS_</summary>
|
||||
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", {
|
||||
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
|
||||
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
|
||||
config: {
|
||||
name: "John Doe", // Prefill Name
|
||||
email: "johndoe@gmail.com", // Prefill Email
|
||||
notes: "Test Meeting", // Prefill Notes
|
||||
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
|
||||
theme: "dark", // "dark" or "light" theme
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
####
|
||||
|
||||
<details>
|
||||
<summary>_React_</summary>
|
||||
|
||||
```jsx
|
||||
import Cal from "@calcom/embed-react";
|
||||
|
||||
const MyComponent = () => (
|
||||
<Cal
|
||||
calLink="pro"
|
||||
config={{
|
||||
name: "John Doe",
|
||||
email: "johndoe@gmail.com",
|
||||
notes: "Test Meeting",
|
||||
guests: ["janedoe@gmail.com"],
|
||||
theme: "dark",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Popup on any existing element
|
||||
|
||||
To show the embed as a popup on clicking an element, add `data-cal-link` attribute to the element.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Vanilla JS</summary>
|
||||
|
||||
To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element.
|
||||
|
||||
<button data-cal-link="jane" data-cal-config="A valid config JSON"></button>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>React</summary>
|
||||
```jsx
|
||||
import "@calcom/embed-react";
|
||||
|
||||
const MyComponent = ()=> {
|
||||
return <button data-cal-link="jane" data-cal-config='A valid config JSON'></button>
|
||||
}
|
||||
|
||||
````
|
||||
|
||||
</details>
|
||||
|
||||
## Supported Instructions
|
||||
|
||||
Consider an instruction as a function with that name and that would be called with the given arguments.
|
||||
|
||||
### `inline`
|
||||
|
||||
Appends embed inline as the child of the element.
|
||||
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", { elementOrSelector, calLink });
|
||||
</script>
|
||||
````
|
||||
|
||||
- `elementOrSelector` - Give it either a valid CSS selector or an HTMLElement instance directly
|
||||
|
||||
- `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](). It makes it easy to configure the calendar host once and use as many links you want with just usernames
|
||||
|
||||
### `ui`
|
||||
|
||||
Configure UI for embed. Make it look part of your webpage.
|
||||
|
||||
```html
|
||||
<script>
|
||||
Cal("inline", { styles });
|
||||
</script>
|
||||
```
|
||||
|
||||
- `styles` - It supports styling for `body` and `eventTypeListItem`. Right now we support just background on these two.
|
||||
|
||||
### preload
|
||||
|
||||
Usage:
|
||||
|
||||
If you want to open cal link on some action. Make it pop open instantly by preloading it.
|
||||
|
||||
```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.
|
||||
```html
|
||||
<script>
|
||||
Cal("on", {
|
||||
action: "ANY_ACTION_NAME",
|
||||
callback: (e)=>{
|
||||
// `data` is properties for the event.
|
||||
// `type` is the name of the action(You can also call it type of the action.) This would be same as "ANY_ACTION_NAME" except when ANY_ACTION_NAME="*" which listens to all the events.
|
||||
// `namespace` tells you the Cal namespace for which the event is fired/
|
||||
const {data, type, namespace} = e.detail;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Following are the list of supported actions.
|
||||
-
|
||||
| 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 |
|
||||
| linkReady | Tells that the link is ready to be shown now. | None |
|
||||
| linkFailed | Fired if link fails to load | code: number; // Error Code <br/><br/>msg: string; //Human Readable msg <br/><br/>data: object // More details to debug the error |
|
||||
| __iframeReady | It is fired when the embedded iframe is ready to communicate with parent snippet. This is mostly for internal use by Embed Snippet | None |
|
||||
| __windowLoadComplete | Tells that window load for iframe is complete | None |
|
||||
| __dimensionChanged | Tells that dimensions of the content inside the iframe changed. | iframeWidth:number, iframeHeight:number |
|
||||
|
||||
_Actions that start with __ are internal._
|
||||
@@ -5,17 +5,17 @@ title: Introduction
|
||||
# Integrations
|
||||
|
||||
## Connecting new calendars
|
||||
1. Go to the [Cal App Store](https://app.cal.com/integrations).
|
||||
1. Go to the [Cal App Store](https://app.cal.com/apps).
|
||||
2. Located at the top right of the screen, press the button saying '+ Connect A New App'
|
||||
3. Choose the account your calendar is connected too by clicking 'Add'. (e.g. Google, Office 365, Zoom)
|
||||
4. You will be redirected to the log in page of the chosen account.
|
||||
5. Allow Cal access to view and edit your calendars.
|
||||
6. You will be sent back to the [Cal App Store](https://app.cal.com/integrations). From here you will now be able to see your connected calendar!
|
||||
6. You will be sent back to the [Cal App Store](https://app.cal.com/apps/installed). From here you will now be able to see your connected calendar!
|
||||
|
||||
## How to choose the primary Calendar?
|
||||
|
||||
If you have two or more integrated calendars and you want your events to show in only one, you can define a primary calendar like this:
|
||||
|
||||
1. Go to your [Integrations](https://app.cal.com/integrations) page.
|
||||
1. Go to your [Installed](https://app.cal.com/apps/installed) page.
|
||||
2. Next to your `Calendars` you will see a dropdown that says `Create events on:`.
|
||||
3. Select your primary calendar.
|
||||
|
||||
21
apps/docs/pages/integrations/slack.mdx
Normal file
21
apps/docs/pages/integrations/slack.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
Title: Slack
|
||||
---
|
||||
|
||||
# Slack
|
||||
|
||||
## Connecting
|
||||
Connecting the bot is easy - If you are a workspace admin, the install button will add the bot to the workspace and also authorize your account with the bot. If you are a normal user, the install button will connect your Slack account with Cal.com. This will allow you to perform commands in Slack.
|
||||
|
||||
## Commands
|
||||
`/today` - This command will display all meetings you have in your Cal.com profile for the current day. This will send a hidden message (not visible to anyone other than you) to the channel you issued the command in.
|
||||
|
||||
`/create-event` - It will display a modal allowing you to simply create a meeting invite with anyone in Slack. Success/Error information will be displayed in a private direct message from the bot.
|
||||
|
||||
`/links` - This command will post all your Cal.com meeting links into the current Slack channel you are in. **Note**: The bot needs to have permission to talk in the channel you are sending the message in. Otherwise, you won't be able to send your links.
|
||||
|
||||
As this is the beggining stage of our Slack integration, we plan on adding more commands in the future that will further improve your Cal.com experience.
|
||||
|
||||
## Self-Hosted
|
||||
If you are using our self-hosted version, please refer to our documentation in
|
||||
[cal.com/README.md](https://github.com/calcom/cal.com/blob/main/README.md#obtaining-slack-client-id-and-secret-and-signing-secret)
|
||||
@@ -6,6 +6,7 @@
|
||||
"event-types": "Event Types",
|
||||
"teams": "Teams",
|
||||
"integrations": "Integrations",
|
||||
"public-api": "API",
|
||||
"webhooks": "Webhooks",
|
||||
"settings": "Settings",
|
||||
"import": "Import",
|
||||
|
||||
11
apps/docs/pages/public-api.mdx
Normal file
11
apps/docs/pages/public-api.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Bleed from 'nextra-theme-docs/bleed'
|
||||
import Head from "next/head";
|
||||
|
||||
<Bleed full>
|
||||
<Head><title>Public API | Cal.com</title></Head>
|
||||
<iframe src="https://developer.cal.com"
|
||||
width="100%"
|
||||
height="900px"
|
||||
title="Public API | Cal.com"
|
||||
></iframe>
|
||||
</Bleed>
|
||||
@@ -27,7 +27,7 @@ or
|
||||
1. Clone calendso-docker
|
||||
|
||||
```bash
|
||||
git clone git@github.com:calendso/calendso-docker.git --recursive
|
||||
git clone --recursive https://github.com/calendso/docker.git calendso-docker
|
||||
```
|
||||
|
||||
2. Update `.env` if needed
|
||||
@@ -40,7 +40,7 @@ or
|
||||
|
||||
4. Start prisma studio
|
||||
```bash
|
||||
docker-compose exec calendso -- npx prisma studio
|
||||
docker-compose exec calendso npx prisma studio
|
||||
```
|
||||
5. Open a browser to [port 5555](http://localhost:5555) on your localhost to look at or modify the database content.
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": false
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/swagger/.env.example
Normal file
1
apps/swagger/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/docs
|
||||
34
apps/swagger/.gitignore
vendored
Normal file
34
apps/swagger/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
34
apps/swagger/README.md
Normal file
34
apps/swagger/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
353
apps/swagger/lib/snippets.ts
Normal file
353
apps/swagger/lib/snippets.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import * as OpenAPISnippet from "openapi-snippet";
|
||||
|
||||
export const requestSnippets = {
|
||||
generators: {
|
||||
curl_bash: {
|
||||
title: "cURL (bash)",
|
||||
syntax: "bash",
|
||||
},
|
||||
curl_powershell: {
|
||||
title: "cURL (PowerShell)",
|
||||
syntax: "powershell",
|
||||
},
|
||||
curl_cmd: {
|
||||
title: "cURL (CMD)",
|
||||
syntax: "bash",
|
||||
},
|
||||
node: {
|
||||
title: "Node",
|
||||
syntax: "node",
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["node"],
|
||||
};
|
||||
// Since swagger-ui-react was not configured to change the request snippets some workarounds required
|
||||
// configuration will be added programatically
|
||||
// Custom Plugin
|
||||
export const SnippedGenerator = {
|
||||
statePlugins: {
|
||||
// extend some internals to gain information about current path, method and spec in the generator function metioned later
|
||||
spec: {
|
||||
wrapSelectors: {
|
||||
requestFor: (ori, system) => (state, path, method) => {
|
||||
return ori(path, method)
|
||||
?.set("spec", state.get("json", {}))
|
||||
?.setIn(["oasPathMethod", "path"], path)
|
||||
?.setIn(["oasPathMethod", "method"], method);
|
||||
},
|
||||
mutatedRequestFor: (ori) => (state, path, method) => {
|
||||
return ori(path, method)
|
||||
?.set("spec", state.get("json", {}))
|
||||
?.setIn(["oasPathMethod", "path"], path)
|
||||
?.setIn(["oasPathMethod", "method"], method);
|
||||
},
|
||||
},
|
||||
},
|
||||
// extend the request snippets core plugin
|
||||
requestSnippets: {
|
||||
wrapSelectors: {
|
||||
// add additional snippet generators here
|
||||
getSnippetGenerators:
|
||||
(ori, system) =>
|
||||
(state, ...args) =>
|
||||
ori(state, ...args)
|
||||
// add node native snippet generator
|
||||
// .set(
|
||||
// // key
|
||||
// "node_native",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "NodeJs Native",
|
||||
// syntax: "javascript",
|
||||
// hostname: "test",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["node_native"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// // Since I don't know why hostname was undefinedundefined, I harcoded it here
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
.set(
|
||||
// key
|
||||
"node_fetch",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "NodeJS",
|
||||
syntax: "javascript",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["node_fetch"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"shell_httpie",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "HTTPie",
|
||||
syntax: "bash",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["shell_httpie"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"php_curl",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "PHP",
|
||||
syntax: "php",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["php_curl"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"java_okhttp",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Java",
|
||||
syntax: "java",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
console.log(spec, oasPathMethod, path, method);
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["java_okhttp"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
// .set(
|
||||
// // key
|
||||
// "java",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "Java (Unirest)",
|
||||
// syntax: "java",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["java"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
// .set(
|
||||
// // key
|
||||
// "c_libcurl",
|
||||
// // config and generator function
|
||||
// system.Im.fromJS({
|
||||
// title: "C (libcurl) ",
|
||||
// syntax: "bash",
|
||||
// fn: (req) => {
|
||||
// // get extended info about request
|
||||
// const { spec, oasPathMethod } = req.toJS();
|
||||
// const { path, method } = oasPathMethod;
|
||||
|
||||
// // run OpenAPISnippet for target node
|
||||
// const targets = ["c_libcurl"];
|
||||
// let snippet;
|
||||
// try {
|
||||
// // set request snippet content
|
||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
||||
// spec,
|
||||
// path,
|
||||
// method,
|
||||
// targets
|
||||
// ).snippets[0].content;
|
||||
// } catch (err) {
|
||||
// // set to error in case it happens the npm package has some flaws
|
||||
// snippet = JSON.stringify(snippet);
|
||||
// }
|
||||
// // return stringified snipped
|
||||
// return snippet;
|
||||
// },
|
||||
// })
|
||||
// )
|
||||
.set(
|
||||
// key
|
||||
"go_native",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Go",
|
||||
syntax: "bash",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["go_native"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"ruby",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Ruby",
|
||||
syntax: "ruby",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["ruby"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
)
|
||||
.set(
|
||||
// key
|
||||
"python",
|
||||
// config and generator function
|
||||
system.Im.fromJS({
|
||||
title: "Python",
|
||||
syntax: "python",
|
||||
fn: (req) => {
|
||||
// get extended info about request
|
||||
const { spec, oasPathMethod } = req.toJS();
|
||||
const { path, method } = oasPathMethod;
|
||||
|
||||
// run OpenAPISnippet for target node
|
||||
const targets = ["python"];
|
||||
let snippet;
|
||||
try {
|
||||
// set request snippet content
|
||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
||||
.content;
|
||||
} catch (err) {
|
||||
// set to error in case it happens the npm package has some flaws
|
||||
snippet = JSON.stringify(snippet);
|
||||
}
|
||||
// return stringified snipped
|
||||
return snippet;
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
5
apps/swagger/next-env.d.ts
vendored
Normal file
5
apps/swagger/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
25
apps/swagger/package.json
Normal file
25
apps/swagger/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@calcom/swagger",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "PORT=4200 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.5.1",
|
||||
"isarray": "2.0.5",
|
||||
"next": "12.1.5",
|
||||
"openapi-snippet": "^0.13.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"swagger-ui-react": "4.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "17.0.27",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/react-dom": "17.0.14",
|
||||
"typescript": "4.6.3"
|
||||
}
|
||||
}
|
||||
10
apps/swagger/pages/_app.tsx
Normal file
10
apps/swagger/pages/_app.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import "highlight.js/styles/default.css";
|
||||
import "swagger-ui-react/swagger-ui.css";
|
||||
|
||||
import "../styles/globals.css";
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
21
apps/swagger/pages/index.tsx
Normal file
21
apps/swagger/pages/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { SnippedGenerator, requestSnippets } from "@lib/snippets";
|
||||
|
||||
const SwaggerUI: any = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||
|
||||
export default function APIDocs() {
|
||||
return (
|
||||
<SwaggerUI
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/docs"}
|
||||
supportedSubmitMethods={["get", "post", "delete", "patch"]}
|
||||
requestSnippetsEnabled={true}
|
||||
requestSnippets={requestSnippets}
|
||||
plugins={[SnippedGenerator]}
|
||||
tryItOutEnabled={true}
|
||||
syntaxHighlight={true}
|
||||
docExpansion="none"
|
||||
filter={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
BIN
apps/swagger/public/favicon.ico
Normal file
BIN
apps/swagger/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
16
apps/swagger/styles/globals.css
Normal file
16
apps/swagger/styles/globals.css
Normal file
@@ -0,0 +1,16 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
24
apps/swagger/tsconfig.json
Normal file
24
apps/swagger/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
2583
apps/swagger/yarn.lock
Normal file
2583
apps/swagger/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
||||
# Set this value to 'agree' to accept our license:
|
||||
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
|
||||
#
|
||||
# Summary of terms:
|
||||
# - The codebase has to stay open source, whether it was modified or not
|
||||
# - You can not repackage or sell the codebase
|
||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
|
||||
# ⚠️ ⚠️ ⚠️ DATABASE_URL got moved to `packages/prisma/.env.example` ⚠️ ⚠️ ⚠️
|
||||
|
||||
# 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
|
||||
|
||||
BASE_URL='http://localhost:3000'
|
||||
NEXT_PUBLIC_APP_URL='http://localhost:3000'
|
||||
|
||||
JWT_SECRET='secret'
|
||||
# This is used so we can bypass emails in auth flows for E2E testing
|
||||
PLAYWRIGHT_SECRET=
|
||||
|
||||
# To enable SAML login, set both these variables
|
||||
# @see https://github.com/calendso/calendso/tree/main/ee#setting-up-saml-login
|
||||
# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml"
|
||||
# SAML_ADMINS='pro@example.com'
|
||||
# If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line.
|
||||
# @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js
|
||||
##PGSSLMODE='no-verify'
|
||||
|
||||
# @see: https://github.com/calendso/calendso/issues/263
|
||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
||||
# NEXTAUTH_URL='http://localhost:3000'
|
||||
|
||||
# Remove this var if you don't want Cal to collect anonymous usage
|
||||
NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r
|
||||
|
||||
# Used for the Office 365 / Outlook.com Calendar integration
|
||||
MS_GRAPH_CLIENT_ID=
|
||||
MS_GRAPH_CLIENT_SECRET=
|
||||
|
||||
# Used for the Zoom integration
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
|
||||
#Used for the Daily integration
|
||||
DAILY_API_KEY=
|
||||
DAILY_SCALE_PLAN=''
|
||||
|
||||
# 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"
|
||||
|
||||
# E-mail settings
|
||||
|
||||
# Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to
|
||||
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
|
||||
|
||||
# Configures the global From: header whilst sending emails.
|
||||
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
||||
|
||||
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
||||
# Note: The below configuration for Office 365 has been verified to work.
|
||||
EMAIL_SERVER_HOST='smtp.office365.com'
|
||||
EMAIL_SERVER_PORT=587
|
||||
EMAIL_SERVER_USER='<office365_emailAddress>'
|
||||
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
|
||||
EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
# The following configuration for Gmail has been verified to work.
|
||||
# EMAIL_SERVER_HOST='smtp.gmail.com'
|
||||
# EMAIL_SERVER_PORT=465
|
||||
# EMAIL_SERVER_USER='<gmail_emailAddress>'
|
||||
## You will need to provision an App Password.
|
||||
## @see https://support.google.com/accounts/answer/185833
|
||||
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
||||
|
||||
# ApiKey for cronjobs
|
||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||
|
||||
# Stripe Config
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
STRIPE_CLIENT_ID= # ca_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
CALENDSO_ENCRYPTION_KEY=
|
||||
|
||||
# Intercom Config
|
||||
NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
|
||||
# Zendesk Config
|
||||
NEXT_PUBLIC_ZENDESK_KEY=
|
||||
241
apps/web/components/App.tsx
Normal file
241
apps/web/components/App.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
DocumentTextIcon,
|
||||
ExternalLinkIcon,
|
||||
FlagIcon,
|
||||
MailIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
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 Shell from "@components/Shell";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
export default function App({
|
||||
name,
|
||||
type,
|
||||
logo,
|
||||
body,
|
||||
categories,
|
||||
author,
|
||||
price = 0,
|
||||
commission,
|
||||
isGlobal = false,
|
||||
feeType,
|
||||
docs,
|
||||
website,
|
||||
email,
|
||||
tos,
|
||||
privacy,
|
||||
}: {
|
||||
name: string;
|
||||
type: AppType["type"];
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
author: string;
|
||||
pro?: boolean;
|
||||
price?: number;
|
||||
commission?: number;
|
||||
feeType?: AppType["feeType"];
|
||||
docs?: string;
|
||||
website?: string;
|
||||
email: string; // required
|
||||
tos?: string;
|
||||
privacy?: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
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>
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="bg-gray-50 px-4">
|
||||
<Link href="/apps">
|
||||
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
|
||||
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
|
||||
</a>
|
||||
</Link>
|
||||
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
||||
<div className="flex">
|
||||
<img className="h-16 w-16" src={logo} alt={name} />
|
||||
<header className="px-4 py-2">
|
||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
||||
<h2 className="text-sm text-gray-500">
|
||||
<span className="capitalize">{categories[0]}</span> • {t("published_by", { author })}
|
||||
</h2>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
||||
{isGlobal || installedApp ? (
|
||||
<Button color="secondary" disabled title="This app is globally installed">
|
||||
{t("installed")}
|
||||
</Button>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
<Button data-testid="install-app-button" {...buttonProps}>
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<small className="block text-right">
|
||||
{feeType === "usage-based"
|
||||
? commission + "% + " + priceInDollar + "/booking"
|
||||
: priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* reintroduce once we show permissions and features
|
||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
|
||||
</div>
|
||||
|
||||
<div className="justify-between px-4 py-10 md:flex">
|
||||
<div className="prose-sm prose mb-6">{body}</div>
|
||||
<div className="md:max-w-80 flex-1 md:ml-8">
|
||||
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
|
||||
<div className="space-x-2">
|
||||
{categories.map((category) => (
|
||||
<Link href={"/apps/categories/" + category} key={category}>
|
||||
<a>
|
||||
<Badge variant="success">{category}</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<h4 className="mt-8 font-medium text-gray-900 ">{t("pricing")}</h4>
|
||||
<small>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
<h4 className="mt-8 mb-2 font-medium text-gray-900 ">{t("learn_more")}</h4>
|
||||
<ul className="prose -ml-1 -mr-1 text-xs leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={docs}>
|
||||
<BookOpenIcon className="mr-1 -mt-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={website}>
|
||||
<ExternalLinkIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<MailIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={tos}>
|
||||
<DocumentTextIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<ShieldCheckIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-6" />
|
||||
<small className="leading-1 block text-gray-500">
|
||||
Every app published on the Cal.com App Store is open source and thoroughly tested via peer
|
||||
reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are
|
||||
published by Cal.com. If you encounter inappropriate content or behaviour please report it.
|
||||
</small>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<FlagIcon className="inline h-3 w-3" /> Report App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/AppsShell.tsx
Normal file
30
apps/web/components/AppsShell.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
export default function AppsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
const { status } = useSession();
|
||||
const tabs = [
|
||||
{
|
||||
name: t("app_store"),
|
||||
href: "/apps",
|
||||
},
|
||||
{
|
||||
name: t("installed_apps"),
|
||||
href: "/apps/installed",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-12 block lg:hidden">
|
||||
{status === "authenticated" && <NavTabs tabs={tabs} linkProps={{ shallow: true }} />}
|
||||
</div>
|
||||
<main className="pb-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useBrandColors } from "@calcom/embed-core";
|
||||
|
||||
const brandColor = "#292929";
|
||||
const brandTextColor = "#ffffff";
|
||||
const darkBrandColor = "#fafafa";
|
||||
@@ -220,6 +222,8 @@ const BrandColor = ({
|
||||
lightVal: string | undefined | null;
|
||||
darkVal: string | undefined | null;
|
||||
}) => {
|
||||
const embedBrandingColors = useBrandColors();
|
||||
lightVal = embedBrandingColors.brandColor || lightVal;
|
||||
// convert to 6 digit equivalent if 3 digit code is entered
|
||||
lightVal = normalizeHexCode(lightVal, false);
|
||||
darkVal = normalizeHexCode(darkVal, true);
|
||||
@@ -235,6 +239,34 @@ const BrandColor = ({
|
||||
: "#" + darkVal
|
||||
: fallBackHex(darkVal, true);
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-highlight-color",
|
||||
embedBrandingColors.highlightColor || "#10B981" // green--500
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-lightest-color",
|
||||
embedBrandingColors.lightestColor || "#E1E1E1" // gray--200
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-lighter-color",
|
||||
embedBrandingColors.lighterColor || "#ACACAC" // gray--400
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-light-color",
|
||||
embedBrandingColors.lightColor || "#888888" // gray--500
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-median-color",
|
||||
embedBrandingColors.medianColor || "#494949" // gray--600
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-dark-color",
|
||||
embedBrandingColors.darkColor || "#313131" // gray--800
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-darker-color",
|
||||
embedBrandingColors.darkerColor || "#292929" // gray--900
|
||||
);
|
||||
document.documentElement.style.setProperty("--brand-color", lightVal);
|
||||
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(lightVal, true));
|
||||
document.documentElement.style.setProperty("--brand-color-dark-mode", darkVal);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
interface Props {
|
||||
onChange: (value: { externalId: string; integration: string }) => void;
|
||||
isLoading?: boolean;
|
||||
@@ -25,20 +25,18 @@ const DestinationCalendarSelector = ({
|
||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOption) {
|
||||
const selected = query.data?.connectedCalendars
|
||||
.map((connected) => connected.calendars ?? [])
|
||||
.flat()
|
||||
.find((cal) => cal.externalId === value);
|
||||
const selected = query.data?.connectedCalendars
|
||||
.map((connected) => connected.calendars ?? [])
|
||||
.flat()
|
||||
.find((cal) => cal.externalId === value);
|
||||
|
||||
if (selected) {
|
||||
setSelectedOption({
|
||||
value: `${selected.integration}:${selected.externalId}`,
|
||||
label: selected.name || "",
|
||||
});
|
||||
}
|
||||
if (selected) {
|
||||
setSelectedOption({
|
||||
value: `${selected.integration}:${selected.externalId}`,
|
||||
label: selected.name || "",
|
||||
});
|
||||
}
|
||||
}, [query.data?.connectedCalendars, selectedOption, value]);
|
||||
}, [query.data?.connectedCalendars, value]);
|
||||
|
||||
if (!query.data?.connectedCalendars.length) {
|
||||
return null;
|
||||
@@ -53,11 +51,14 @@ const DestinationCalendarSelector = ({
|
||||
})),
|
||||
})) ?? [];
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative" title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||
{/* There's no easy way to customize the displayed value for a Select, so we fake it. */}
|
||||
{!hidePlaceholder && (
|
||||
<div className="pointer-events-none absolute z-10">
|
||||
<Button size="sm" color="secondary" className="m-[1px] rounded-sm border-transparent">
|
||||
<div className="pointer-events-none absolute z-10 w-full">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="m-[1px] w-[calc(100%_-_40px)] overflow-hidden overflow-ellipsis whitespace-nowrap rounded-sm border-none leading-5">
|
||||
{t("select_destination_calendar")}: {selectedOption?.label || ""}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -67,7 +68,7 @@ const DestinationCalendarSelector = ({
|
||||
placeholder={!hidePlaceholder ? `${t("select_destination_calendar")}:` : undefined}
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
className="focus:border-primary-500 focus:ring-primary-500 mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-md border-gray-300 sm:text-sm"
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-md border-gray-300 sm:text-sm"
|
||||
onChange={(option) => {
|
||||
setSelectedOption(option);
|
||||
if (!option) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { DialogClose, DialogTrigger, Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { DialogClose, DialogTrigger, Dialog, DialogContent } from "@components/Dialog";
|
||||
import Slider from "@components/Slider";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
type ImageUploaderProps = {
|
||||
id: string;
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
import { AdminRequired } from "components/ui/AdminRequired";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ElementType, FC } from "react";
|
||||
import React, { ElementType, FC, Fragment } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
interface Props {
|
||||
export interface NavTabProps {
|
||||
tabs: {
|
||||
name: string;
|
||||
href: string;
|
||||
icon?: ElementType;
|
||||
adminRequired?: boolean;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
}
|
||||
|
||||
const NavTabs: FC<Props> = ({ tabs, linkProps }) => {
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="-mb-px flex space-x-2 space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse"
|
||||
aria-label="Tabs">
|
||||
<nav className="-mb-px flex space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const isCurrent = router.asPath === tab.href;
|
||||
const Component = tab.adminRequired ? AdminRequired : Fragment;
|
||||
return (
|
||||
<Link key={tab.name} href={tab.href} {...linkProps}>
|
||||
<a
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Component key={tab.name}>
|
||||
<Link href={tab.href} {...linkProps}>
|
||||
<a
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 hidden h-5 w-5 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Component>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import { CreditCardIcon, KeyIcon, LockClosedIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
import NavTabs, { NavTabProps } from "./NavTabs";
|
||||
|
||||
export default function SettingsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
@@ -29,6 +29,12 @@ export default function SettingsShell({ children }: { children: React.ReactNode
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
name: t("admin"),
|
||||
href: "/settings/admin",
|
||||
icon: LockClosedIcon,
|
||||
adminRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
PuzzleIcon,
|
||||
MoonIcon,
|
||||
MapIcon,
|
||||
MoonIcon,
|
||||
ViewGridIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { SessionContextValue, signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import React, { Fragment, ReactNode, useEffect } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import LicenseBanner from "@ee/components/LicenseBanner";
|
||||
import TrialBanner from "@ee/components/TrialBanner";
|
||||
import IntercomMenuItem from "@ee/lib/intercom/IntercomMenuItem";
|
||||
import ZendeskMenuItem from "@ee/lib/zendesk/ZendeskMenuItem";
|
||||
import HelpMenuItem from "@ee/components/support/HelpMenuItem";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants";
|
||||
import { WEBAPP_URL } from "@lib/config/constants";
|
||||
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
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 Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/ui/Dropdown";
|
||||
import ImpersonatingBanner from "@components/ui/ImpersonatingBanner";
|
||||
|
||||
import pkg from "../package.json";
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
import Logo from "./Logo";
|
||||
import Button from "./ui/Button";
|
||||
|
||||
export function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
@@ -54,25 +56,30 @@ export function useMeQuery() {
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
function useRedirectToLoginIfUnauthenticated() {
|
||||
function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
||||
const { data: session, status } = useSession();
|
||||
const loading = status === "loading";
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPublic) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loading && !session) {
|
||||
router.replace({
|
||||
pathname: "/auth/login",
|
||||
query: {
|
||||
callbackUrl: `${NEXT_PUBLIC_BASE_URL}/${location.pathname}${location.search}`,
|
||||
callbackUrl: `${WEBAPP_URL}${location.pathname}${location.search}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, session]);
|
||||
}, [loading, session, isPublic]);
|
||||
|
||||
return {
|
||||
loading: loading && !session,
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,11 +88,7 @@ function useRedirectToOnboardingIfNeeded() {
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
const [isRedirectingToOnboarding, setRedirecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
user && setRedirecting(shouldShowOnboarding(user));
|
||||
}, [router, user]);
|
||||
const isRedirectingToOnboarding = user && shouldShowOnboarding(user);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRedirectingToOnboarding) {
|
||||
@@ -119,25 +122,15 @@ export function ShellSubHeading(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Shell(props: {
|
||||
centered?: boolean;
|
||||
title?: string;
|
||||
heading: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
CTA?: ReactNode;
|
||||
HeadingLeftIcon?: ReactNode;
|
||||
backPath?: string; // renders back button to specified path
|
||||
// use when content needs to expand with flex
|
||||
flexChildrenContainer?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const Layout = ({
|
||||
status,
|
||||
plan,
|
||||
...props
|
||||
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => {
|
||||
const isEmbed = useIsEmbed();
|
||||
const router = useRouter();
|
||||
const { loading } = useRedirectToLoginIfUnauthenticated();
|
||||
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const { t } = useLocale();
|
||||
const navigation = [
|
||||
{
|
||||
name: t("event_types_page_title"),
|
||||
@@ -158,10 +151,22 @@ export default function Shell(props: {
|
||||
current: router.asPath.startsWith("/availability"),
|
||||
},
|
||||
{
|
||||
name: t("integrations"),
|
||||
href: "/integrations",
|
||||
icon: PuzzleIcon,
|
||||
current: router.asPath.startsWith("/integrations"),
|
||||
name: t("apps"),
|
||||
href: "/apps",
|
||||
icon: ViewGridIcon,
|
||||
current: router.asPath.startsWith("/apps"),
|
||||
child: [
|
||||
{
|
||||
name: t("app_store"),
|
||||
href: "/apps",
|
||||
current: router.asPath === "/apps",
|
||||
},
|
||||
{
|
||||
name: t("installed_apps"),
|
||||
href: "/apps/installed",
|
||||
current: router.asPath === "/apps/installed",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("settings"),
|
||||
@@ -170,31 +175,10 @@ export default function Shell(props: {
|
||||
current: router.asPath.startsWith("/settings"),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => {
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
|
||||
});
|
||||
}, [telemetry, router.asPath]);
|
||||
|
||||
const pageTitle = typeof props.heading === "string" ? props.heading : props.title;
|
||||
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
const i18n = useViewerI18n();
|
||||
|
||||
if (i18n.status === "loading" || isRedirectingToOnboarding || loading) {
|
||||
// show spinner whilst i18n is loading to avoid language flicker
|
||||
return (
|
||||
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||
<HeadSeo
|
||||
title={pageTitle ?? "Cal.com"}
|
||||
description={props.subtitle ? props.subtitle?.toString() : ""}
|
||||
@@ -207,96 +191,129 @@ export default function Shell(props: {
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-screen overflow-hidden bg-gray-100" data-testid="dashboard-shell">
|
||||
<div className="hidden md:flex lg:flex-shrink-0">
|
||||
<div className="flex w-14 flex-col lg:w-56">
|
||||
<div className="flex h-0 flex-1 flex-col border-r border-gray-200 bg-white">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pt-3 pb-4 lg:pt-5">
|
||||
<Link href="/event-types">
|
||||
<a className="px-4 md:hidden lg:inline">
|
||||
<Logo small />
|
||||
</a>
|
||||
</Link>
|
||||
{/* logo icon for tablet */}
|
||||
<Link href="/event-types">
|
||||
<a className="md:inline lg:hidden">
|
||||
<Logo small icon />
|
||||
</a>
|
||||
</Link>
|
||||
<nav className="mt-2 flex-1 space-y-1 bg-white px-2 lg:mt-5">
|
||||
{navigation.map((item) => (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
item.current
|
||||
? "bg-neutral-100 text-neutral-900"
|
||||
: "text-neutral-500 hover:bg-gray-50 hover:text-neutral-900",
|
||||
"group flex items-center rounded-sm px-2 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current
|
||||
? "text-neutral-500"
|
||||
: "text-neutral-400 group-hover:text-neutral-500",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div
|
||||
className={classNames("flex h-screen overflow-hidden", props.large ? "bg-white" : "bg-gray-100")}
|
||||
data-testid="dashboard-shell">
|
||||
{status === "authenticated" && (
|
||||
<div style={isEmbed ? { display: "none" } : {}} className="hidden md:flex lg:flex-shrink-0">
|
||||
<div className="flex w-14 flex-col lg:w-56">
|
||||
<div className="flex h-0 flex-1 flex-col border-r border-gray-200 bg-white">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pt-3 pb-4 lg:pt-5">
|
||||
<Link href="/event-types">
|
||||
<a className="px-4 md:hidden lg:inline">
|
||||
<Logo small />
|
||||
</a>
|
||||
</Link>
|
||||
{/* logo icon for tablet */}
|
||||
<Link href="/event-types">
|
||||
<a className="md:inline lg:hidden">
|
||||
<Logo small icon />
|
||||
</a>
|
||||
</Link>
|
||||
<nav className="mt-2 flex-1 space-y-1 bg-white px-2 lg:mt-5">
|
||||
{navigation.map((item) => (
|
||||
<Fragment key={item.name}>
|
||||
<Link href={item.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
item.current
|
||||
? "bg-neutral-100 text-neutral-900"
|
||||
: "text-neutral-500 hover:bg-gray-50 hover:text-neutral-900",
|
||||
"group flex items-center rounded-sm px-2 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current
|
||||
? "text-neutral-500"
|
||||
: "text-neutral-400 group-hover:text-neutral-500",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
{item.child &&
|
||||
router.asPath.startsWith(item.href) &&
|
||||
item.child.map((item) => {
|
||||
return (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
item.current
|
||||
? "text-neutral-900"
|
||||
: "text-neutral-500 hover:text-neutral-900",
|
||||
"group hidden items-center rounded-sm px-2 py-2 pl-10 text-sm font-medium lg:flex"
|
||||
)}>
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<TrialBanner />
|
||||
<div
|
||||
className="rounded-sm pb-2 pl-3 pt-2 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2"
|
||||
data-testid="user-dropdown-trigger">
|
||||
<span className="hidden lg:inline">
|
||||
<UserDropdown />
|
||||
</span>
|
||||
<span className="hidden md:inline lg:hidden">
|
||||
<UserDropdown small />
|
||||
</span>
|
||||
</div>
|
||||
<small style={{ fontSize: "0.5rem" }} className="mx-3 mt-1 mb-2 hidden opacity-50 lg:block">
|
||||
© {new Date().getFullYear()} Cal.com, Inc. v.{pkg.version + "-"}
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? "h" : "sh"}
|
||||
<span className="lowercase" data-testid={`plan-${plan?.toLowerCase()}`}>
|
||||
-{plan}
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
<TrialBanner />
|
||||
<div className="rounded-sm pt-2 pb-2 pl-3 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2">
|
||||
<span className="hidden lg:inline">
|
||||
<UserDropdown />
|
||||
</span>
|
||||
<span className="hidden md:inline lg:hidden">
|
||||
<UserDropdown small />
|
||||
</span>
|
||||
</div>
|
||||
<small style={{ fontSize: "0.5rem" }} className="mx-3 mt-1 mb-2 hidden opacity-50 lg:block">
|
||||
© {new Date().getFullYear()} Cal.com, Inc. v.{pkg.version + "-"}
|
||||
{process.env.NEXT_PUBLIC_APP_URL === "https://cal.com" ? "h" : "sh"}
|
||||
<span className="lowercase">-{user && user.plan}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-0 flex-1 flex-col overflow-hidden">
|
||||
<main
|
||||
className={classNames(
|
||||
"relative z-0 max-w-[1700px] flex-1 overflow-y-auto focus:outline-none",
|
||||
"relative z-0 flex-1 overflow-y-auto focus:outline-none",
|
||||
status === "authenticated" && "max-w-[1700px]",
|
||||
props.flexChildrenContainer && "flex flex-col"
|
||||
)}>
|
||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="flex items-center justify-between border-b border-gray-200 bg-white p-4 md:hidden">
|
||||
<Link href="/event-types">
|
||||
<a>
|
||||
<Logo />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 self-center">
|
||||
<button className="rounded-full bg-white p-2 text-gray-400 hover:bg-gray-50 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
|
||||
<span className="sr-only">{t("view_notifications")}</span>
|
||||
<Link href="/settings/profile">
|
||||
<a>
|
||||
<CogIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</a>
|
||||
</Link>
|
||||
</button>
|
||||
<UserDropdown small />
|
||||
</div>
|
||||
</nav>
|
||||
{status === "authenticated" && (
|
||||
<nav
|
||||
style={isEmbed ? { display: "none" } : {}}
|
||||
className="flex items-center justify-between border-b border-gray-200 bg-white p-4 md:hidden">
|
||||
<Link href="/event-types">
|
||||
<a>
|
||||
<Logo />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 self-center">
|
||||
<button className="rounded-full bg-white p-2 text-gray-400 hover:bg-gray-50 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
|
||||
<span className="sr-only">{t("view_notifications")}</span>
|
||||
<Link href="/settings/profile">
|
||||
<a>
|
||||
<CogIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</a>
|
||||
</Link>
|
||||
</button>
|
||||
<UserDropdown small />
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
props.centered && "mx-auto md:max-w-5xl",
|
||||
props.flexChildrenContainer && "flex flex-1 flex-col",
|
||||
"py-8"
|
||||
!props.large && "py-8"
|
||||
)}>
|
||||
<ImpersonatingBanner />
|
||||
{!!props.backPath && (
|
||||
<div className="mx-3 mb-8 sm:mx-8">
|
||||
<Button
|
||||
@@ -307,50 +324,73 @@ export default function Shell(props: {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="block min-h-[80px] justify-between px-4 sm:flex sm:px-6 md:px-8">
|
||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="mb-8 w-full">
|
||||
<h1 className="font-cal mb-1 text-xl text-gray-900">{props.heading}</h1>
|
||||
<p className="text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
|
||||
{props.heading && (
|
||||
<div
|
||||
className={classNames(
|
||||
props.large && "bg-gray-100 py-8 lg:mb-8 lg:pt-16 lg:pb-7",
|
||||
"block min-h-[80px] justify-between px-4 sm:flex sm:px-6 md:px-8"
|
||||
)}>
|
||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="mb-8 w-full">
|
||||
{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>
|
||||
<div className="mb-4 flex-shrink-0">{props.CTA}</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"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) */}
|
||||
<nav className="bottom-nav fixed bottom-0 z-30 flex w-full bg-white shadow md:hidden">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
{navigation.flatMap((item, itemIdx) =>
|
||||
item.href === "/settings/profile" ? (
|
||||
[]
|
||||
) : (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
item.current ? "text-gray-900" : "text-neutral-400 hover:text-gray-700",
|
||||
itemIdx === 0 ? "rounded-l-lg" : "",
|
||||
itemIdx === navigation.length - 1 ? "rounded-r-lg" : "",
|
||||
"group relative min-w-0 flex-1 overflow-hidden bg-white py-2 px-2 text-center text-xs font-medium hover:bg-gray-50 focus:z-10 sm:text-sm"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon
|
||||
{status === "authenticated" && (
|
||||
<nav
|
||||
style={isEmbed ? { display: "none" } : {}}
|
||||
className="bottom-nav fixed bottom-0 z-30 flex w-full bg-white shadow md:hidden">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
{navigation.flatMap((item, itemIdx) =>
|
||||
item.href === "/settings/profile" ? (
|
||||
[]
|
||||
) : (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
item.current ? "text-gray-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"mx-auto mb-1 block h-5 w-5 flex-shrink-0 text-center"
|
||||
item.current ? "text-gray-900" : "text-neutral-400 hover:text-gray-700",
|
||||
itemIdx === 0 ? "rounded-l-lg" : "",
|
||||
itemIdx === navigation.length - 1 ? "rounded-r-lg" : "",
|
||||
"group relative min-w-0 flex-1 overflow-hidden bg-white py-2 px-2 text-center text-xs font-medium hover:bg-gray-50 focus:z-10 sm:text-sm"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current ? "text-gray-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"mx-auto mb-1 block h-5 w-5 flex-shrink-0 text-center"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
{/* add padding to content for mobile navigation*/}
|
||||
<div className="block pt-12 md:hidden" />
|
||||
</div>
|
||||
@@ -360,6 +400,63 @@ export default function Shell(props: {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedLayout = React.memo(Layout);
|
||||
|
||||
type LayoutProps = {
|
||||
centered?: boolean;
|
||||
title?: string;
|
||||
heading?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
CTA?: ReactNode;
|
||||
large?: boolean;
|
||||
HeadingLeftIcon?: ReactNode;
|
||||
backPath?: string; // renders back button to specified path
|
||||
// use when content needs to expand with flex
|
||||
flexChildrenContainer?: boolean;
|
||||
isPublic?: boolean;
|
||||
customLoader?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Shell(props: LayoutProps) {
|
||||
const router = useRouter();
|
||||
const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic);
|
||||
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => {
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
|
||||
});
|
||||
}, [telemetry, router.asPath]);
|
||||
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
const i18n = useViewerI18n();
|
||||
const { status } = useSession();
|
||||
|
||||
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 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session && !props.isPublic) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||
<MemoizedLayout plan={user?.plan} status={status} {...props} isLoading={isLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDropdown({ small }: { small?: boolean }) {
|
||||
@@ -376,7 +473,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="group flex w-full cursor-pointer appearance-none items-center">
|
||||
<button className="group flex w-full cursor-pointer appearance-none items-center text-left">
|
||||
<span
|
||||
className={classNames(
|
||||
small ? "h-8 w-8" : "h-10 w-10",
|
||||
@@ -384,12 +481,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
)}>
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={
|
||||
(process.env.NEXT_PUBLIC_APP_URL || process.env.NEXT_PUBLIC_BASE_URL) +
|
||||
"/" +
|
||||
user?.username +
|
||||
"/avatar.png"
|
||||
}
|
||||
src={process.env.NEXT_PUBLIC_WEBSITE_URL + "/" + user?.username + "/avatar.png"}
|
||||
alt={user?.username || "Nameless User"}
|
||||
/>
|
||||
{!user?.away && (
|
||||
@@ -415,7 +507,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portalled={true}>
|
||||
<DropdownMenuItem>
|
||||
@@ -424,7 +516,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
mutation.mutate({ away: !user?.away });
|
||||
utils.invalidateQueries("viewer.me");
|
||||
}}
|
||||
className="flex cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
||||
className="flex min-w-max cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
||||
<MoonIcon
|
||||
className={classNames(
|
||||
user?.away
|
||||
@@ -443,7 +535,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/${user.username}`}
|
||||
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||
<ExternalLinkIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("view_public_page")}
|
||||
</a>
|
||||
@@ -490,8 +582,9 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
<MapIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("visit_roadmap")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<IntercomMenuItem />
|
||||
<ZendeskMenuItem />
|
||||
|
||||
<HelpMenuItem />
|
||||
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
|
||||
@@ -17,13 +17,13 @@ export function Tooltip({
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive.Root
|
||||
delayDuration={150}
|
||||
delayDuration={50}
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content
|
||||
className="-mt-2 rounded-sm bg-black px-1 py-0.5 text-xs text-white shadow-lg"
|
||||
className="slideInBottom -mt-2 rounded-sm bg-black px-1 py-0.5 text-xs text-white shadow-lg"
|
||||
side="top"
|
||||
align="center"
|
||||
{...props}>
|
||||
|
||||
54
apps/web/components/UpgradeToProDialog.tsx
Normal file
54
apps/web/components/UpgradeToProDialog.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { InformationCircleIcon } from "@heroicons/react/outline";
|
||||
import { Trans } from "next-i18next";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
export function UpgradeToProDialog({
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
children,
|
||||
}: {
|
||||
modalOpen: boolean;
|
||||
setModalOpen: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Dialog open={modalOpen}>
|
||||
<DialogContent>
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
|
||||
<InformationCircleIcon className="h-6 w-6 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="mt-3 sm:mt-0 sm:text-left">
|
||||
<h3 className="font-cal text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("only_available_on_pro_plan")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<p>{children}</p>
|
||||
<p>
|
||||
<Trans i18nKey="plan_upgrade_instructions">
|
||||
You can
|
||||
<a href="/api/upgrade" className="underline">
|
||||
upgrade here
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogClose asChild>
|
||||
<Button className="btn-wide table-cell text-center" onClick={() => setModalOpen(false)}>
|
||||
{t("dismiss")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
27
apps/web/components/apps/AllApps.tsx
Normal file
27
apps/web/components/apps/AllApps.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { App } from "@calcom/types/App";
|
||||
|
||||
import AppCard from "./AppCard";
|
||||
|
||||
export default function AllApps({ apps }: { apps: App[] }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="mb-16">
|
||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("all_apps")}</h2>
|
||||
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{apps.map((app) => (
|
||||
<AppCard
|
||||
key={app.name}
|
||||
name={app.name}
|
||||
slug={app.slug}
|
||||
description={app.description}
|
||||
logo={app.logo}
|
||||
rating={app.rating}
|
||||
reviews={app.reviews}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
apps/web/components/apps/AppCard.tsx
Normal file
41
apps/web/components/apps/AppCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { StarIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
interface AppCardProps {
|
||||
logo: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
category?: string;
|
||||
description: string;
|
||||
rating: number;
|
||||
reviews?: number;
|
||||
}
|
||||
|
||||
export default function AppCard(props: AppCardProps) {
|
||||
return (
|
||||
<Link href={"/apps/" + props.slug}>
|
||||
<a className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50">
|
||||
<div className="flex">
|
||||
<img src={props.logo} alt={props.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
|
||||
<Button
|
||||
data-testid={`app-store-app-card-${props.slug}`}
|
||||
color="secondary"
|
||||
className="ml-auto flex self-start"
|
||||
onClick={() => {
|
||||
console.log("The magic is supposed to happen here");
|
||||
}}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<h3 className="font-medium">{props.name}</h3>
|
||||
{/* TODO: add reviews <div className="flex text-sm text-gray-800">
|
||||
<span>{props.rating} stars</span> <StarIcon className="ml-1 mt-0.5 h-4 w-4 text-yellow-600" />
|
||||
<span className="pl-1 text-gray-500">{props.reviews} reviews</span>
|
||||
</div> */}
|
||||
<p className="mt-2 truncate text-sm text-gray-500">{props.description}</p>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
43
apps/web/components/apps/Categories.tsx
Normal file
43
apps/web/components/apps/Categories.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function AppStoreCategories({
|
||||
categories,
|
||||
}: {
|
||||
categories: {
|
||||
name: string;
|
||||
count: number;
|
||||
}[];
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
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 w-full gap-3 overflow-scroll sm:grid-flow-col">
|
||||
{categories.map((category) => (
|
||||
<Link key={category.name} href={"/apps/categories/" + category.name}>
|
||||
<a
|
||||
data-testid={`app-store-category-${category.name}`}
|
||||
className="relative flex rounded-sm bg-gray-100 px-6 py-4 sm:block">
|
||||
<div className="min-w-24 -ml-5 text-center sm:ml-0">
|
||||
<Image
|
||||
alt={category.name}
|
||||
width="352"
|
||||
height="252"
|
||||
layout="responsive"
|
||||
src={"/app-store/" + category.name + ".svg"}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<h3 className="font-medium capitalize">{category.name}</h3>
|
||||
<p className="text-sm text-gray-500">{category.count} apps</p>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
78
apps/web/components/apps/Slider.tsx
Normal file
78
apps/web/components/apps/Slider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Glide, { Options } from "@glidejs/glide";
|
||||
import "@glidejs/glide/dist/css/glide.core.min.css";
|
||||
import "@glidejs/glide/dist/css/glide.theme.min.css";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const Slider = <T extends unknown>({
|
||||
title = "",
|
||||
className = "",
|
||||
items,
|
||||
itemKey = (item) => `${item}`,
|
||||
renderItem,
|
||||
options = {},
|
||||
}: {
|
||||
title?: string;
|
||||
className?: string;
|
||||
items: T[];
|
||||
itemKey?: (item: T) => string;
|
||||
renderItem?: (item: T) => JSX.Element;
|
||||
options?: Options;
|
||||
}) => {
|
||||
const glide = useRef(null);
|
||||
const slider = useRef<Glide.Properties | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (glide.current) {
|
||||
slider.current = new Glide(glide.current, {
|
||||
type: "carousel",
|
||||
...options,
|
||||
}).mount();
|
||||
}
|
||||
|
||||
return () => slider.current?.destroy();
|
||||
}, [options]);
|
||||
|
||||
return (
|
||||
<div className={`mb-2 ${className}`}>
|
||||
<style jsx global>
|
||||
{`
|
||||
.glide__slide {
|
||||
height: auto !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className="glide" ref={glide}>
|
||||
<div className="flex cursor-default">
|
||||
{title && (
|
||||
<div>
|
||||
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="glide__arrows ml-auto" data-glide-el="controls">
|
||||
<button data-glide-dir="<" className="mr-4">
|
||||
<ArrowLeftIcon className="h-5 w-5 text-gray-600 hover:text-black" />
|
||||
</button>
|
||||
<button data-glide-dir=">">
|
||||
<ArrowRightIcon className="h-5 w-5 text-gray-600 hover:text-black" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glide__track" data-glide-el="track">
|
||||
<ul className="glide__slides">
|
||||
{items.map((item) => {
|
||||
if (typeof renderItem !== "function") return null;
|
||||
return (
|
||||
<li key={itemKey(item)} className="glide__slide h-auto pl-0">
|
||||
{renderItem(item)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Slider;
|
||||
39
apps/web/components/apps/TrendingAppsSlider.tsx
Normal file
39
apps/web/components/apps/TrendingAppsSlider.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { App } from "@calcom/types/App";
|
||||
|
||||
import AppCard from "./AppCard";
|
||||
import Slider from "./Slider";
|
||||
|
||||
const TrendingAppsSlider = <T extends App>({ items }: { items: T[] }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Slider<T>
|
||||
className="mb-16"
|
||||
title={t("trending_apps")}
|
||||
items={items.filter((app) => !!app.trending)}
|
||||
itemKey={(app) => app.name}
|
||||
options={{
|
||||
perView: 3,
|
||||
breakpoints: {
|
||||
768 /* and below */: {
|
||||
perView: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
renderItem={(app) => (
|
||||
<AppCard
|
||||
key={app.name}
|
||||
name={app.name}
|
||||
slug={app.slug}
|
||||
description={app.description}
|
||||
logo={app.logo}
|
||||
rating={app.rating}
|
||||
reviews={app.reviews}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendingAppsSlider;
|
||||
@@ -2,12 +2,12 @@ import { signIn } from "next-auth/react";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
samlTenantID: string;
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useEffect, useState } from "react";
|
||||
import useDigitInput from "react-digit-input";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Input } from "@calcom/ui/form/fields";
|
||||
|
||||
import { Input } from "@components/form/fields";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
export default function TwoFactor() {
|
||||
const [value, onChange] = useState("");
|
||||
@@ -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]} />
|
||||
|
||||
89
apps/web/components/availability/NewScheduleButton.tsx
Normal file
89
apps/web/components/availability/NewScheduleButton.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { PlusIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
export function NewScheduleButton({ name = "new-schedule" }: { name?: string }) {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useForm<{
|
||||
name: string;
|
||||
}>();
|
||||
const { register } = form;
|
||||
|
||||
const createMutation = trpc.useMutation("viewer.availability.schedule.create", {
|
||||
onSuccess: async ({ schedule }) => {
|
||||
await router.push("/availability/" + schedule.id);
|
||||
showToast(t("schedule_created_successfully", { scheduleName: schedule.name }), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
const message = `${err.data.code}: You are not able to create this event`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog name={name} clearQueryParamsOnClose={["copy-schedule-id"]}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid={name} StartIcon={PlusIcon}>
|
||||
{t("new_schedule_btn")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("add_new_schedule")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
createMutation.mutate(values);
|
||||
}}>
|
||||
<div className="mt-3 space-y-4">
|
||||
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
placeholder={t("default_schedule_name")}
|
||||
{...register("name")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<Button type="submit" loading={createMutation.isLoading}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
338
apps/web/components/availability/Schedule.tsx
Normal file
338
apps/web/components/availability/Schedule.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { DuplicateIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import dayjs, { Dayjs, ConfigType } from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { GroupBase, Props, SingleValue } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/Dropdown";
|
||||
|
||||
import { defaultDayRange } from "@lib/availability";
|
||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import { TimeRange } from "@lib/types/schedule";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/** Begin Time Increments For Select */
|
||||
const increment = 15;
|
||||
|
||||
type Option = {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an array of times on a 15 minute interval from
|
||||
* 00:00:00 (Start of day) to
|
||||
* 23:45:00 (End of day with enough time for 15 min booking)
|
||||
*/
|
||||
const useOptions = () => {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const { timeFormat } = query.data || { timeFormat: null };
|
||||
|
||||
const [filteredOptions, setFilteredOptions] = useState<Option[]>([]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const end = dayjs().utc().endOf("day");
|
||||
let t: Dayjs = dayjs().utc().startOf("day");
|
||||
|
||||
const options: Option[] = [];
|
||||
while (t.isBefore(end)) {
|
||||
options.push({
|
||||
value: t.toDate().valueOf(),
|
||||
label: dayjs(t)
|
||||
.utc()
|
||||
.format(timeFormat === 12 ? "h:mma" : "HH:mm"),
|
||||
});
|
||||
t = t.add(increment, "minutes");
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const filter = useCallback(
|
||||
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
||||
if (current) {
|
||||
setFilteredOptions([options.find((option) => option.value === dayjs(current).toDate().valueOf())!]);
|
||||
} else
|
||||
setFilteredOptions(
|
||||
options.filter((option) => {
|
||||
const time = dayjs(option.value);
|
||||
return (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset));
|
||||
})
|
||||
);
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
return { options: filteredOptions, filter };
|
||||
};
|
||||
|
||||
type TimeRangeFieldProps = {
|
||||
name: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const LazySelect = ({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
...props
|
||||
}: Omit<Props<Option, false, GroupBase<Option>>, "value"> & {
|
||||
value: ConfigType;
|
||||
min?: ConfigType;
|
||||
max?: ConfigType;
|
||||
}) => {
|
||||
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
|
||||
const { options, filter } = useOptions();
|
||||
|
||||
useEffect(() => {
|
||||
filter({ current: value });
|
||||
}, [filter, value]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
onMenuOpen={() => {
|
||||
if (min) filter({ offset: min });
|
||||
if (max) filter({ limit: max });
|
||||
}}
|
||||
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
|
||||
onMenuClose={() => filter({ current: value })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeRangeField = ({ name, className }: TimeRangeFieldProps) => {
|
||||
const { watch } = useFormContext();
|
||||
const minEnd = watch(`${name}.start`);
|
||||
const maxStart = watch(`${name}.end`);
|
||||
return (
|
||||
<div className={classNames("flex flex-grow items-center space-x-3", className)}>
|
||||
<Controller
|
||||
name={`${name}.start`}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<LazySelect
|
||||
className="w-[120px]"
|
||||
value={value}
|
||||
max={maxStart}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span>-</span>
|
||||
<Controller
|
||||
name={`${name}.end`}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<LazySelect
|
||||
className="flex-grow sm:w-[120px]"
|
||||
value={value}
|
||||
min={minEnd}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ScheduleBlockProps = {
|
||||
day: number;
|
||||
weekday: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (selected: number[]) => void }) => {
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const { i18n, t } = useLocale();
|
||||
return (
|
||||
<div className="m-4 space-y-2 py-4">
|
||||
<p className="h6 text-xs font-medium uppercase text-neutral-400">Copy times to</p>
|
||||
<ol className="space-y-2">
|
||||
{weekdayNames(i18n.language).map((weekday, num) => (
|
||||
<li key={weekday}>
|
||||
<label className="flex w-full items-center justify-between">
|
||||
<span>{weekday}</span>
|
||||
<input
|
||||
value={num}
|
||||
defaultChecked={disabled.includes(num)}
|
||||
disabled={disabled.includes(num)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked && !selected.includes(num)) {
|
||||
setSelected(selected.concat([num]));
|
||||
} else if (!e.target.checked && selected.includes(num)) {
|
||||
setSelected(selected.slice(selected.indexOf(num), 1));
|
||||
}
|
||||
}}
|
||||
type="checkbox"
|
||||
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500 disabled:text-neutral-400"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="pt-2">
|
||||
<Button className="w-full justify-center" color="primary" onClick={() => onApply(selected)}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DayRanges = ({
|
||||
name,
|
||||
defaultValue = [defaultDayRange],
|
||||
}: {
|
||||
name: string;
|
||||
defaultValue?: TimeRange[];
|
||||
}) => {
|
||||
const { setValue, watch } = useFormContext();
|
||||
// XXX: Hack to make copying times work; `fields` is out of date until save.
|
||||
const watcher = watch(name);
|
||||
|
||||
const { fields, replace, append, remove } = useFieldArray({
|
||||
name,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue.length && !fields.length) {
|
||||
replace(defaultValue);
|
||||
}
|
||||
}, [replace, defaultValue, fields.length]);
|
||||
|
||||
const handleAppend = () => {
|
||||
// FIXME: Fix type-inference, can't get this to work. @see https://github.com/react-hook-form/react-hook-form/issues/4499
|
||||
const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
|
||||
const nextRangeEnd = dayjs(nextRangeStart).add(1, "hour");
|
||||
|
||||
if (nextRangeEnd.isBefore(nextRangeStart.endOf("day"))) {
|
||||
return append({
|
||||
start: nextRangeStart.toDate(),
|
||||
end: nextRangeEnd.toDate(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-center rtl:space-x-reverse">
|
||||
<div className="flex flex-grow sm:flex-grow-0">
|
||||
<TimeRangeField name={`${name}.${index}`} />
|
||||
<Button
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={TrashIcon}
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
/>
|
||||
</div>
|
||||
{index === 0 && (
|
||||
<div className="absolute top-2 right-0 text-right sm:relative sm:top-0 sm:flex-grow">
|
||||
<Button
|
||||
className="text-neutral-400"
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={PlusIcon}
|
||||
onClick={handleAppend}
|
||||
/>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={DuplicateIcon}
|
||||
onClick={handleAppend}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<CopyTimes
|
||||
disabled={[parseInt(name.substring(name.lastIndexOf(".") + 1), 10)]}
|
||||
onApply={(selected) =>
|
||||
selected.forEach((day) => {
|
||||
// TODO: Figure out why this is different?
|
||||
// console.log(watcher, fields);
|
||||
setValue(name.substring(0, name.lastIndexOf(".") + 1) + day, watcher);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const form = useFormContext();
|
||||
const watchAvailable = form.watch(`${name}.${day}`, []);
|
||||
|
||||
return (
|
||||
<fieldset className="relative flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
|
||||
<label
|
||||
className={classNames(
|
||||
"flex space-x-2 rtl:space-x-reverse",
|
||||
!watchAvailable.length ? "w-full" : "w-1/3"
|
||||
)}>
|
||||
<div className={classNames(!watchAvailable.length ? "w-1/3" : "w-full")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={watchAvailable.length}
|
||||
onChange={(e) => {
|
||||
form.setValue(`${name}.${day}`, e.target.checked ? [defaultDayRange] : []);
|
||||
}}
|
||||
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
|
||||
/>
|
||||
<span className="ml-2 inline-block text-sm capitalize">{weekday}</span>
|
||||
</div>
|
||||
{!watchAvailable.length && (
|
||||
<div className="flex-grow text-right text-sm text-gray-500 sm:flex-shrink">
|
||||
{t("no_availability")}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
{!!watchAvailable.length && (
|
||||
<div className="flex-grow">
|
||||
<DayRanges name={`${name}.${day}`} defaultValue={[]} />
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
const Schedule = ({ name }: { name: string }) => {
|
||||
const { i18n } = useLocale();
|
||||
return (
|
||||
<fieldset className="divide-y divide-gray-200">
|
||||
{weekdayNames(i18n.language).map((weekday, num) => (
|
||||
<ScheduleBlock key={num} name={name} weekday={weekday} day={num} />
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default Schedule;
|
||||
33
apps/web/components/availability/SkeletonLoader.tsx
Normal file
33
apps/web/components/availability/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-2 py-[23px] sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText width="32" height="4"></SkeletonText>
|
||||
<SkeletonText width="32" height="2"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="12" height="6"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useSlots } from "@lib/hooks/useSlots";
|
||||
@@ -18,6 +20,7 @@ type AvailableTimesProps = {
|
||||
afterBufferTime: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
eventTypeSlug: string;
|
||||
slotInterval: number | null;
|
||||
date: Dayjs;
|
||||
users: {
|
||||
@@ -30,6 +33,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
date,
|
||||
eventLength,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
timeFormat,
|
||||
@@ -41,7 +45,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
|
||||
const { slots, loading, error } = useSlots({
|
||||
date,
|
||||
slotInterval,
|
||||
@@ -63,9 +66,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
return (
|
||||
<div className="mt-8 flex flex-col text-center sm:mt-0 sm:w-1/3 sm:pl-4 md:-mb-5">
|
||||
<div className="mb-4 text-left text-lg font-light text-gray-600">
|
||||
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||
<strong>{date.toDate().toLocaleString(i18n.language, { weekday: "long" })}</strong>
|
||||
<span className="text-gray-500">
|
||||
<span className="text-bookingdarker w-1/2 dark:text-white">
|
||||
<strong>{nameOfDay(i18n.language, Number(date.format("d")))}</strong>
|
||||
<span className="text-bookinglight">
|
||||
{date.format(", D ")}
|
||||
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
|
||||
</span>
|
||||
@@ -85,6 +88,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
...router.query,
|
||||
date: slot.time.format(),
|
||||
type: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,7 +105,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
<Link href={bookingUrl}>
|
||||
<a
|
||||
className={classNames(
|
||||
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
|
||||
"text-bookingdarker hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}
|
||||
data-testid="time">
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline";
|
||||
import { PaperAirplaneIcon } from "@heroicons/react/outline";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
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 { TextArea } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@components/Dialog";
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import { TextArea } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
@@ -79,15 +83,44 @@ function BookingListItem(booking: BookingItem) {
|
||||
{
|
||||
id: "reschedule",
|
||||
label: t("reschedule"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
icon: ClockIcon,
|
||||
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),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||
<PaperAirplaneIcon className="-mt-[1px] w-4 rotate-45" />
|
||||
<p className="ml-2 ">{t("reschedule_request_sent")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
isOpenDialog={isOpenRescheduleDialog}
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("rejection_reason_title")} />
|
||||
@@ -145,7 +178,10 @@ function BookingListItem(booking: BookingItem) {
|
||||
</div>
|
||||
<div
|
||||
title={booking.title}
|
||||
className="max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max">
|
||||
className={classNames(
|
||||
"max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
||||
{booking.title}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
@@ -160,11 +196,17 @@ function BookingListItem(booking: BookingItem) {
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.attendees.length !== 0 && (
|
||||
<div className="text-sm text-gray-900 hover:text-blue-500">
|
||||
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
|
||||
</div>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block text-left text-sm md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
|
||||
@@ -179,6 +221,11 @@ function BookingListItem(booking: BookingItem) {
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
|
||||
@@ -5,13 +5,15 @@ import dayjsBusinessTime from "dayjs-business-time";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { memoize } from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useEmbedStyles } from "@calcom/embed-core";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import { doWorkAsync } from "@lib/doWorkAsync";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import getSlots from "@lib/slots";
|
||||
import { WorkingHours } from "@lib/types/schedule";
|
||||
|
||||
@@ -85,7 +87,8 @@ function DatePicker({
|
||||
}: DatePickerProps): JSX.Element {
|
||||
const { i18n } = useLocale();
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs | null>(date);
|
||||
|
||||
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
|
||||
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
|
||||
const [month, setMonth] = useState<string>("");
|
||||
const [year, setYear] = useState<string>("");
|
||||
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
|
||||
@@ -123,6 +126,8 @@ function DatePicker({
|
||||
eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}: Omit<DatePickerProps, "weekStart" | "onDatePicked" | "date"> & {
|
||||
browsingDate: Dayjs;
|
||||
}
|
||||
) => {
|
||||
const date = browsingDate.startOf("day").date(day);
|
||||
@@ -185,7 +190,7 @@ function DatePicker({
|
||||
batch: 1,
|
||||
name: "DatePicker",
|
||||
length: daysInMonth,
|
||||
callback: (i: number, isLast) => {
|
||||
callback: (i: number) => {
|
||||
let day = i + 1;
|
||||
days[daysInitialOffset + i] = {
|
||||
disabled: isDisabledMemoized(day, {
|
||||
@@ -229,20 +234,20 @@ function DatePicker({
|
||||
className={
|
||||
"mt-8 sm:mt-0 sm:min-w-[455px] " +
|
||||
(date
|
||||
? "w-full sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-800 md:w-1/3 "
|
||||
? "w-full sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
|
||||
: "w-full sm:pl-4")
|
||||
}>
|
||||
<div className="mb-4 flex text-xl font-light text-gray-600">
|
||||
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||
<strong className="text-gray-900 dark:text-white">{month}</strong>{" "}
|
||||
<span className="text-gray-500">{year}</span>
|
||||
<div className="mb-4 flex text-xl font-light">
|
||||
<span className="w-1/2 dark:text-white">
|
||||
<strong className="text-bookingdarker dark:text-white">{month}</strong>{" "}
|
||||
<span className="text-bookinglight">{year}</span>
|
||||
</span>
|
||||
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
|
||||
<div className="w-1/2 text-right dark:text-gray-400">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={classNames(
|
||||
"group p-1 ltr:mr-2 rtl:ml-2",
|
||||
isFirstMonth && "text-gray-400 dark:text-gray-600"
|
||||
isFirstMonth && "text-bookinglighter dark:text-gray-600"
|
||||
)}
|
||||
disabled={isFirstMonth}
|
||||
data-testid="decrementMonth">
|
||||
@@ -253,9 +258,9 @@ function DatePicker({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
|
||||
<div className="border-bookinglightest grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
|
||||
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
|
||||
<div key={weekDay} className="my-4 text-xs uppercase tracking-widest text-gray-500">
|
||||
<div key={weekDay} className="text-bookinglight my-4 text-xs uppercase tracking-widest">
|
||||
{weekDay}
|
||||
</div>
|
||||
))}
|
||||
@@ -274,10 +279,15 @@ function DatePicker({
|
||||
<button
|
||||
onClick={() => onDatePicked(browsingDate.date(day.date))}
|
||||
disabled={day.disabled}
|
||||
style={
|
||||
day.disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }
|
||||
}
|
||||
className={classNames(
|
||||
"absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center",
|
||||
"hover:border-brand hover:border dark:hover:border-white",
|
||||
day.disabled ? "cursor-default font-light text-gray-400 hover:border-0" : "font-medium",
|
||||
day.disabled
|
||||
? "text-bookinglighter cursor-default font-light hover:border-0"
|
||||
: "font-medium",
|
||||
date && date.isSame(browsingDate.date(day.date), "day")
|
||||
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
|
||||
: !day.disabled
|
||||
|
||||
39
apps/web/components/booking/SkeletonLoader.tsx
Normal file
39
apps/web/components/booking/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
import BookingsShell from "@components/BookingsShell";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="mt-6 animate-pulse divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-2 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText width="32" height="5" />
|
||||
<SkeletonText width="16" height="4" />
|
||||
</div>
|
||||
<SkeletonText width="24" height="5" className="ml-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="16" height="6" />
|
||||
<SkeletonText width="32" height="6" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
// Get router variables
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
GlobeIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
@@ -16,12 +18,24 @@ import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
useIsBackgroundTransparent,
|
||||
sdkActionManager,
|
||||
useEmbedType,
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { BASE_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { parseDate } from "@lib/parseDate";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
|
||||
@@ -41,13 +55,18 @@ dayjs.extend(customParseFormat);
|
||||
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Props) => {
|
||||
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => {
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed();
|
||||
const { rescheduleUid } = router.query;
|
||||
const { isReady, Theme } = useTheme(profile.theme);
|
||||
const { t } = useLocale();
|
||||
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(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
const eventOwner = eventType.users[0];
|
||||
@@ -59,17 +78,27 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
const selectedDate = useMemo(() => {
|
||||
const dateString = asStringOrNull(router.query.date);
|
||||
if (dateString) {
|
||||
// todo some extra validation maybe.
|
||||
const utcOffsetAsDate = dayjs(dateString.substr(11, 14), "Hmm");
|
||||
const utcOffset = parseInt(
|
||||
dateString.substr(10, 1) + (utcOffsetAsDate.hour() * 60 + utcOffsetAsDate.minute())
|
||||
);
|
||||
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffset, true);
|
||||
const offsetString = dateString.substr(11, 14); // hhmm
|
||||
const offsetSign = dateString.substr(10, 1); // + or -
|
||||
|
||||
const offsetHour = offsetString.slice(0, -2);
|
||||
const offsetMinute = offsetString.slice(-2);
|
||||
|
||||
const utcOffsetInMinutes =
|
||||
(offsetSign === "-" ? -1 : 1) *
|
||||
(60 * (offsetHour !== "" ? parseInt(offsetHour) : 0) +
|
||||
(offsetMinute !== "" ? parseInt(offsetMinute) : 0));
|
||||
|
||||
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true);
|
||||
return date.isValid() ? date : null;
|
||||
}
|
||||
return null;
|
||||
}, [router.query.date]);
|
||||
|
||||
if (selectedDate) {
|
||||
// Let iframe take the width available due to increase in max-width
|
||||
sdkActionManager?.fire("__refreshWidth", {});
|
||||
}
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
|
||||
@@ -78,7 +107,12 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
useEffect(() => {
|
||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()));
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
telemetryEventTypes.pageView,
|
||||
collectPageParameters("availability", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
}, [telemetry]);
|
||||
|
||||
const changeDate = (newDate: Dayjs) => {
|
||||
@@ -121,17 +155,26 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<div>
|
||||
<main
|
||||
className={
|
||||
"transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
}>
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? classNames(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
)}>
|
||||
{isReady && (
|
||||
<div className="rounded-sm border-gray-200 bg-white dark:bg-gray-900 sm:dark:border-gray-600 md:border">
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||
"border-bookinglightest rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-900 border-white"
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
@@ -139,7 +182,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
image: `${process.env.NEXT_PUBLIC_APP_URL}/${user.username}/avatar.png`,
|
||||
image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`,
|
||||
alt: user.name || undefined,
|
||||
})),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
@@ -147,17 +190,25 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-sm font-medium text-black dark:text-gray-300">{profile.name}</p>
|
||||
<div className="flex gap-2 text-xs font-medium text-gray-600">
|
||||
{eventType.title}
|
||||
<div>
|
||||
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
||||
<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="text-bookinglight mb-2 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
<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" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
@@ -167,20 +218,35 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
</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>
|
||||
<p className="mt-3 text-gray-600 dark:text-gray-200">{eventType.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:flex sm:p-4 sm:py-5">
|
||||
<div
|
||||
className={
|
||||
"hidden pr-8 sm:border-r sm:dark:border-gray-800 md:flex md:flex-col " +
|
||||
"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")
|
||||
}>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-900 border-white"
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
@@ -189,7 +255,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
alt: user.name,
|
||||
image: `${process.env.NEXT_PUBLIC_APP_URL}/${user.username}/avatar.png`,
|
||||
image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`,
|
||||
})),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
}
|
||||
@@ -197,16 +263,22 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
|
||||
<h1 className="font-cal mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
|
||||
<h1 className="font-cal mb-4 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
|
||||
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
{eventType?.description && (
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
|
||||
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
<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" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
@@ -218,9 +290,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
)}
|
||||
|
||||
<TimezoneDropdown />
|
||||
|
||||
<p className="mt-3 mb-8 text-gray-600 dark:text-gray-200">{eventType.description}</p>
|
||||
{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"
|
||||
@@ -229,7 +299,21 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
<p className="sr-only">Go Back</p>
|
||||
</div>
|
||||
)}
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-2 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_desktop">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-white">
|
||||
<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>
|
||||
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
periodType={eventType?.periodType}
|
||||
@@ -253,6 +337,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
timeFormat={timeFormat}
|
||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
slotInterval={eventType.slotInterval}
|
||||
eventLength={eventType.length}
|
||||
date={selectedDate}
|
||||
@@ -265,7 +350,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && <PoweredByCal />}
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
@@ -274,8 +359,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
function TimezoneDropdown() {
|
||||
return (
|
||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-500">
|
||||
<GlobeIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
<Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-600 dark:text-white">
|
||||
<GlobeIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{timeZone()}
|
||||
{isTimeOptionsOpen ? (
|
||||
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { CalendarIcon, ClockIcon, CreditCardIcon, ExclamationIcon } from "@heroicons/react/solid";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs from "dayjs";
|
||||
@@ -6,39 +13,51 @@ import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
useIsEmbed,
|
||||
useEmbedStyles,
|
||||
useIsBackgroundTransparent,
|
||||
useEmbedType,
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { createPaymentLink } from "@calcom/stripe/client";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { ensureArray } from "@lib/ensureArray";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { LocationType } from "@lib/location";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import { parseZone } from "@lib/parseZone";
|
||||
import { parseDate } from "@lib/parseDate";
|
||||
import slugify from "@lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import { EmailInput, Form } from "@components/form/fields";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import { Button } from "@components/ui/Button";
|
||||
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 */
|
||||
const PhoneInput = dynamic(() => import("@components/ui/form/PhoneInput"));
|
||||
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;
|
||||
@@ -52,11 +71,24 @@ type BookingFormValues = {
|
||||
};
|
||||
};
|
||||
|
||||
const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
const BookingPage = ({
|
||||
eventType,
|
||||
booking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
locationLabels,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const router = useRouter();
|
||||
const { contracts } = useContracts();
|
||||
const { data: session } = useSession();
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
|
||||
useEffect(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
const eventOwner = eventType.users[0];
|
||||
@@ -96,11 +128,13 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
query: {
|
||||
date,
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
user: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
location,
|
||||
eventName: profile.eventName || "",
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -114,7 +148,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
|
||||
const eventTypeDetail = { isWeb3Active: false, ...eventType };
|
||||
|
||||
type Location = { type: LocationType; address?: string };
|
||||
type Location = { type: LocationType; address?: string; link?: string };
|
||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||
const locations: Location[] = useMemo(
|
||||
() => (eventType.locations as Location[]) || [],
|
||||
@@ -130,19 +164,11 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
|
||||
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
|
||||
const guestListEmails = !isDynamicGroupBooking
|
||||
? booking?.attendees.slice(1).map((attendee) => attendee.email)
|
||||
: [];
|
||||
|
||||
// TODO: Move to translations
|
||||
const locationLabels = {
|
||||
[LocationType.InPerson]: t("in_person_meeting"),
|
||||
[LocationType.Phone]: t("phone_call"),
|
||||
[LocationType.GoogleMeet]: "Google Meet",
|
||||
[LocationType.Zoom]: "Zoom Video",
|
||||
[LocationType.Jitsi]: "Jitsi Meet",
|
||||
[LocationType.Daily]: "Daily.co Video",
|
||||
[LocationType.Huddle01]: "Huddle01 Video",
|
||||
[LocationType.Tandem]: "Tandem Video",
|
||||
};
|
||||
const loggedInIsOwner = eventType.users[0].name === session?.user.name;
|
||||
const defaultValues = () => {
|
||||
if (!rescheduleUid) {
|
||||
return {
|
||||
@@ -169,12 +195,21 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
return {
|
||||
name: primaryAttendee.name || "",
|
||||
email: primaryAttendee.email || "",
|
||||
guests: booking.attendees.slice(1).map((attendee) => attendee.email),
|
||||
guests: guestListEmails,
|
||||
notes: booking.description || "",
|
||||
};
|
||||
};
|
||||
|
||||
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({
|
||||
@@ -199,23 +234,21 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
case LocationType.InPerson: {
|
||||
return locationInfo(locationType)?.address || "";
|
||||
}
|
||||
case LocationType.Link: {
|
||||
return locationInfo(locationType)?.link || "";
|
||||
}
|
||||
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||
default:
|
||||
return selectedLocation || "";
|
||||
}
|
||||
};
|
||||
|
||||
const parseDate = (date: string | null) => {
|
||||
if (!date) return "No date";
|
||||
const parsedZone = parseZone(date);
|
||||
if (!parsedZone?.isValid()) return "Invalid date";
|
||||
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
|
||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
||||
};
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
||||
jitsu.track(
|
||||
telemetryEventTypes.bookingConfirmed,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
|
||||
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
|
||||
@@ -247,6 +280,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
@@ -259,9 +293,13 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
};
|
||||
|
||||
const disableInput = !!rescheduleUid;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Theme />
|
||||
@@ -281,13 +319,24 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<main className="mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
|
||||
<main
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "" : "sm:my-24",
|
||||
"my-0 max-w-3xl "
|
||||
)}>
|
||||
{isReady && (
|
||||
<div className="overflow-hidden border border-gray-200 bg-white dark:border-0 dark:bg-neutral-900 sm:rounded-sm">
|
||||
<div
|
||||
className={classNames(
|
||||
"main overflow-hidden",
|
||||
isEmbed ? "" : "border border-gray-200",
|
||||
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
|
||||
"rounded-md sm:border sm:dark:border-gray-600"
|
||||
)}>
|
||||
<div className="px-4 py-5 sm:flex sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
|
||||
<AvatarGroup
|
||||
border="border-2 border-white dark:border-gray-900"
|
||||
border="border-2 border-white dark:border-gray-800"
|
||||
size={14}
|
||||
items={[{ image: profile.image || "", alt: profile.name || "" }].concat(
|
||||
eventType.users
|
||||
@@ -298,17 +347,25 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
<h2 className="font-cal mt-2 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
|
||||
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
|
||||
<h2 className="font-cal text-bookinglight mt-2 font-medium dark:text-gray-300">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<p className="mb-2 text-gray-500">
|
||||
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
{eventType?.description && (
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
|
||||
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1 dark:text-white">
|
||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
@@ -318,18 +375,28 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-4 text-green-500">
|
||||
<CalendarIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
{parseDate(date)}
|
||||
<p className="text-bookinghighlight mb-4">
|
||||
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{parseDate(date, i18n)}
|
||||
</p>
|
||||
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
|
||||
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
|
||||
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-8 text-gray-600 dark:text-white">{eventType.description}</p>
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mt-8 mb-2 text-gray-600 dark:text-white" data-testid="former_time_p">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-white">
|
||||
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</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">
|
||||
@@ -337,13 +404,17 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
{...bookingForm.register("name")}
|
||||
{...bookingForm.register("name", { required: true })}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
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" : ""
|
||||
)}
|
||||
placeholder={t("example_name")}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,8 +428,13 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
<EmailInput
|
||||
{...bookingForm.register("email")}
|
||||
required
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
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" : ""
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
|
||||
disabled={disableInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,13 +467,13 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
{t("phone_number")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<PhoneInput
|
||||
// @ts-expect-error
|
||||
<PhoneInput<BookingFormValues>
|
||||
control={bookingForm.control}
|
||||
name="phone"
|
||||
placeholder={t("enter_phone_number")}
|
||||
id="phone"
|
||||
required
|
||||
disabled={disableInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,8 +496,12 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
rows={3}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
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" : ""
|
||||
)}
|
||||
placeholder={input.placeholder}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.TEXT && (
|
||||
@@ -431,8 +511,9 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.NUMBER && (
|
||||
@@ -442,7 +523,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
placeholder=""
|
||||
/>
|
||||
)}
|
||||
@@ -484,32 +565,49 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("guests")}
|
||||
</label>
|
||||
<Controller
|
||||
control={bookingForm.control}
|
||||
name="guests"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ReactMultiEmail
|
||||
className="relative"
|
||||
placeholder="guest@example.com"
|
||||
emails={value}
|
||||
onChange={onChange}
|
||||
getLabel={(
|
||||
email: string,
|
||||
index: number,
|
||||
removeEmail: (index: number) => void
|
||||
) => {
|
||||
return (
|
||||
<div data-tag key={index}>
|
||||
{email}
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!disableInput && (
|
||||
<Controller
|
||||
control={bookingForm.control}
|
||||
name="guests"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ReactMultiEmail
|
||||
className="relative"
|
||||
placeholder="guest@example.com"
|
||||
emails={value}
|
||||
onChange={onChange}
|
||||
getLabel={(
|
||||
email: string,
|
||||
index: number,
|
||||
removeEmail: (index: number) => void
|
||||
) => {
|
||||
return (
|
||||
<div data-tag key={index} className="cursor-pointer">
|
||||
{email}
|
||||
{!disableInput && (
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Custom code when guest emails should not be editable */}
|
||||
{disableInput && guestListEmails && guestListEmails.length > 0 && (
|
||||
<div data-tag className="react-multi-email">
|
||||
{/* // @TODO: user owners are appearing as guest here when should be only user input */}
|
||||
{guestListEmails.map((email, index) => {
|
||||
return (
|
||||
<div key={index} className="cursor-pointer">
|
||||
<span data-tag>{email}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -523,13 +621,21 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
<textarea
|
||||
{...bookingForm.register("notes")}
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
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" : ""
|
||||
)}
|
||||
placeholder={t("share_additional_notes")}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start space-x-2 rtl:space-x-reverse">
|
||||
<Button type="submit" loading={mutation.isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||
loading={mutation.isLoading}>
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
</Button>
|
||||
<Button color="secondary" type="button" onClick={() => router.back()}>
|
||||
@@ -547,7 +653,8 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
</div>
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||
{(mutation.error as HttpError)?.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { CheckIcon } from "@heroicons/react/solid";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { DialogClose, DialogContent } from "@components/Dialog";
|
||||
import { Button } from "@components/ui/Button";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
export type ConfirmationDialogContentProps = {
|
||||
confirmBtn?: ReactNode;
|
||||
|
||||
97
apps/web/components/dialog/RescheduleDialog.tsx
Normal file
97
apps/web/components/dialog/RescheduleDialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { RescheduleResponse } from "pages/api/book/request-reschedule";
|
||||
import React, { useState, Dispatch, SetStateAction } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
|
||||
import * as fetchWrapper from "@lib/core/http/fetch-wrapper";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
interface IRescheduleDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
bookingUId: string;
|
||||
}
|
||||
|
||||
export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
|
||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const rescheduleApi = useMutation(
|
||||
async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await fetchWrapper.post<
|
||||
{ bookingId: string; rescheduleReason: string },
|
||||
RescheduleResponse
|
||||
>("/api/book/request-reschedule", {
|
||||
bookingId,
|
||||
rescheduleReason,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
showToast(t("reschedule_request_sent"), "success");
|
||||
setIsOpenDialog(false);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t("unexpected_error_try_again"), "error");
|
||||
// @TODO: notify sentry
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.bookings"]);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<ClockIcon className="m-auto h-6 w-6"></ClockIcon>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("send_reschedule_request")} />
|
||||
|
||||
<p className="-mt-8 text-sm text-gray-500">{t("reschedule_modal_description")}</p>
|
||||
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
||||
{t("reason_for_reschedule_request")}
|
||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||
</p>
|
||||
<TextArea
|
||||
data-testid="reschedule_reason"
|
||||
name={t("reschedule_reason")}
|
||||
value={rescheduleReason}
|
||||
onChange={(e) => setRescheduleReason(e.target.value)}
|
||||
className="mb-5 sm:mb-6"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
rescheduleApi.mutate();
|
||||
}}>
|
||||
{t("send_reschedule_request")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -2,34 +2,34 @@ import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { slugify } from "@lib/slugify";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
|
||||
import { Form, InputLeading, TextAreaField, TextField } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { Button } from "@components/ui/Button";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/ui/Dropdown";
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Form, InputLeading, TextAreaField, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { slugify } from "@lib/slugify";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import * as RadioArea from "@components/ui/form/radio-area";
|
||||
|
||||
// this describes the uniform data needed to create a new event type on Profile or Team
|
||||
interface EventTypeParent {
|
||||
export interface EventTypeParent {
|
||||
teamId: number | null | undefined; // if undefined, then it's a profile
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
@@ -56,13 +56,33 @@ 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 type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
||||
|
||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||
resolver: zodResolver(createEventTypeInput),
|
||||
defaultValues: { length: 15 },
|
||||
});
|
||||
const { setValue, watch, register } = form;
|
||||
|
||||
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 }) => {
|
||||
if (name === "title" && type === "change") {
|
||||
@@ -75,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) => {
|
||||
@@ -113,7 +133,9 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog name="new-eventtype" clearQueryParamsOnClose={["eventPage", "teamId"]}>
|
||||
<Dialog
|
||||
name="new-eventtype"
|
||||
clearQueryParamsOnClose={["eventPage", "teamId", "type", "description", "title", "length", "slug"]}>
|
||||
{!hasTeams || props.isIndividualTeam ? (
|
||||
<Button
|
||||
onClick={() => openModal(props.options[0])}
|
||||
@@ -178,7 +200,7 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
required
|
||||
addOnLeading={
|
||||
<InputLeading>
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{pageSlug}/
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
||||
</InputLeading>
|
||||
}
|
||||
{...register("slug")}
|
||||
@@ -196,7 +218,6 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
required
|
||||
min="10"
|
||||
placeholder="15"
|
||||
defaultValue={15}
|
||||
label={t("length")}
|
||||
className="pr-20"
|
||||
{...register("length", { valueAsNumber: true })}
|
||||
@@ -222,11 +243,17 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
{...register("schedulingType")}
|
||||
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
|
||||
className="relative mt-1 flex space-x-6 rounded-sm shadow-sm rtl:space-x-reverse">
|
||||
<RadioArea.Item value={SchedulingType.COLLECTIVE} className="w-1/2 text-sm">
|
||||
<RadioArea.Item
|
||||
value={SchedulingType.COLLECTIVE}
|
||||
defaultChecked={type === SchedulingType.COLLECTIVE}
|
||||
className="w-1/2 text-sm">
|
||||
<strong className="mb-1 block">{t("collective")}</strong>
|
||||
<p>{t("collective_description")}</p>
|
||||
</RadioArea.Item>
|
||||
<RadioArea.Item value={SchedulingType.ROUND_ROBIN} className="w-1/2 text-sm">
|
||||
<RadioArea.Item
|
||||
value={SchedulingType.ROUND_ROBIN}
|
||||
defaultChecked={type === SchedulingType.ROUND_ROBIN}
|
||||
className="w-1/2 text-sm">
|
||||
<strong className="mb-1 block">{t("round_robin")}</strong>
|
||||
<p>{t("round_robin_description")}</p>
|
||||
</RadioArea.Item>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
import { List } from "@components/List";
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import ConnectIntegration from "./ConnectIntegrations";
|
||||
import DisconnectIntegration from "./DisconnectIntegration";
|
||||
import IntegrationListItem from "./IntegrationListItem";
|
||||
import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections";
|
||||
@@ -110,7 +110,8 @@ function ConnectedCalendarsList(props: Props) {
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
<IntegrationListItem
|
||||
{...item.integration}
|
||||
title={item.integration.title}
|
||||
imageSrc={item.integration.imageSrc}
|
||||
description={item.primary?.externalId || "No external Id"}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
@@ -127,8 +128,8 @@ function ConnectedCalendarsList(props: Props) {
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId as string}
|
||||
title={cal.name as string}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
/>
|
||||
@@ -138,7 +139,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Something went wrong"
|
||||
title={t("calendar_error")}
|
||||
message={item.error?.message}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
@@ -174,16 +175,18 @@ function CalendarList(props: Props) {
|
||||
{data.calendar.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
key={item.title}
|
||||
{...item}
|
||||
title={item.title}
|
||||
imageSrc={item.imageSrc}
|
||||
description={item.description}
|
||||
actions={
|
||||
<ConnectIntegration
|
||||
<InstallAppButton
|
||||
type={item.type}
|
||||
render={(btnProps) => (
|
||||
<Button color="secondary" {...btnProps} data-testid="integration-connection-button">
|
||||
render={(buttonProps) => (
|
||||
<Button color="secondary" {...buttonProps}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={() => props.onChanged()}
|
||||
onChanged={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -193,6 +196,7 @@ function CalendarList(props: Props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarListContainer(props: { heading?: false }) {
|
||||
const { t } = useLocale();
|
||||
const { heading = true } = props;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants";
|
||||
import { AddAppleIntegrationModal } from "@lib/integrations/calendar/components/AddAppleIntegration";
|
||||
import { AddCalDavIntegrationModal } from "@lib/integrations/calendar/components/AddCalDavIntegration";
|
||||
|
||||
import { ButtonBaseProps } from "@components/ui/Button";
|
||||
|
||||
export default function ConnectIntegration(props: {
|
||||
type: string;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
const { type } = props;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const mutation = useMutation(async () => {
|
||||
const state: IntegrationOAuthCallbackState = {
|
||||
returnTo: NEXT_PUBLIC_BASE_URL + location.pathname + location.search,
|
||||
};
|
||||
const stateStr = encodeURIComponent(JSON.stringify(state));
|
||||
const searchParams = `?state=${stateStr}`;
|
||||
const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add" + searchParams);
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
const json = await res.json();
|
||||
window.location.href = json.url;
|
||||
setIsLoading(true);
|
||||
});
|
||||
const [isModalOpen, _setIsModalOpen] = useState(false);
|
||||
|
||||
const setIsModalOpen = (v: boolean) => {
|
||||
_setIsModalOpen(v);
|
||||
props.onOpenChange(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.render({
|
||||
onClick() {
|
||||
if (["caldav_calendar", "apple_calendar"].includes(type)) {
|
||||
// special handlers
|
||||
setIsModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate();
|
||||
},
|
||||
loading: mutation.isLoading || isLoading,
|
||||
disabled: isModalOpen,
|
||||
})}
|
||||
{type === "caldav_calendar" && (
|
||||
<AddCalDavIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||
)}
|
||||
|
||||
{type === "apple_calendar" && (
|
||||
<AddAppleIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { Dialog } from "@components/Dialog";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import { ButtonBaseProps } from "@components/ui/Button";
|
||||
|
||||
export default function DisconnectIntegration(props: {
|
||||
/** Integration credential id */
|
||||
@@ -24,12 +26,14 @@ export default function DisconnectIntegration(props: {
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await props.onOpenChange(modalOpen);
|
||||
},
|
||||
onSuccess() {
|
||||
onSuccess(data) {
|
||||
showToast(data.message, "success");
|
||||
setModalOpen(false);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Image from "next/image";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
@@ -6,7 +5,7 @@ import classNames from "@lib/classNames";
|
||||
import { ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||
|
||||
function IntegrationListItem(props: {
|
||||
imageSrc: string;
|
||||
imageSrc?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
@@ -15,7 +14,7 @@ function IntegrationListItem(props: {
|
||||
return (
|
||||
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
|
||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
|
||||
<Image width={40} height={40} src={`/${props.imageSrc}`} alt={props.title} />
|
||||
{props.imageSrc && <img className="h-10 w-10" src={props.imageSrc} alt={props.title} />}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3">{props.title}</ListItemTitle>
|
||||
<ListItemText component="p">{props.description}</ListItemText>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
|
||||
import React, { FC } from "react";
|
||||
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
import Select from "react-select";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
interface OptionTypeBase {
|
||||
label: string;
|
||||
@@ -55,7 +56,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
defaultValue={selectedInputOption}
|
||||
options={inputOptions}
|
||||
isSearchable={false}
|
||||
className="focus:border-primary-500 focus:ring-primary-500 mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-md border-gray-300 sm:text-sm"
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 sm:text-sm"
|
||||
onChange={(option) => option && field.onChange(option.value)}
|
||||
value={selectedInputOption}
|
||||
onBlur={field.onBlur}
|
||||
@@ -73,7 +74,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
@@ -89,7 +90,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
<input
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { SyntheticEvent, useState } from "react";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { ErrorCode } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
const ChangePasswordSection = () => {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
@@ -56,11 +56,11 @@ const ChangePasswordSection = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="py-6 lg:pb-5">
|
||||
<div className="my-3">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-1/2 ltr:mr-2 rtl:ml-2">
|
||||
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
|
||||
@@ -74,7 +74,7 @@ const ChangePasswordSection = () => {
|
||||
name="current_password"
|
||||
id="current_password"
|
||||
required
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
placeholder={t("your_old_password")}
|
||||
/>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@ const ChangePasswordSection = () => {
|
||||
value={newPassword}
|
||||
required
|
||||
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
placeholder={t("super_secure_new_password")}
|
||||
/>
|
||||
</div>
|
||||
@@ -99,9 +99,10 @@ const ChangePasswordSection = () => {
|
||||
</div>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
<div className="flex justify-end py-8">
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
<Button color="secondary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { SyntheticEvent, useState } from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { ErrorCode } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { Dialog, DialogContent } from "@components/Dialog";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
||||
|
||||
@@ -70,7 +70,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-900 focus:ring-neutral-900 sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { SyntheticEvent, useState } from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { ErrorCode } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { Dialog, DialogContent } from "@components/Dialog";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
||||
|
||||
@@ -139,7 +139,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-900 focus:ring-neutral-900 sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -172,7 +172,8 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
minLength={6}
|
||||
inputMode="numeric"
|
||||
onInput={(e) => setTotpCode(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-900 focus:ring-neutral-900 sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Badge from "@components/ui/Badge";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import DisableTwoFactorModal from "./DisableTwoFactorModal";
|
||||
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
||||
@@ -16,21 +17,25 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
<div className="flex flex-row justify-between truncate pt-9 pl-2">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
<Badge className="ml-2 text-xs" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
|
||||
<Button
|
||||
className="mt-6"
|
||||
type="submit"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? t("disable") : t("enable")} {t("2fa")}
|
||||
</Button>
|
||||
|
||||
{enableModalOpen && (
|
||||
<EnableTwoFactorModal
|
||||
onEnable={() => {
|
||||
|
||||
@@ -67,14 +67,14 @@ const constructImage = (name: string, description: string, username: string): st
|
||||
return (
|
||||
encodeURIComponent("Meet **" + name + "** <br>" + description).replace(/'/g, "%27") +
|
||||
".png?md=1&images=https%3A%2F%2Fcal.com%2Flogo-white.svg&images=" +
|
||||
(process.env.NEXT_PUBLIC_APP_URL || process.env.BASE_URL) +
|
||||
(process.env.NEXT_PUBLIC_WEBSITE_URL || process.env.NEXT_PUBLIC_WEBAPP_URL) +
|
||||
"/" +
|
||||
username +
|
||||
"/avatar.png"
|
||||
);
|
||||
};
|
||||
|
||||
export const HeadSeo: React.FC<HeadSeoProps & { children?: never }> = (props) => {
|
||||
export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
|
||||
const defaultUrl = getBrowserInfo()?.url;
|
||||
const image = getSeoImage("default");
|
||||
|
||||
@@ -113,3 +113,5 @@ export const HeadSeo: React.FC<HeadSeoProps & { children?: never }> = (props) =>
|
||||
|
||||
return <NextSeo {...seoProps} />;
|
||||
};
|
||||
|
||||
export default HeadSeo;
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
import React, { SyntheticEvent, useEffect } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type MembershipRoleOption = {
|
||||
value: MembershipRole;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }];
|
||||
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
isOpen: boolean;
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [role, setRole] = useState(props.initialRole || MembershipRole.MEMBER);
|
||||
useEffect(() => {
|
||||
options.forEach((option, i) => {
|
||||
options[i].label = t(option.value.toLowerCase());
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [role, setRole] = useState(
|
||||
options.find((option) => option.value === props.initialRole || MembershipRole.MEMBER)!
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
@@ -35,12 +54,12 @@ export default function MemberChangeRoleModal(props: {
|
||||
changeRoleMutation.mutate({
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
role,
|
||||
role: role.value,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContainer>
|
||||
<ModalContainer isOpen={props.isOpen} onExit={props.onExit}>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
@@ -54,17 +73,16 @@ export default function MemberChangeRoleModal(props: {
|
||||
<label className="mb-2 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<select
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={options}
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as MembershipRole)}
|
||||
onChange={(option) => option && setRole(option)}
|
||||
id="role"
|
||||
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
</select>
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
import { UserIcon } from "@heroicons/react/outline";
|
||||
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
import React, { useState, useEffect, SyntheticEvent, useMemo } from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { TextField } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
||||
type MemberInvitationModalProps = {
|
||||
isOpen: boolean;
|
||||
team: TeamWithMembers | null;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
type MembershipRoleOption = {
|
||||
value: MembershipRole;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }];
|
||||
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const options = useMemo(() => {
|
||||
_options.forEach((option, i) => {
|
||||
_options[i].label = t(option.value.toLowerCase());
|
||||
});
|
||||
return _options;
|
||||
}, [t]);
|
||||
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
@@ -48,102 +70,89 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block transform rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UserIcon className="text-brandcontrast h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("invite_new_member")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
|
||||
</div>
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<DialogContent>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UserIcon className="text-brandcontrast h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("invite_new_member")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={inviteMember}>
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
label={t("email_or_username")}
|
||||
id="inviteUser"
|
||||
name="inviteUser"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
className="focus:border-brand mt-1 block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="sendInviteEmail"
|
||||
defaultChecked
|
||||
id="sendInviteEmail"
|
||||
className="focus:border-brand rounded-sm border-gray-300 text-black shadow-sm focus:ring-black sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-2 rtl:mr-2">
|
||||
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
|
||||
{t("send_invite_email")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row rounded-sm bg-gray-50 px-3 py-2">
|
||||
<InformationCircleIcon className="h-5 w-5 flex-shrink-0 fill-gray-400" aria-hidden="true" />
|
||||
<span className="ml-2 text-sm leading-tight text-gray-500">
|
||||
Note: This will cost an extra seat ($12/m) on your subscription if this invitee does not
|
||||
have a pro account.{" "}
|
||||
<a href="#" className="underline">
|
||||
Learn More
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
data-testid="invite-new-member-button">
|
||||
{t("invite")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={inviteMember}>
|
||||
<div className="space-y-4">
|
||||
<TextField
|
||||
label={t("email_or_username")}
|
||||
id="inviteUser"
|
||||
name="inviteUser"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={options[0]}
|
||||
options={options}
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="sendInviteEmail"
|
||||
defaultChecked
|
||||
id="sendInviteEmail"
|
||||
className="rounded-sm border-gray-300 text-black shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-2 rtl:mr-2">
|
||||
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
|
||||
{t("send_invite_email")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row rounded-sm bg-gray-50 px-3 py-2">
|
||||
<InformationCircleIcon className="h-5 w-5 flex-shrink-0 fill-gray-400" aria-hidden="true" />
|
||||
<span className="ml-2 text-sm leading-tight text-gray-500">
|
||||
Note: This will cost an extra seat ($12/m) on your subscription if this invitee does not have
|
||||
a pro account.{" "}
|
||||
<a href="#" className="underline">
|
||||
Learn More
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
data-testid="invite-new-member-button">
|
||||
{t("invite")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/Dropdown";
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||
|
||||
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";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamPill, { TeamRole } from "./TeamPill";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
@@ -164,6 +164,7 @@ export default function MemberListItem(props: Props) {
|
||||
</div>
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
isOpen={showChangeMemberRoleModal}
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
@@ -171,9 +172,13 @@ export default function MemberListItem(props: Props) {
|
||||
/>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<ModalContainer wide noPadding>
|
||||
<ModalContainer
|
||||
wide
|
||||
noPadding
|
||||
isOpen={showTeamAvailabilityModal}
|
||||
onExit={() => setShowTeamAvailabilityModal(false)}>
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<div className="space-x-2 border-t p-5 rtl:space-x-reverse">
|
||||
<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`}>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -32,24 +35,12 @@ export default function TeamCreate(props: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block transform rounded-sm bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<>
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onClose}>
|
||||
<DialogContent>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="h-6 w-6 text-neutral-900" />
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="text-brandcontrast h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
@@ -72,21 +63,25 @@ export default function TeamCreate(props: Props) {
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<div className="mt-5 flex flex-row-reverse sm:mt-4">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("create_team")}
|
||||
</button>
|
||||
<button onClick={props.onClose} type="button" className="btn btn-white ltr:mr-2">
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
data-testid="create-new-team-button">
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import showToast from "@lib/notification";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import TeamListItem from "./TeamListItem";
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import { LogoutIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
TrashIcon,
|
||||
LinkIcon,
|
||||
DotsHorizontalIcon,
|
||||
PencilIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@components/ui/Dropdown";
|
||||
} from "@calcom/ui/Dropdown";
|
||||
|
||||
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";
|
||||
|
||||
import { TeamRole } from "./TeamPill";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.list">[number];
|
||||
@@ -65,7 +72,7 @@ export default function TeamListItem(props: Props) {
|
||||
<div className="ml-3 inline-block">
|
||||
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/team/{team.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +112,7 @@ export default function TeamListItem(props: Props) {
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
|
||||
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
className="h-10 w-10 transition-none"
|
||||
@@ -125,9 +132,9 @@ export default function TeamListItem(props: Props) {
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<a>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full font-normal"
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={PencilIcon}>
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
@@ -135,16 +142,14 @@ export default function TeamListItem(props: Props) {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && <DropdownMenuSeparator className="h-px bg-gray-200" />}
|
||||
<DropdownMenuItem>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`} passHref={true}>
|
||||
<a target="_blank">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full font-normal"
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={ExternalLinkIcon}>
|
||||
{" "}
|
||||
{t("preview_team")}
|
||||
</Button>
|
||||
</a>
|
||||
@@ -160,8 +165,9 @@ export default function TeamListItem(props: Props) {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={TrashIcon}
|
||||
className="w-full font-normal">
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={TrashIcon}>
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -183,8 +189,9 @@ export default function TeamListItem(props: Props) {
|
||||
<Button
|
||||
type="button"
|
||||
color="warn"
|
||||
size="lg"
|
||||
StartIcon={LogoutIcon}
|
||||
className="w-full"
|
||||
className="w-full rounded-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { HashtagIcon, InformationCircleIcon, LinkIcon, PhotographIcon } from "@heroicons/react/solid";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { objectKeys } from "@calcom/lib/objectKeys";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import { TextField } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
import SettingInputContainer from "@components/ui/SettingInputContainer";
|
||||
|
||||
interface Props {
|
||||
@@ -53,9 +55,9 @@ export default function TeamSettings(props: Props) {
|
||||
hideBranding: hideBrandingRef.current?.checked,
|
||||
};
|
||||
// remove unchanged variables
|
||||
for (const key in variables) {
|
||||
if (variables[key] === team?.[key]) delete variables[key];
|
||||
}
|
||||
objectKeys(variables).forEach((key) => {
|
||||
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
|
||||
});
|
||||
mutation.mutate({ id: team.id, ...variables });
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export default function TeamSettings(props: Props) {
|
||||
id="team-url"
|
||||
addOnLeading={
|
||||
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{"team/"}
|
||||
</span>
|
||||
}
|
||||
ref={teamUrlRef}
|
||||
@@ -110,7 +112,7 @@ export default function TeamSettings(props: Props) {
|
||||
id="name"
|
||||
placeholder={t("your_team_name")}
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800 sm:text-sm"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
defaultValue={team?.name as string}
|
||||
/>
|
||||
}
|
||||
@@ -129,7 +131,7 @@ export default function TeamSettings(props: Props) {
|
||||
name="about"
|
||||
rows={3}
|
||||
defaultValue={team?.bio as string}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"></textarea>
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"></textarea>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</>
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export default function TeamSettings(props: Props) {
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800 sm:text-sm"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
defaultValue={team?.logo ?? undefined}
|
||||
/>
|
||||
<ImageUploader
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { ClockIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, TrashIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||
import LinkIconButton from "@components/ui/LinkIconButton";
|
||||
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
|
||||
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`;
|
||||
const permalink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${props.team?.slug}`;
|
||||
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
@@ -11,9 +10,10 @@ import {
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "@components/Dialog";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
} from "@calcom/ui/Dialog";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
interface Props {
|
||||
teamId: number;
|
||||
|
||||
@@ -5,11 +5,12 @@ import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
import React from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import Text from "@components/ui/Text";
|
||||
|
||||
type TeamType = TeamPageProps["team"];
|
||||
@@ -27,7 +28,7 @@ const Team = ({ team }: TeamPageProps) => {
|
||||
"space-y-4",
|
||||
"p-4",
|
||||
"min-w-full sm:min-w-64 sm:max-w-64",
|
||||
"bg-white dark:bg-neutral-900 dark:border-0 dark:bg-opacity-8",
|
||||
"bg-white dark:bg-neutral-800 dark:border-neutral-700 dark:bg-opacity-8",
|
||||
"border border-neutral-200",
|
||||
"hover:cursor-pointer",
|
||||
"hover:border-brand dark:border-neutral-700 dark:hover:border-neutral-600",
|
||||
|
||||
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>;
|
||||
};
|
||||
@@ -11,7 +11,7 @@ export type AvatarProps = {
|
||||
};
|
||||
|
||||
// defaultAvatarSrc from profile.tsx can't be used as it imports crypto
|
||||
function defaultAvatarSrc({ md5 }) {
|
||||
function defaultAvatarSrc(md5: string) {
|
||||
return `https://www.gravatar.com/avatar/${md5}?s=160&d=identicon&r=PG`;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function AvatarSSR(props: AvatarProps) {
|
||||
if (user.avatar) {
|
||||
imgSrc = user.avatar;
|
||||
} else if (user.emailMd5) {
|
||||
imgSrc = defaultAvatarSrc({ md5: user.emailMd5 });
|
||||
imgSrc = defaultAvatarSrc(user.emailMd5);
|
||||
}
|
||||
return imgSrc ? <img alt={alt} className={className} src={imgSrc}></img> : null;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// TODO: Remove this file once every Button is imported from `@calcom/ui`
|
||||
export * from "@calcom/ui/Button";
|
||||
export { default } from "@calcom/ui/Button";
|
||||
34
apps/web/components/ui/EditableHeading.tsx
Normal file
34
apps/web/components/ui/EditableHeading.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { PencilIcon } from "@heroicons/react/solid";
|
||||
import { useState } from "react";
|
||||
|
||||
const EditableHeading = ({ title, onChange }: { title: string; onChange: (value: string) => void }) => {
|
||||
const [editIcon, setEditIcon] = useState(true);
|
||||
return (
|
||||
<div className="group relative cursor-pointer" onClick={() => setEditIcon(false)}>
|
||||
{editIcon ? (
|
||||
<>
|
||||
<h1
|
||||
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
|
||||
className="inline pl-0 text-gray-900 focus:text-black group-hover:text-gray-500">
|
||||
{title}
|
||||
</h1>
|
||||
<PencilIcon className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ marginBottom: -11 }}>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
style={{ top: -6, fontSize: 22 }}
|
||||
required
|
||||
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
|
||||
defaultValue={title}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableHeading;
|
||||
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,39 +1,35 @@
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
|
||||
|
||||
interface Props extends React.PropsWithChildren<any> {
|
||||
wide?: boolean;
|
||||
scroll?: boolean;
|
||||
noPadding?: boolean;
|
||||
isOpen: boolean;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export default function ModalContainer(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className={classNames(
|
||||
"min-w-96 inline-block transform rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:p-6 sm:align-middle",
|
||||
{
|
||||
"sm:w-full sm:max-w-lg ": !props.wide,
|
||||
"sm:w-4xl sm:max-w-4xl": props.wide,
|
||||
"overflow-scroll": props.scroll,
|
||||
"!p-0": props.noPadding,
|
||||
}
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<DialogContent>
|
||||
<div
|
||||
className={classNames(
|
||||
"inline-block transform bg-white text-left align-bottom transition-all sm:align-middle",
|
||||
{
|
||||
"sm:w-full sm:max-w-lg ": !props.wide,
|
||||
"sm:w-4xl sm:max-w-4xl": props.wide,
|
||||
"overflow-auto": props.scroll,
|
||||
"!p-0": props.noPadding,
|
||||
}
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
const PoweredByCal = () => {
|
||||
const { t } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
return (
|
||||
<div className="p-1 text-center text-xs sm:text-right">
|
||||
<div className={"p-1 text-center text-xs sm:text-right" + (isEmbed ? " max-w-3xl" : "")}>
|
||||
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
|
||||
<a target="_blank" className="text-gray-500 opacity-50 hover:opacity-100 dark:text-white">
|
||||
<a target="_blank" className="text-bookinglight opacity-50 hover:opacity-100 dark:text-white">
|
||||
{t("powered_by")}{" "}
|
||||
<img
|
||||
className="relative -mt-px inline h-[10px] w-auto dark:hidden"
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { Availability } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import { WeekdaySelect } from "./WeekdaySelect";
|
||||
import SetTimesModal from "./modal/SetTimesModal";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type AvailabilityInput = Pick<Availability, "days" | "startTime" | "endTime">;
|
||||
|
||||
type Props = {
|
||||
timeZone: string;
|
||||
availability: Availability[];
|
||||
setTimeZone: (timeZone: string) => void;
|
||||
setAvailability: (schedule: {
|
||||
openingHours: AvailabilityInput[];
|
||||
dateOverrides: AvailabilityInput[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone }: Props) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const [editSchedule, setEditSchedule] = useState(-1);
|
||||
const [openingHours, setOpeningHours] = useState<Availability[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpeningHours(availability.filter((item: Availability) => item.days.length !== 0));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailability({ openingHours, dateOverrides: [] });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openingHours]);
|
||||
|
||||
const addNewSchedule = () => setEditSchedule(openingHours.length);
|
||||
|
||||
const applyEditSchedule = (changed: Availability) => {
|
||||
// new entry
|
||||
if (!changed.days) {
|
||||
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
|
||||
setOpeningHours(openingHours.concat(changed));
|
||||
} else {
|
||||
// update
|
||||
const replaceWith = { ...openingHours[editSchedule], ...changed };
|
||||
openingHours.splice(editSchedule, 1, replaceWith);
|
||||
setOpeningHours([...openingHours]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeScheduleAt = (toRemove: number) => {
|
||||
openingHours.splice(toRemove, 1);
|
||||
setOpeningHours([...openingHours]);
|
||||
};
|
||||
|
||||
const OpeningHours = ({ idx, item }: { idx: number; item: Availability }) => (
|
||||
<li className="flex justify-between border-b py-2">
|
||||
<div className="flex flex-col space-y-4 lg:inline-flex">
|
||||
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
|
||||
<button
|
||||
className="rounded-sm bg-neutral-100 px-3 py-2 text-sm"
|
||||
type="button"
|
||||
onClick={() => setEditSchedule(idx)}>
|
||||
{item.startTime.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
{t("until")}
|
||||
{item.endTime.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeScheduleAt(idx)}
|
||||
className="btn-sm ml-1 bg-transparent px-2 py-1">
|
||||
<TrashIcon className="-mt-1 inline h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={timeZone}
|
||||
onChange={(tz: ITimezoneOption) => setTimeZone(tz.value)}
|
||||
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{openingHours.map((item, idx) => (
|
||||
<OpeningHours key={idx} idx={idx} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
<Button type="button" onClick={addNewSchedule} className="mt-2" color="secondary" size="sm">
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{editSchedule >= 0 && (
|
||||
<SetTimesModal
|
||||
startTime={
|
||||
openingHours[editSchedule]
|
||||
? new Date(openingHours[editSchedule].startTime).getUTCHours() * 60 +
|
||||
new Date(openingHours[editSchedule].startTime).getUTCMinutes()
|
||||
: 540
|
||||
}
|
||||
endTime={
|
||||
openingHours[editSchedule]
|
||||
? new Date(openingHours[editSchedule].endTime).getUTCHours() * 60 +
|
||||
new Date(openingHours[editSchedule].endTime).getUTCMinutes()
|
||||
: 1020
|
||||
}
|
||||
onChange={(times: { startTime: number; endTime: number }) =>
|
||||
applyEditSchedule({
|
||||
...(openingHours[editSchedule] || {}),
|
||||
startTime: new Date(
|
||||
new Date().setUTCHours(Math.floor(times.startTime / 60), times.startTime % 60, 0, 0)
|
||||
),
|
||||
endTime: new Date(
|
||||
new Date().setUTCHours(Math.floor(times.endTime / 60), times.endTime % 60, 0, 0)
|
||||
),
|
||||
})
|
||||
}
|
||||
onExit={() => setEditSchedule(-1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user