Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
160
.env.example
160
.env.example
@@ -1,2 +1,158 @@
|
||||
# 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
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
# - TANDEM
|
||||
# - ZOOM
|
||||
|
||||
# - 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
|
||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
||||
# NEXTAUTH_URL='http://localhost:3000'
|
||||
NEXTAUTH_URL=
|
||||
JWT_SECRET='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=
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - 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 **********************************************************************************************
|
||||
# - DAILY.CO VIDEO
|
||||
DAILY_API_KEY=
|
||||
DAILY_SCALE_PLAN=''
|
||||
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS='{}'
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
||||
GOOGLE_LOGIN_ENABLED=false
|
||||
|
||||
# - OFFICE 365
|
||||
# Used for the Office 365 / Outlook.com Calendar / MS Teams integration
|
||||
# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret
|
||||
MS_GRAPH_CLIENT_ID=
|
||||
MS_GRAPH_CLIENT_SECRET=
|
||||
|
||||
# - SLACK
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret
|
||||
SLACK_SIGNING_SECRET=
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
|
||||
# - STRIPE
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
STRIPE_CLIENT_ID= # ca_...
|
||||
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||
|
||||
# - TANDEM
|
||||
# Used for the Tandem integration -- contact support@tandem.chat to for API access.
|
||||
TANDEM_CLIENT_ID=""
|
||||
TANDEM_CLIENT_SECRET=""
|
||||
TANDEM_BASE_URL="https://tandem.chat"
|
||||
|
||||
# - ZOOM
|
||||
# Used for the Zoom integration
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
packages/prisma/zod
|
||||
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"]
|
||||
|
||||
38
.github/workflows/e2e.yml
vendored
38
.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
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
JWT_SECRET: secret
|
||||
PLAYWRIGHT_SECRET: ${{ secrets.CI_PLAYWRIGHT_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/
|
||||
|
||||
6
.gitmodules
vendored
6
.gitmodules
vendored
@@ -1,6 +1,8 @@
|
||||
[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"]
|
||||
|
||||
56
README.md
56
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
|
||||
@@ -196,7 +196,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env
|
||||
### E2E-Testing
|
||||
|
||||
```sh
|
||||
# In first terminal
|
||||
# In first terminal. Must run on port 3000.
|
||||
yarn dx
|
||||
# In second terminal
|
||||
yarn workspace @calcom/web test-e2e
|
||||
@@ -317,6 +317,56 @@ 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
|
||||
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.
|
||||
|
||||
2
apps/api
2
apps/api
Submodule apps/api updated: 378cbf8f3a...187b97afa1
@@ -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)
|
||||
|
||||
208
apps/docs/pages/integrations/embed.mdx
Normal file
208
apps/docs/pages/integrations/embed.mdx
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: Embed
|
||||
---
|
||||
|
||||
# Embed
|
||||
|
||||
The Embed allows your website visitors to book a meeting with you directly from your website.
|
||||
|
||||
## Install on any website
|
||||
|
||||
TODO: Mention possibility of installation through tag managers as well
|
||||
|
||||
- _Step-1._ Install the Vanilla JS Snippet
|
||||
|
||||
```javascript
|
||||
(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");
|
||||
```
|
||||
|
||||
- _Step-2_. Initialize it
|
||||
|
||||
```javascript
|
||||
Cal("init)
|
||||
```
|
||||
|
||||
## 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 add 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>
|
||||
|
||||
```javascript
|
||||
Cal("inline", {
|
||||
elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly
|
||||
calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed
|
||||
config: {
|
||||
name: "John Doe", // Prefill Name
|
||||
email: "johndoe@gmail.com", // Prefill Email
|
||||
notes: "Test Meeting", // Prefill Notes
|
||||
guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests
|
||||
theme: "dark", // "dark" or "light" theme
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
</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>
|
||||
### Full Screen
|
||||
|
||||
## 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.
|
||||
|
||||
```javascript
|
||||
Cal("inline", { elementOrSelector, calLink });
|
||||
````
|
||||
|
||||
- `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.
|
||||
|
||||
```javascript
|
||||
Cal("inline", { styles });
|
||||
```
|
||||
|
||||
- `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.
|
||||
|
||||
```javascript
|
||||
Cal("preload", { calLink });
|
||||
```
|
||||
|
||||
- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john]()
|
||||
|
||||
## Actions
|
||||
You can listen to an action that occurs in embedded cal link as follows. You can think of them as DOM events. We are avoiding the term events to not confuse it with Cal Events.
|
||||
```javascript
|
||||
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;
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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.
|
||||
23
apps/swagger/package.json
Normal file
23
apps/swagger/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@calcom/swagger",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"isarray": "2.0.5",
|
||||
"next": "12.1.4",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"swagger-ui-react": "4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "16.11.26",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/react-dom": "17.0.14",
|
||||
"typescript": "4.6.3"
|
||||
}
|
||||
}
|
||||
8
apps/swagger/pages/_app.tsx
Normal file
8
apps/swagger/pages/_app.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import "../styles/globals.css";
|
||||
import "../styles/swagger-cal.css";
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
45
apps/swagger/pages/index.tsx
Normal file
45
apps/swagger/pages/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Head from "next/head";
|
||||
import SwaggerUI from "swagger-ui-react";
|
||||
|
||||
const requestSnippets = {
|
||||
generators: {
|
||||
curl_bash: {
|
||||
title: "cURL (bash)",
|
||||
syntax: "bash",
|
||||
},
|
||||
curl_powershell: {
|
||||
title: "cURL (PowerShell)",
|
||||
syntax: "powershell",
|
||||
},
|
||||
curl_cmd: {
|
||||
title: "cURL (CMD)",
|
||||
syntax: "bash",
|
||||
},
|
||||
node: {
|
||||
title: "Node",
|
||||
syntax: "node",
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["curl_bash"],
|
||||
// e.g. only show curl bash = ["curl_bash"]
|
||||
};
|
||||
export default function APIDocs() {
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Cal.com - Docs - SwaggerUI</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<SwaggerUI
|
||||
requestSnippets={requestSnippets}
|
||||
requestSnippetsEnabled={true}
|
||||
docExpansion="none"
|
||||
operationsSorter="method"
|
||||
filter={true}
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/api/docs"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 |
4
apps/swagger/public/vercel.svg
Normal file
4
apps/swagger/public/vercel.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 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;
|
||||
}
|
||||
1751
apps/swagger/styles/swagger-cal.css
Normal file
1751
apps/swagger/styles/swagger-cal.css
Normal file
File diff suppressed because it is too large
Load Diff
20
apps/swagger/tsconfig.json
Normal file
20
apps/swagger/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
"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=
|
||||
220
apps/web/components/App.tsx
Normal file
220
apps/web/components/App.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
DocumentTextIcon,
|
||||
ExternalLinkIcon,
|
||||
FlagIcon,
|
||||
MailIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
//import NavTabs from "@components/NavTabs";
|
||||
import Shell from "@components/Shell";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
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);
|
||||
|
||||
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 ? (
|
||||
<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>{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,48 +1,49 @@
|
||||
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 { 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 { UserPlan } from "@calcom/prisma/client";
|
||||
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 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 +55,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 +87,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 +121,14 @@ 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 }) => {
|
||||
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 +149,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 +173,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,95 +189,127 @@ 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"
|
||||
)}>
|
||||
{!!props.backPath && (
|
||||
<div className="mx-3 mb-8 sm:mx-8">
|
||||
@@ -307,14 +321,22 @@ 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">
|
||||
<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",
|
||||
@@ -323,34 +345,38 @@ export default function Shell(props: {
|
||||
{props.children}
|
||||
</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 +386,60 @@ 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;
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
if (i18n.status === "loading" || query.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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session && !props.isPublic) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||
<MemoizedLayout plan={user?.plan} status={status} {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDropdown({ small }: { small?: boolean }) {
|
||||
@@ -376,7 +456,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 +464,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 +490,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portalled={true}>
|
||||
<DropdownMenuItem>
|
||||
@@ -424,7 +499,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 +518,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 +565,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
|
||||
|
||||
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 gap-3 md: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>
|
||||
);
|
||||
}
|
||||
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("");
|
||||
|
||||
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;
|
||||
@@ -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 { 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,42 @@ function BookingListItem(booking: BookingItem) {
|
||||
{
|
||||
id: "reschedule",
|
||||
label: t("reschedule"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
icon: ClockIcon,
|
||||
actions: [
|
||||
{
|
||||
id: "edit",
|
||||
label: t("edit_booking"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
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 +176,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 +194,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 +219,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
|
||||
|
||||
@@ -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,17 @@ import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent, sdkActionManager } from "@calcom/embed-core";
|
||||
import 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 { 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 +48,16 @@ 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");
|
||||
let isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
useExposePlanGlobally(plan);
|
||||
useEffect(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
const eventOwner = eventType.users[0];
|
||||
@@ -59,17 +69,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 +98,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) => {
|
||||
@@ -122,16 +147,24 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
<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")
|
||||
isEmbed
|
||||
? classNames("m-auto", selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
}>
|
||||
{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-sm 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 className="block items-center sm:flex sm:space-x-4">
|
||||
<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 +172,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 +180,23 @@ 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">
|
||||
<div className="mt-4 sm:-mt-2">
|
||||
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
||||
<div className="mt-2 flex gap-2 text-xl font-medium dark:text-gray-100">
|
||||
{eventType.title}
|
||||
{eventType?.description && (
|
||||
<p className="mb-2 text-gray-600 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
<ClockIcon className="mr-[10px] -mt-1 inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</div>
|
||||
{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}
|
||||
@@ -170,17 +209,16 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
</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 +227,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 +235,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="mb-2 text-gray-600 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
||||
<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,8 +262,6 @@ 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}` && (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<ArrowLeftIcon
|
||||
@@ -229,6 +271,17 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
|
||||
<p className="sr-only">Go Back</p>
|
||||
</div>
|
||||
)}
|
||||
{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] -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}
|
||||
@@ -253,6 +306,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 +319,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 +328,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,10 @@
|
||||
import { CalendarIcon, ClockIcon, CreditCardIcon, ExclamationIcon } from "@heroicons/react/solid";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs from "dayjs";
|
||||
@@ -6,37 +12,41 @@ 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 { useIsEmbed, useIsBackgroundTransparent } 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 { 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;
|
||||
|
||||
@@ -52,11 +62,20 @@ type BookingFormValues = {
|
||||
};
|
||||
};
|
||||
|
||||
const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
const BookingPage = ({
|
||||
eventType,
|
||||
booking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
locationLabels,
|
||||
}: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
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 +115,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 +135,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 +151,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,7 +182,8 @@ 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 || "",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -199,23 +213,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 +259,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,
|
||||
@@ -262,6 +275,8 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const disableInput = !!rescheduleUid;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Theme />
|
||||
@@ -281,13 +296,23 @@ 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(
|
||||
isEmbed ? "mx-auto" : "mx-auto my-0 rounded-sm sm:my-24",
|
||||
"max-w-3xl sm:border sm:dark:border-gray-600"
|
||||
)}>
|
||||
{isReady && (
|
||||
<div className="overflow-hidden border border-gray-200 bg-white dark:border-0 dark:bg-neutral-900 sm:rounded-sm">
|
||||
<div
|
||||
className={classNames(
|
||||
"overflow-hidden",
|
||||
isEmbed ? "" : "border border-gray-200",
|
||||
isBackgroundTransparent ? "" : "bg-white dark:border-0 dark:bg-gray-800",
|
||||
"sm:rounded-sm"
|
||||
)}>
|
||||
<div className="px-4 py-5 sm:flex sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
|
||||
<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 +323,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,16 +351,26 @@ 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">
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
@@ -342,8 +385,12 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white 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 +404,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>
|
||||
@@ -375,6 +427,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
|
||||
{...bookingForm.register("locationType", { required: true })}
|
||||
value={location.type}
|
||||
defaultChecked={selectedLocation === location.type}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-gray-500">
|
||||
{locationLabels[location.type]}
|
||||
@@ -391,13 +444,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 +473,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 +488,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 +500,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 +542,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 +598,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 +630,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,12 +56,23 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
: undefined;
|
||||
const pageSlug = router.query.eventPage || props.options[0].slug;
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
const title: string =
|
||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
||||
const length: number =
|
||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
||||
const description: string =
|
||||
typeof router.query.description === "string" && router.query.description ? router.query.description : "";
|
||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
||||
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
||||
|
||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||
resolver: zodResolver(createEventTypeInput),
|
||||
defaultValues: { length: 15 },
|
||||
});
|
||||
const { setValue, watch, register } = form;
|
||||
setValue("title", title);
|
||||
setValue("length", length);
|
||||
setValue("description", description);
|
||||
setValue("slug", slug);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name, type }) => {
|
||||
@@ -113,7 +124,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 +191,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 +209,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 +234,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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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("");
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 @@ 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"
|
||||
/>
|
||||
</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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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-scroll": 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>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +1,83 @@
|
||||
import { DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import { ChevronDownIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import React, { FC } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@calcom/ui/Dropdown";
|
||||
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
import Dropdown, {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@components/ui/Dropdown";
|
||||
|
||||
import Button from "./Button";
|
||||
|
||||
export type ActionType = {
|
||||
id: string;
|
||||
icon: SVGComponent;
|
||||
icon?: SVGComponent;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
color?: "primary" | "secondary";
|
||||
} & ({ href?: never; onClick: () => any } | { href: string; onClick?: never });
|
||||
} & ({ href?: never; onClick: () => any } | { href?: string; onClick?: never }) & {
|
||||
actions?: ActionType[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
actions: ActionType[];
|
||||
}
|
||||
|
||||
const DropdownActions = ({ actions, actionTrigger }: { actions: ActionType[]; actionTrigger?: any }) => {
|
||||
return (
|
||||
<Dropdown>
|
||||
{!actionTrigger ? (
|
||||
<DropdownMenuTrigger className="h-[38px] w-[38px] cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900">
|
||||
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
) : (
|
||||
<DropdownMenuTrigger asChild>{actionTrigger}</DropdownMenuTrigger>
|
||||
)}
|
||||
<DropdownMenuContent portalled>
|
||||
{actions.map((action) => (
|
||||
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="minimal"
|
||||
className="w-full rounded-none font-normal"
|
||||
href={action.href}
|
||||
StartIcon={action.icon}
|
||||
onClick={action.onClick}
|
||||
data-testid={action.id}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const TableActions: FC<Props> = ({ actions }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<div className="hidden space-x-2 rtl:space-x-reverse lg:block">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
data-testid={action.id}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
StartIcon={action.icon}
|
||||
disabled={action.disabled}
|
||||
color={action.color || "secondary"}>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
{actions.map((action) => {
|
||||
const button = (
|
||||
<Button
|
||||
key={action.id}
|
||||
data-testid={action.id}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
StartIcon={action.icon}
|
||||
{...(action?.actions ? { EndIcon: ChevronDownIcon } : null)}
|
||||
disabled={action.disabled}
|
||||
color={action.color || "secondary"}>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
if (!action.actions) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return <DropdownActions key={action.id} actions={action.actions} actionTrigger={button} />;
|
||||
})}
|
||||
</div>
|
||||
<div className="inline-block text-left lg:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="h-[38px] w-[38px] cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900">
|
||||
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portalled>
|
||||
{actions.map((action) => (
|
||||
<DropdownMenuItem key={action.id}>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full font-normal"
|
||||
href={action.href}
|
||||
StartIcon={action.icon}
|
||||
onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
<DropdownActions actions={actions} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,10 +4,7 @@ import React from "react";
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Largetitle: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames(
|
||||
"font-cal tracking-wider text-3xl text-gray-900 dark:text-white mb-2",
|
||||
props?.className
|
||||
);
|
||||
const classes = classnames("font-cal tracking-wider text-3xl mb-2", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @deprecated create new a new set of components, waiting for designs
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
import Body from "./Body";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert } from "../Alert";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
||||
/**
|
||||
* @deprecated use `<Alert severity="error" message="x" />` instead
|
||||
|
||||
@@ -87,7 +87,7 @@ const ColorPicker = (props: ColorPickerProps) => {
|
||||
</div>
|
||||
)}
|
||||
<HexColorInput
|
||||
className="ml-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="ml-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
color={color}
|
||||
onChange={(val) => {
|
||||
setColor(val);
|
||||
|
||||
@@ -16,7 +16,7 @@ export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
||||
return (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
"focus:border-primary-500 focus:ring-primary-500 rounded-sm border border-gray-300 p-1 pl-2 shadow-sm sm:text-sm",
|
||||
"focus:ring-primary-500 focus:border-primary-500 rounded-sm border border-gray-300 p-1 pl-2 shadow-sm sm:text-sm",
|
||||
className
|
||||
)}
|
||||
clearIcon={null}
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
export const DateRangePicker = ({ startDate, endDate, onDatesChange }: Props) => {
|
||||
return (
|
||||
<PrimitiveDateRangePicker
|
||||
className="focus:border-primary-500 focus:ring-primary-500 rounded-sm border-gray-300 sm:text-sm"
|
||||
className="rounded-sm border-gray-300 sm:text-sm"
|
||||
clearIcon={null}
|
||||
calendarIcon={<CalendarIcon className="h-5 w-5 text-gray-500" />}
|
||||
rangeDivider={<ArrowRightIcon className="h-4 w-4 text-gray-400 ltr:mr-2 rtl:ml-2" />}
|
||||
|
||||
@@ -22,7 +22,7 @@ const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, re
|
||||
ref={ref}
|
||||
type="number"
|
||||
className={classNames(
|
||||
"focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 pl-2 pr-12 sm:text-sm",
|
||||
"block w-full rounded-sm border-gray-300 pl-2 pr-12 sm:text-sm",
|
||||
rest.className
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import React from "react";
|
||||
import { Control } from "react-hook-form";
|
||||
import BasePhoneInput, { Props } from "react-phone-number-input/react-hook-form";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
type PhoneInputProps = {
|
||||
value: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
};
|
||||
export type PhoneInputProps<FormValues> = Props<
|
||||
{
|
||||
value: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
},
|
||||
FormValues
|
||||
>;
|
||||
|
||||
export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) => (
|
||||
<BasePhoneInput
|
||||
{...rest}
|
||||
name={name}
|
||||
control={control}
|
||||
className={classNames(
|
||||
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
|
||||
)}
|
||||
onChange={() => {
|
||||
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
|
||||
}}
|
||||
/>
|
||||
);
|
||||
function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<FormValues>) {
|
||||
return (
|
||||
<BasePhoneInput
|
||||
{...rest}
|
||||
name={name}
|
||||
control={control}
|
||||
className={classNames(
|
||||
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white",
|
||||
rest.disabled ? "bg-gray-200 dark:text-gray-500" : ""
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PhoneInput;
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import dayjs, { Dayjs, ConfigType } from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Controller, useFieldArray } from "react-hook-form";
|
||||
|
||||
import { defaultDayRange } from "@lib/availability";
|
||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { TimeRange } from "@lib/types/schedule";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import Button from "@components/ui/Button";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/** Begin Time Increments For Select */
|
||||
const increment = 15;
|
||||
/**
|
||||
* 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 TIMES = (() => {
|
||||
const end = dayjs().utc().endOf("day");
|
||||
let t: Dayjs = dayjs().utc().startOf("day");
|
||||
|
||||
const times: Dayjs[] = [];
|
||||
while (t.isBefore(end)) {
|
||||
times.push(t);
|
||||
t = t.add(increment, "minutes");
|
||||
}
|
||||
return times;
|
||||
})();
|
||||
/** End Time Increments For Select */
|
||||
|
||||
type Option = {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
};
|
||||
|
||||
type TimeRangeFieldProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
const [selected, setSelected] = useState<number | undefined>();
|
||||
// const { i18n } = useLocale();
|
||||
|
||||
const handleSelected = (value: number | undefined) => {
|
||||
setSelected(value);
|
||||
};
|
||||
|
||||
const getOption = (time: ConfigType) => ({
|
||||
value: dayjs(time).toDate().valueOf(),
|
||||
label: dayjs(time)
|
||||
.utc()
|
||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm"),
|
||||
// .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }),
|
||||
});
|
||||
|
||||
const timeOptions = useCallback(
|
||||
(offsetOrLimitorSelected: { offset?: number; limit?: number; selected?: number } = {}) => {
|
||||
const { limit, offset, selected } = offsetOrLimitorSelected;
|
||||
return TIMES.filter(
|
||||
(time) =>
|
||||
(!limit || time.isBefore(limit)) &&
|
||||
(!offset || time.isAfter(offset)) &&
|
||||
(!selected || time.isAfter(selected))
|
||||
).map((t) => getOption(t));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
name={`${name}.start`}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
handleSelected(value);
|
||||
return (
|
||||
<Select
|
||||
className="w-30"
|
||||
options={options}
|
||||
onFocus={() => setOptions(timeOptions())}
|
||||
onBlur={() => setOptions([])}
|
||||
defaultValue={getOption(value)}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
handleSelected(option?.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span>-</span>
|
||||
<Controller
|
||||
name={`${name}.end`}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
className="w-30"
|
||||
options={options}
|
||||
onFocus={() => setOptions(timeOptions({ selected }))}
|
||||
onBlur={() => setOptions([])}
|
||||
defaultValue={getOption(value)}
|
||||
onChange={(option) => onChange(new Date(option?.value as number))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ScheduleBlockProps = {
|
||||
day: number;
|
||||
weekday: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
||||
const { t } = useLocale();
|
||||
const { fields, append, remove, replace } = useFieldArray({
|
||||
name: `${name}.${day}`,
|
||||
});
|
||||
|
||||
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 (
|
||||
<fieldset className="flex min-h-[86px] flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
|
||||
<div className="w-1/3">
|
||||
<label className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.length > 0}
|
||||
onChange={(e) => (e.target.checked ? replace([defaultDayRange]) : replace([]))}
|
||||
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
|
||||
/>
|
||||
<span className="inline-block text-sm capitalize">{weekday}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="mb-1 flex justify-between">
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<TimeRangeField name={`${name}.${day}.${index}`} />
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={TrashIcon}
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<span className="block text-sm text-gray-500">{!fields.length && t("no_availability")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
className={fields.length > 0 ? "visible" : "invisible"}
|
||||
StartIcon={PlusIcon}
|
||||
onClick={handleAppend}
|
||||
/>
|
||||
</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;
|
||||
@@ -1,13 +1,32 @@
|
||||
import React from "react";
|
||||
import ReactSelect, { components, GroupBase, Props } from "react-select";
|
||||
import ReactSelect, { components, GroupBase, Props, InputProps } from "react-select";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export type SelectProps<
|
||||
Option,
|
||||
IsMulti extends boolean = false,
|
||||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
> = Props<Option, IsMulti, Group>;
|
||||
|
||||
export const InputComponent = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>({
|
||||
inputClassName,
|
||||
...props
|
||||
}: InputProps<Option, IsMulti, Group>) => {
|
||||
return (
|
||||
<components.Input
|
||||
// disables our default form focus hightlight on the react-select input element
|
||||
inputClassName={classNames("focus:ring-0 focus:ring-offset-0", inputClassName)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function Select<
|
||||
Option,
|
||||
IsMulti extends boolean = false,
|
||||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
>({ className, ...props }: Props<Option, IsMulti, Group>) {
|
||||
>({ className, ...props }: SelectProps<Option, IsMulti, Group>) {
|
||||
return (
|
||||
<ReactSelect
|
||||
theme={(theme) => ({
|
||||
@@ -15,16 +34,28 @@ function Select<
|
||||
borderRadius: 2,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary: "rgba(17, 17, 17, var(--tw-bg-opacity))",
|
||||
primary50: "rgba(17, 17, 17, var(--tw-bg-opacity))",
|
||||
primary: "var(--brand-color)",
|
||||
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
},
|
||||
})}
|
||||
styles={{
|
||||
option: (provided, state) => ({
|
||||
...provided,
|
||||
color: state.isSelected ? "var(--brand-text-color)" : "black",
|
||||
":active": {
|
||||
backgroundColor: state.isSelected ? "" : "var(--brand-color)",
|
||||
color: "var(--brand-text-color)",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
components={{
|
||||
...components,
|
||||
IndicatorSeparator: () => null,
|
||||
Input: InputComponent,
|
||||
}}
|
||||
className={classNames("focus:border-primary-500 text-sm shadow-sm", className)}
|
||||
className={classNames("text-sm shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
43
apps/web/components/ui/form/TimezoneSelect.tsx
Normal file
43
apps/web/components/ui/form/TimezoneSelect.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import classNames from "classnames";
|
||||
import { components } from "react-select";
|
||||
import BaseSelect, { ITimezone, Props as SelectProps } from "react-timezone-select";
|
||||
|
||||
import { InputComponent } from "@components/ui/form/Select";
|
||||
|
||||
function TimezoneSelect({ className, ...props }: SelectProps) {
|
||||
return (
|
||||
<BaseSelect
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 2,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary: "var(--brand-color)",
|
||||
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
},
|
||||
})}
|
||||
styles={{
|
||||
option: (provided, state) => ({
|
||||
...provided,
|
||||
color: state.isSelected ? "var(--brand-text-color)" : "black",
|
||||
":active": {
|
||||
backgroundColor: state.isSelected ? "" : "var(--brand-color)",
|
||||
color: "var(--brand-text-color)",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
components={{
|
||||
...components,
|
||||
IndicatorSeparator: () => null,
|
||||
Input: InputComponent,
|
||||
}}
|
||||
className={classNames("text-sm shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimezoneSelect;
|
||||
export type { ITimezone };
|
||||
@@ -1,238 +0,0 @@
|
||||
import { ClockIcon } from "@heroicons/react/outline";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
interface SetTimesModalProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
onChange: (times: { startTime: number; endTime: number }) => void;
|
||||
onExit: (...p: unknown[]) => void;
|
||||
}
|
||||
|
||||
export default function SetTimesModal(props: SetTimesModalProps) {
|
||||
const { t } = useLocale();
|
||||
const [startHours, startMinutes] = [Math.floor(props.startTime / 60), props.startTime % 60];
|
||||
const [endHours, endMinutes] = [Math.floor(props.endTime / 60), props.endTime % 60];
|
||||
const startHoursRef = useRef<HTMLInputElement>(null!);
|
||||
const startMinsRef = useRef<HTMLInputElement>(null!);
|
||||
const endHoursRef = useRef<HTMLInputElement>(null!);
|
||||
const endMinsRef = useRef<HTMLInputElement>(null!);
|
||||
const [endMinuteDisable, setEndMinuteDisable] = useState(false);
|
||||
const [maximumStartTime, setMaximumStartTime] = useState({ hour: endHours, minute: 59 });
|
||||
const [minimumEndTime, setMinimumEndTime] = useState({ hour: startHours, minute: 59 });
|
||||
|
||||
const STEP = 15;
|
||||
|
||||
const isValidTime = (startTime: number, endTime: number) => {
|
||||
if (new Date(startTime) > new Date(endTime)) {
|
||||
showToast(t("error_end_time_before_start_time"), "error");
|
||||
return false;
|
||||
}
|
||||
if (endTime > 1440) {
|
||||
showToast(t("error_end_time_next_day"), "error");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// compute dynamic range for minimum and maximum allowed hours/minutes.
|
||||
const setEdgeTimes = (
|
||||
(step) =>
|
||||
(
|
||||
startHoursRef: React.MutableRefObject<HTMLInputElement>,
|
||||
startMinsRef: React.MutableRefObject<HTMLInputElement>,
|
||||
endHoursRef: React.MutableRefObject<HTMLInputElement>,
|
||||
endMinsRef: React.MutableRefObject<HTMLInputElement>
|
||||
) => {
|
||||
//parse all the refs
|
||||
const startHour = parseInt(startHoursRef.current.value);
|
||||
let startMinute = parseInt(startMinsRef.current.value);
|
||||
const endHour = parseInt(endHoursRef.current.value);
|
||||
let endMinute = parseInt(endMinsRef.current.value);
|
||||
|
||||
//convert to dayjs object
|
||||
const startTime = dayjs(`${startHour}:${startMinute}`, "hh:mm");
|
||||
const endTime = dayjs(`${endHour}:${endMinute}`, "hh:mm");
|
||||
|
||||
//compute minimin and maximum allowed
|
||||
const maximumStartTime = endTime.subtract(step, "minute");
|
||||
const maximumStartHour = maximumStartTime.hour();
|
||||
const maximumStartMinute = startHour === endHour ? maximumStartTime.minute() : 59;
|
||||
|
||||
const minimumEndTime = startTime.add(step, "minute");
|
||||
const minimumEndHour = minimumEndTime.hour();
|
||||
const minimumEndMinute = startHour === endHour ? minimumEndTime.minute() : 0;
|
||||
|
||||
//check allow min/max minutes when the end/start hour matches
|
||||
if (startHoursRef.current.value === endHoursRef.current.value) {
|
||||
if (parseInt(startMinsRef.current.value) >= maximumStartMinute)
|
||||
startMinsRef.current.value = maximumStartMinute.toString();
|
||||
if (parseInt(endMinsRef.current.value) <= minimumEndMinute)
|
||||
endMinsRef.current.value = minimumEndMinute.toString();
|
||||
}
|
||||
|
||||
//save into state
|
||||
setMaximumStartTime({ hour: maximumStartHour, minute: maximumStartMinute });
|
||||
setMinimumEndTime({ hour: minimumEndHour, minute: minimumEndMinute });
|
||||
}
|
||||
)(STEP);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
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 overflow-hidden 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="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ClockIcon className="h-6 w-6 text-black" />
|
||||
</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("change_bookings_availability")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("set_work_schedule")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 flex">
|
||||
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("start_time")}</label>
|
||||
<div className="w-1/6">
|
||||
<label htmlFor="startHours" className="sr-only">
|
||||
{t("hours")}
|
||||
</label>
|
||||
<input
|
||||
ref={startHoursRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max={maximumStartTime.hour}
|
||||
minLength={2}
|
||||
name="hours"
|
||||
id="startHours"
|
||||
className="focus:border-brand block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
placeholder="9"
|
||||
defaultValue={startHours}
|
||||
onChange={() => setEdgeTimes(startHoursRef, startMinsRef, endHoursRef, endMinsRef)}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<div className="w-1/6">
|
||||
<label htmlFor="startMinutes" className="sr-only">
|
||||
{t("minutes")}
|
||||
</label>
|
||||
<input
|
||||
ref={startMinsRef}
|
||||
type="number"
|
||||
min="0"
|
||||
max={maximumStartTime.minute}
|
||||
step={STEP}
|
||||
maxLength={2}
|
||||
name="minutes"
|
||||
id="startMinutes"
|
||||
className="focus:border-brand block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
placeholder="30"
|
||||
defaultValue={startMinutes}
|
||||
onChange={() => setEdgeTimes(startHoursRef, startMinsRef, endHoursRef, endMinsRef)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("end_time")}</label>
|
||||
<div className="w-1/6">
|
||||
<label htmlFor="endHours" className="sr-only">
|
||||
{t("hours")}
|
||||
</label>
|
||||
<input
|
||||
ref={endHoursRef}
|
||||
type="number"
|
||||
min={minimumEndTime.hour}
|
||||
max="24"
|
||||
maxLength={2}
|
||||
name="hours"
|
||||
id="endHours"
|
||||
className="focus:border-brand block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
placeholder="17"
|
||||
defaultValue={endHours}
|
||||
onChange={(e) => {
|
||||
if (endHoursRef.current.value === "24") endMinsRef.current.value = "0";
|
||||
setEdgeTimes(startHoursRef, startMinsRef, endHoursRef, endMinsRef);
|
||||
setEndMinuteDisable(endHoursRef.current.value === "24");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<div className="w-1/6">
|
||||
<label htmlFor="endMinutes" className="sr-only">
|
||||
{t("minutes")}
|
||||
</label>
|
||||
<input
|
||||
ref={endMinsRef}
|
||||
type="number"
|
||||
min={minimumEndTime.minute}
|
||||
max="59"
|
||||
maxLength={2}
|
||||
step={STEP}
|
||||
name="minutes"
|
||||
id="endMinutes"
|
||||
className="focus:border-brand block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
placeholder="30"
|
||||
defaultValue={endMinutes}
|
||||
disabled={endMinuteDisable}
|
||||
onChange={() => setEdgeTimes(startHoursRef, startMinsRef, endHoursRef, endMinsRef)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const enteredStartHours = parseInt(startHoursRef.current.value);
|
||||
const enteredStartMins = parseInt(startMinsRef.current.value);
|
||||
const enteredEndHours = parseInt(endHoursRef.current.value);
|
||||
const enteredEndMins = parseInt(endMinsRef.current.value);
|
||||
|
||||
if (
|
||||
isValidTime(
|
||||
enteredStartHours * 60 + enteredStartMins,
|
||||
enteredEndHours * 60 + enteredEndMins
|
||||
)
|
||||
) {
|
||||
props.onChange({
|
||||
startTime: enteredStartHours * 60 + enteredStartMins,
|
||||
endTime: enteredEndHours * 60 + enteredEndMins,
|
||||
});
|
||||
props.onExit(0);
|
||||
}
|
||||
}}
|
||||
type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button onClick={props.onExit} type="button" color="secondary" className="ltr:mr-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function UserCalendarIllustration() {
|
||||
return (
|
||||
<svg
|
||||
className="mx-auto mb-4 block w-1/2 md:w-32"
|
||||
viewBox="0 0 132 132"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
x="1.48387"
|
||||
y="1.48387"
|
||||
width="129.032"
|
||||
height="129.032"
|
||||
rx="64.5161"
|
||||
fill="white"
|
||||
stroke="white"
|
||||
strokeWidth="1.03226"
|
||||
/>
|
||||
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="2" y="2" width="128" height="128">
|
||||
<rect x="2" y="2" width="128" height="128" rx="64" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<rect x="56.1936" y="40.1936" width="20.129" height="2.06452" rx="0.516129" fill="#708097" />
|
||||
<rect x="47.9355" y="44.8387" width="36.6452" height="2.06452" rx="0.516129" fill="#C6CCD5" />
|
||||
<g filter="url(#filter0_dd)">
|
||||
<rect width="115.84" height="83.2303" transform="translate(8.07983 53.52)" fill="#F7F8F9" />
|
||||
<path
|
||||
d="M15.7699 61.589V63.5013H16.1023V62.1847H16.1201L16.6486 63.4957H16.8969L17.4254 62.1875H17.4432V63.5013H17.7756V61.589H17.3517L16.7839 62.9747H16.7615L16.1938 61.589H15.7699ZM19.993 62.5451C19.993 61.927 19.6158 61.5628 19.1144 61.5628C18.612 61.5628 18.2357 61.927 18.2357 62.5451C18.2357 63.1623 18.612 63.5274 19.1144 63.5274C19.6158 63.5274 19.993 63.1633 19.993 62.5451ZM19.6447 62.5451C19.6447 62.9803 19.4262 63.2165 19.1144 63.2165C18.8034 63.2165 18.584 62.9803 18.584 62.5451C18.584 62.11 18.8034 61.8738 19.1144 61.8738C19.4262 61.8738 19.6447 62.11 19.6447 62.5451Z"
|
||||
fill="#657388"
|
||||
/>
|
||||
<path
|
||||
d="M32.1112 61.8794H32.7022V63.5013H33.0459V61.8794H33.6369V61.589H32.1112V61.8794ZM35.268 61.589V62.8094C35.268 63.0494 35.1008 63.2212 34.8385 63.2212C34.5751 63.2212 34.4089 63.0494 34.4089 62.8094V61.589H34.0625V62.8383C34.0625 63.2492 34.3706 63.5302 34.8385 63.5302C35.3044 63.5302 35.6144 63.2492 35.6144 62.8383V61.589H35.268Z"
|
||||
fill="#657388"
|
||||
/>
|
||||
<path
|
||||
d="M48.3554 63.5013H48.6971L49.0809 62.1595H49.0958L49.4786 63.5013H49.8204L50.3601 61.589H49.9875L49.643 62.9952H49.6262L49.2573 61.589H48.9184L48.5505 62.9943H48.5328L48.1882 61.589H47.8157L48.3554 63.5013ZM50.7318 63.5013H51.983V63.2109H51.0782V62.6889H51.9111V62.3985H51.0782V61.8794H51.9755V61.589H50.7318V63.5013Z"
|
||||
fill="#657388"
|
||||
/>
|
||||
<path
|
||||
d="M64.1925 61.8794H64.7836V63.5013H65.1272V61.8794H65.7183V61.589H64.1925V61.8794ZM66.1439 63.5013H66.4903V62.6889H67.3764V63.5013H67.7237V61.589H67.3764V62.3985H66.4903V61.589H66.1439V63.5013Z"
|
||||
fill="#657388"
|
||||
/>
|
||||
<path
|
||||
d="M80.545 63.5013H80.8914V62.6889H81.686V62.3985H80.8914V61.8794H81.7701V61.589H80.545V63.5013ZM82.2171 63.5013H82.5636V62.801H82.9165L83.2919 63.5013H83.6784L83.2648 62.7431C83.4898 62.6525 83.6084 62.4602 83.6084 62.2006C83.6084 61.8355 83.3731 61.589 82.9342 61.589H82.2171V63.5013ZM82.5636 62.5134V61.8784H82.881C83.1397 61.8784 83.2555 61.997 83.2555 62.2006C83.2555 62.4041 83.1397 62.5134 82.8829 62.5134H82.5636Z"
|
||||
fill="#657388"
|
||||
/>
|
||||
<path
|
||||
d="M97.4678 62.1147H97.8011C97.7946 61.7916 97.5191 61.5628 97.112 61.5628C96.7105 61.5628 96.4089 61.7888 96.4098 62.1268C96.4098 62.4013 96.605 62.5591 96.9197 62.6404L97.1372 62.6964C97.3436 62.7487 97.4799 62.8131 97.4808 62.9616C97.4799 63.125 97.3249 63.2342 97.0989 63.2342C96.8823 63.2342 96.7142 63.1371 96.7002 62.9364H96.3594C96.3734 63.3164 96.6563 63.5302 97.1017 63.5302C97.5602 63.5302 97.8263 63.3015 97.8273 62.9644C97.8263 62.6329 97.5527 62.4816 97.2651 62.4135L97.0859 62.3687C96.929 62.3313 96.7591 62.265 96.7609 62.1053C96.7619 61.9615 96.8907 61.856 97.1073 61.856C97.3137 61.856 97.45 61.9522 97.4678 62.1147ZM98.4823 63.5013L98.6401 63.0297H99.3591L99.5178 63.5013H99.8876L99.2134 61.589H98.7858L98.1126 63.5013H98.4823ZM98.7335 62.7515L98.9921 61.9812H99.0071L99.2657 62.7515H98.7335Z"
|
||||
fill="#657388"
|
||||
/>
|
||||
<path
|
||||
d="M113.487 62.1147H113.82C113.814 61.7916 113.538 61.5628 113.131 61.5628C112.73 61.5628 112.428 61.7888 112.429 62.1268C112.429 62.4013 112.624 62.5591 112.939 62.6404L113.157 62.6964C113.363 62.7487 113.499 62.8131 113.5 62.9616C113.499 63.125 113.344 63.2342 113.118 63.2342C112.902 63.2342 112.734 63.1371 112.72 62.9364H112.379C112.393 63.3164 112.676 63.5302 113.121 63.5302C113.58 63.5302 113.846 63.3015 113.847 62.9644C113.846 62.6329 113.572 62.4816 113.285 62.4135L113.105 62.3687C112.948 62.3313 112.778 62.265 112.78 62.1053C112.781 61.9615 112.91 61.856 113.127 61.856C113.333 61.856 113.469 61.9522 113.487 62.1147ZM115.492 61.589V62.8094C115.492 63.0494 115.325 63.2212 115.063 63.2212C114.8 63.2212 114.633 63.0494 114.633 62.8094V61.589H114.287V62.8383C114.287 63.2492 114.595 63.5302 115.063 63.5302C115.529 63.5302 115.839 63.2492 115.839 62.8383V61.589H115.492Z"
|
||||
fill="#657388"
|
||||
/>
|
||||
<rect x="9.83276" y="70.2902" width="112.334" height="0.516129" fill="white" />
|
||||
<path
|
||||
d="M66.3454 77.5155H66.0366L65.3992 77.9388V78.2525L66.0217 77.8392H66.0366V80.0652H66.3454V77.5155Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<path
|
||||
d="M81.2501 80.0652H82.8586V79.7913H81.6734V79.7714L82.2461 79.1588C82.6843 78.6895 82.8138 78.4704 82.8138 78.1877C82.8138 77.7943 82.4951 77.4806 82.0469 77.4806C81.6 77.4806 81.2601 77.7844 81.2601 78.2326H81.5539C81.5539 77.9425 81.7419 77.7495 82.0369 77.7495C82.3133 77.7495 82.525 77.9188 82.525 78.1877C82.525 78.4231 82.3868 78.5973 82.0917 78.9198L81.2501 79.8411V80.0652Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<path
|
||||
d="M98.1047 80.1C98.599 80.1 98.9662 79.79 98.9662 79.373C98.9662 79.0493 98.7745 78.814 98.4533 78.7604V78.7405C98.711 78.6621 98.8716 78.4504 98.8716 78.1628C98.8716 77.8018 98.5865 77.4806 98.1147 77.4806C97.6739 77.4806 97.3079 77.752 97.293 78.1529H97.5918C97.603 77.8989 97.8445 77.7495 98.1097 77.7495C98.3911 77.7495 98.5728 77.9201 98.5728 78.1778C98.5728 78.4467 98.3624 78.621 98.0599 78.621H97.8557V78.8949H98.0599C98.4471 78.8949 98.6625 79.0916 98.6625 79.373C98.6625 79.6431 98.4272 79.8261 98.0997 79.8261C97.8047 79.8261 97.5706 79.6743 97.5519 79.4278H97.2382C97.2569 79.8286 97.6105 80.1 98.1047 80.1Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<path
|
||||
d="M113.222 79.5423H114.423V80.0652H114.716V79.5423H115.065V79.2684H114.716V77.5155H114.343L113.222 79.2883V79.5423ZM114.423 79.2684H113.556V79.2485L114.403 77.9089H114.423V79.2684Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<path
|
||||
d="M17.8508 96.1477C18.3364 96.1477 18.6925 95.7892 18.6925 95.3011C18.6925 94.8069 18.3488 94.4446 17.8807 94.4446C17.7089 94.4446 17.5421 94.5056 17.4375 94.589H17.4226L17.5122 93.837H18.5779V93.5631H17.2533L17.0989 94.8181L17.3877 94.8529C17.4935 94.777 17.6741 94.7222 17.8309 94.7235C18.1559 94.7259 18.3937 94.9724 18.3937 95.3061C18.3937 95.6335 18.1646 95.8738 17.8508 95.8738C17.5894 95.8738 17.3815 95.7057 17.3578 95.4754H17.059C17.0777 95.8639 17.4126 96.1477 17.8508 96.1477Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<rect x="26.3188" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M33.9284 96.1478C34.4786 96.1516 34.8484 95.7731 34.8472 95.2689C34.8484 94.7871 34.5048 94.4385 34.0578 94.4385C33.7839 94.4385 33.5424 94.5717 33.4204 94.7908H33.403C33.4042 94.2542 33.6009 93.928 33.9545 93.928C34.1736 93.928 34.3218 94.055 34.3691 94.2505H34.8235C34.7687 93.8384 34.4363 93.5284 33.9545 93.5284C33.342 93.5284 32.9548 94.0388 32.9548 94.9103C32.9535 95.8453 33.4391 96.1453 33.9284 96.1478ZM33.9259 95.7743C33.6532 95.7743 33.454 95.549 33.4528 95.2826C33.4553 95.0149 33.6619 94.7908 33.9321 94.7908C34.2023 94.7908 34.4002 95.0049 34.399 95.2788C34.4002 95.5577 34.196 95.7743 33.9259 95.7743Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<circle cx="34.1386" cy="99.9637" r="0.657352" fill="#3B82F6" />
|
||||
<rect x="42.3665" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M49.2434 96.113H49.7228L50.8059 93.9579V93.5632H49.0691V93.9492H50.3278V93.9666L49.2434 96.113Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="58.4143" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M66.0013 96.1478C66.5528 96.1478 66.9475 95.8441 66.9487 95.4295C66.9475 95.1108 66.7122 94.8443 66.4159 94.7945V94.7771C66.6736 94.7198 66.8529 94.4883 66.8541 94.2119C66.8529 93.8197 66.4918 93.5284 66.0013 93.5284C65.5071 93.5284 65.146 93.8185 65.1473 94.2119C65.146 94.4883 65.3228 94.7198 65.5855 94.7771V94.7945C65.2842 94.8443 65.0514 95.1108 65.0526 95.4295C65.0514 95.8441 65.4448 96.1478 66.0013 96.1478ZM66.0013 95.7918C65.7125 95.7918 65.5257 95.6324 65.5282 95.3971C65.5257 95.1531 65.7262 94.98 66.0013 94.98C66.2727 94.98 66.4719 95.1543 66.4744 95.3971C66.4719 95.6324 66.2864 95.7918 66.0013 95.7918ZM66.0013 94.6302C65.7648 94.6302 65.5955 94.4771 65.5979 94.2555C65.5955 94.0363 65.7598 93.8894 66.0013 93.8894C66.2391 93.8894 66.4022 94.0363 66.4047 94.2555C66.4022 94.4783 66.2341 94.6302 66.0013 94.6302Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="74.4617" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M82.0226 93.5284C81.4698 93.5247 81.1026 93.9044 81.1026 94.4024C81.1038 94.8829 81.4462 95.2303 81.8931 95.2303C82.1683 95.2303 82.4073 95.0971 82.5306 94.878H82.548C82.5468 95.4233 82.3488 95.7482 81.9965 95.7482C81.7761 95.7482 81.628 95.6212 81.5819 95.4183H81.1275C81.1798 95.8403 81.5134 96.1478 81.9965 96.1478C82.6078 96.1478 82.9974 95.6374 82.9962 94.7597C82.995 93.8309 82.5119 93.5309 82.0226 93.5284ZM82.0239 93.9019C82.2965 93.9019 82.497 94.1285 82.497 94.3887C82.4982 94.6526 82.2878 94.8792 82.0189 94.8792C81.7475 94.8792 81.552 94.6651 81.5508 94.3924C81.5508 94.1185 81.7537 93.9019 82.0239 93.9019Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="90.5098" y="87.2924" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M97.3476 93.5632H96.9081L96.2744 93.9704V94.3937L96.8708 94.0127H96.8857V96.113H97.3476V93.5632ZM98.9375 96.1615C99.5525 96.1628 99.9197 95.6772 99.9197 94.8406C99.9197 94.009 99.55 93.5284 98.9375 93.5284C98.3249 93.5284 97.9564 94.0077 97.9552 94.8406C97.9552 95.676 98.3224 96.1615 98.9375 96.1615ZM98.9375 95.7719C98.62 95.7719 98.4208 95.4531 98.422 94.8406C98.4233 94.233 98.6212 93.9131 98.9375 93.9131C99.2549 93.9131 99.4529 94.233 99.4541 94.8406C99.4541 95.4531 99.2562 95.7719 98.9375 95.7719Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<path
|
||||
d="M113.674 93.5632H113.365L112.727 93.9865V94.3003L113.35 93.8869H113.365V96.113H113.674V93.5632ZM115.303 93.5632H114.995L114.357 93.9865V94.3003L114.98 93.8869H114.995V96.113H115.303V93.5632Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<rect x="10.2712" y="103.34" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M17.1895 109.611H16.7501L16.1164 110.018V110.441L16.7127 110.06H16.7276V112.161H17.1895V109.611ZM17.8369 112.161H19.5849V111.775H18.4744V111.757L18.9138 111.31C19.4093 110.835 19.5463 110.603 19.5463 110.316C19.5463 109.889 19.1989 109.576 18.686 109.576C18.1805 109.576 17.822 109.89 17.822 110.374H18.2615C18.2615 110.114 18.4258 109.951 18.6798 109.951C18.9226 109.951 19.1031 110.099 19.1031 110.339C19.1031 110.552 18.9736 110.704 18.7221 110.959L17.8369 111.827V112.161Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="26.3188" y="103.34" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M33.1843 109.611H32.7448L32.1111 110.018V110.441L32.7075 110.06H32.7224V112.161H33.1843V109.611ZM34.7468 112.196C35.2921 112.196 35.6892 111.883 35.688 111.452C35.6892 111.134 35.49 110.905 35.1327 110.853V110.834C35.4091 110.774 35.5946 110.568 35.5934 110.282C35.5946 109.894 35.2634 109.576 34.7542 109.576C34.2587 109.576 33.8753 109.871 33.8653 110.298H34.3098C34.3173 110.084 34.5165 109.951 34.7518 109.951C34.9895 109.951 35.1477 110.095 35.1464 110.309C35.1477 110.532 34.9634 110.68 34.6995 110.68H34.4741V111.036H34.6995C35.0219 111.036 35.2136 111.198 35.2124 111.429C35.2136 111.654 35.0182 111.808 34.7455 111.808C34.4891 111.808 34.2911 111.675 34.2799 111.467H33.8118C33.8242 111.898 34.2089 112.196 34.7468 112.196Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="42.3665" y="103.34" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M49.2063 109.611H48.7668L48.1331 110.018V110.441L48.7294 110.06H48.7444V112.161H49.2063V109.611ZM49.8076 111.688H51.0239V112.161H51.4647V111.688H51.7908V111.308H51.4647V109.611H50.8895L49.8076 111.32V111.688ZM51.0289 111.308H50.2807V111.288L51.009 110.134H51.0289V111.308Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<path
|
||||
d="M65.2789 109.611H64.9702L64.3327 110.034V110.348L64.9552 109.935H64.9702V112.161H65.2789V109.611ZM66.809 112.196C67.2945 112.196 67.6506 111.837 67.6506 111.349C67.6506 110.855 67.307 110.492 66.8389 110.492C66.6671 110.492 66.5002 110.553 66.3957 110.637H66.3807L66.4704 109.885H67.5361V109.611H66.2114L66.057 110.866L66.3459 110.901C66.4517 110.825 66.6322 110.77 66.7891 110.771C67.114 110.774 67.3518 111.02 67.3518 111.354C67.3518 111.681 67.1227 111.922 66.809 111.922C66.5476 111.922 66.3396 111.754 66.316 111.523H66.0172C66.0359 111.912 66.3708 112.196 66.809 112.196Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<rect x="74.4617" y="103.34" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M81.3323 109.611H80.8928L80.2591 110.018V110.441L80.8554 110.06H80.8704V112.161H81.3323V109.611ZM82.9134 112.196C83.4637 112.199 83.8335 111.821 83.8322 111.317C83.8335 110.835 83.4898 110.486 83.0429 110.486C82.769 110.486 82.5275 110.619 82.4055 110.839H82.388C82.3893 110.302 82.586 109.976 82.9396 109.976C83.1587 109.976 83.3068 110.103 83.3541 110.298H83.8086C83.7538 109.886 83.4214 109.576 82.9396 109.576C82.327 109.576 81.9398 110.087 81.9398 110.958C81.9386 111.893 82.4241 112.193 82.9134 112.196ZM82.9109 111.822C82.6383 111.822 82.4391 111.597 82.4378 111.33C82.4403 111.063 82.647 110.839 82.9171 110.839C83.1873 110.839 83.3853 111.053 83.384 111.327C83.3853 111.605 83.1811 111.822 82.9109 111.822Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<path
|
||||
d="M97.4394 109.611H97.1307L96.4932 110.034V110.348L97.1157 109.935H97.1307V112.161H97.4394V109.611ZM98.2524 112.161H98.5761L99.7115 109.9V109.611H98.0781V109.885H99.3928V109.905L98.2524 112.161Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<path
|
||||
d="M113.408 109.611H113.1L112.462 110.034V110.348L113.085 109.935H113.1V112.161H113.408V109.611ZM114.958 112.196C115.467 112.196 115.822 111.898 115.825 111.483C115.822 111.161 115.607 110.887 115.332 110.836V110.821C115.571 110.759 115.728 110.525 115.73 110.253C115.728 109.865 115.402 109.576 114.958 109.576C114.51 109.576 114.184 109.865 114.186 110.253C114.184 110.525 114.341 110.759 114.585 110.821V110.836C114.305 110.887 114.089 111.161 114.092 111.483C114.089 111.898 114.444 112.196 114.958 112.196ZM114.958 111.922C114.608 111.922 114.393 111.742 114.396 111.469C114.393 111.181 114.631 110.976 114.958 110.976C115.281 110.976 115.519 111.181 115.521 111.469C115.519 111.742 115.303 111.922 114.958 111.922ZM114.958 110.712C114.679 110.712 114.483 110.537 114.485 110.273C114.483 110.014 114.672 109.845 114.958 109.845C115.24 109.845 115.429 110.014 115.431 110.273C115.429 110.537 115.232 110.712 114.958 110.712Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<rect x="10.2712" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M17.1416 125.659H16.7021L16.0684 126.066V126.489L16.6648 126.108H16.6797V128.208H17.1416V125.659ZM18.6742 125.624C18.1214 125.62 17.7541 126 17.7541 126.498C17.7554 126.978 18.0978 127.326 18.5447 127.326C18.8198 127.326 19.0589 127.192 19.1821 126.973H19.1996C19.1983 127.519 19.0004 127.844 18.648 127.844C18.4277 127.844 18.2795 127.717 18.2335 127.514H17.779C17.8313 127.936 18.165 128.243 18.648 128.243C19.2593 128.243 19.649 127.733 19.6478 126.855C19.6465 125.926 19.1635 125.626 18.6742 125.624ZM18.6754 125.997C18.9481 125.997 19.1485 126.224 19.1485 126.484C19.1498 126.748 18.9394 126.975 18.6704 126.975C18.399 126.975 18.2036 126.76 18.2023 126.488C18.2023 126.214 18.4053 125.997 18.6754 125.997Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="26.3188" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M31.8734 128.208H33.6213V127.822H32.5108V127.805L32.9503 127.358C33.4458 126.882 33.5827 126.651 33.5827 126.363C33.5827 125.936 33.2354 125.624 32.7224 125.624C32.217 125.624 31.8584 125.937 31.8584 126.422H32.2979C32.2979 126.162 32.4622 125.998 32.7162 125.998C32.959 125.998 33.1395 126.147 33.1395 126.387C33.1395 126.6 33.01 126.752 32.7585 127.007L31.8734 127.875V128.208ZM34.9933 128.257C35.6083 128.258 35.9756 127.773 35.9756 126.936C35.9756 126.104 35.6058 125.624 34.9933 125.624C34.3808 125.624 34.0122 126.103 34.011 126.936C34.011 127.771 34.3783 128.257 34.9933 128.257ZM34.9933 127.867C34.6758 127.867 34.4766 127.548 34.4779 126.936C34.4791 126.328 34.6771 126.008 34.9933 126.008C35.3108 126.008 35.5087 126.328 35.51 126.936C35.51 127.548 35.312 127.867 34.9933 127.867Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="42.3665" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M48.2479 128.208H49.9959V127.822H48.8854V127.805L49.3248 127.358C49.8203 126.882 49.9573 126.651 49.9573 126.363C49.9573 125.936 49.6099 125.624 49.097 125.624C48.5915 125.624 48.233 125.937 48.233 126.422H48.6725C48.6725 126.162 48.8368 125.998 49.0908 125.998C49.3335 125.998 49.5141 126.147 49.5141 126.387C49.5141 126.6 49.3846 126.752 49.1331 127.007L48.2479 127.875V128.208ZM51.4625 125.659H51.023L50.3893 126.066V126.489L50.9856 126.108H51.0006V128.208H51.4625V125.659Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="58.4143" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M64.049 128.208H65.797V127.822H64.6865V127.805L65.1259 127.358C65.6214 126.882 65.7584 126.651 65.7584 126.363C65.7584 125.936 65.411 125.624 64.8981 125.624C64.3926 125.624 64.0341 125.937 64.0341 126.422H64.4736C64.4736 126.162 64.6379 125.998 64.8919 125.998C65.1347 125.998 65.3152 126.147 65.3152 126.387C65.3152 126.6 65.1857 126.752 64.9342 127.007L64.049 127.875V128.208ZM66.2265 128.208H67.9745V127.822H66.8639V127.805L67.3034 127.358C67.7989 126.882 67.9359 126.651 67.9359 126.363C67.9359 125.936 67.5885 125.624 67.0756 125.624C66.5701 125.624 66.2116 125.937 66.2116 126.422H66.651C66.651 126.162 66.8154 125.998 67.0694 125.998C67.3121 125.998 67.4927 126.147 67.4927 126.387C67.4927 126.6 67.3632 126.752 67.1117 127.007L66.2265 127.875V128.208Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<rect x="74.4617" y="119.388" width="15.1717" height="15.1717" fill="#DBEAFE" />
|
||||
<path
|
||||
d="M80.0436 128.208H81.7915V127.822H80.681V127.805L81.1205 127.358C81.616 126.882 81.7529 126.651 81.7529 126.363C81.7529 125.936 81.4056 125.624 80.8926 125.624C80.3872 125.624 80.0286 125.937 80.0286 126.422H80.4681C80.4681 126.162 80.6324 125.998 80.8864 125.998C81.1292 125.998 81.3097 126.147 81.3097 126.387C81.3097 126.6 81.1802 126.752 80.9287 127.007L80.0436 127.875V128.208ZM83.1361 128.243C83.6814 128.243 84.0786 127.931 84.0773 127.5C84.0786 127.181 83.8794 126.952 83.5221 126.901V126.881C83.7984 126.821 83.9839 126.616 83.9827 126.33C83.9839 125.941 83.6528 125.624 83.1436 125.624C82.6481 125.624 82.2646 125.919 82.2547 126.346H82.6991C82.7066 126.132 82.9058 125.998 83.1411 125.998C83.3789 125.998 83.537 126.143 83.5357 126.357C83.537 126.58 83.3527 126.728 83.0888 126.728H82.8635V127.084H83.0888C83.4112 127.084 83.603 127.246 83.6017 127.476C83.603 127.702 83.4075 127.856 83.1349 127.856C82.8784 127.856 82.6804 127.723 82.6692 127.515H82.2011C82.2136 127.946 82.5983 128.243 83.1361 128.243Z"
|
||||
fill="#3B82F6"
|
||||
/>
|
||||
<path
|
||||
d="M96.2007 128.208H97.8092V127.934H96.624V127.914L97.1967 127.302C97.6349 126.833 97.7644 126.613 97.7644 126.331C97.7644 125.937 97.4456 125.624 96.9975 125.624C96.5505 125.624 96.2106 125.928 96.2106 126.376H96.5044C96.5044 126.086 96.6924 125.893 96.9875 125.893C97.2639 125.893 97.4755 126.062 97.4755 126.331C97.4755 126.566 97.3373 126.74 97.0423 127.063L96.2007 127.984V128.208ZM98.2088 127.685H99.409V128.208H99.7028V127.685H100.051V127.412H99.7028V125.659H99.3293L98.2088 127.431V127.685ZM99.409 127.412H98.5425V127.392L99.3891 126.052H99.409V127.412Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
<path
|
||||
d="M112.279 128.208H113.888V127.934H112.702V127.914L113.275 127.302C113.713 126.833 113.843 126.613 113.843 126.331C113.843 125.937 113.524 125.624 113.076 125.624C112.629 125.624 112.289 125.928 112.289 126.376H112.583C112.583 126.086 112.771 125.893 113.066 125.893C113.342 125.893 113.554 126.062 113.554 126.331C113.554 126.566 113.416 126.74 113.121 127.063L112.279 127.984V128.208ZM115.199 128.243C115.684 128.243 116.04 127.885 116.04 127.397C116.04 126.902 115.697 126.54 115.228 126.54C115.057 126.54 114.89 126.601 114.785 126.684H114.77L114.86 125.932H115.926V125.659H114.601L114.447 126.914L114.735 126.948C114.841 126.872 115.022 126.818 115.179 126.819C115.504 126.821 115.741 127.068 115.741 127.402C115.741 127.729 115.512 127.969 115.199 127.969C114.937 127.969 114.729 127.801 114.706 127.571H114.407C114.425 127.959 114.76 128.243 115.199 128.243Z"
|
||||
fill="#9BA6B6"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g clipPath="url(#clip0)">
|
||||
<path
|
||||
d="M54.1289 22.6026C54.1289 16.0129 59.4709 10.671 66.0605 10.671C72.6502 10.671 77.9922 16.0129 77.9922 22.6026C77.9922 29.1923 72.6502 34.5342 66.0605 34.5342C59.4709 34.5342 54.1289 29.1923 54.1289 22.6026Z"
|
||||
fill="#F4F5F6"
|
||||
/>
|
||||
<path
|
||||
d="M77.7885 31.2806C77.9226 31.4509 77.9922 31.6625 77.9922 31.8793V33.5352C77.9922 34.0875 77.5445 34.5352 76.9922 34.5352H55.1289C54.5766 34.5352 54.1289 34.0875 54.1289 33.5352V31.889C54.1289 31.673 54.198 31.4621 54.3312 31.2921C55.6901 29.5582 57.4178 28.1462 59.3905 27.1595C61.4626 26.1232 63.7478 25.5845 66.0645 25.5865C70.8206 25.5865 75.0583 27.8133 77.7885 31.2806ZM70.0397 19.6197C70.0397 20.6745 69.6207 21.6861 68.8748 22.432C68.129 23.1779 67.1173 23.5969 66.0625 23.5969C65.0077 23.5969 63.9961 23.1779 63.2502 22.432C62.5043 21.6861 62.0853 20.6745 62.0853 19.6197C62.0853 18.5648 62.5043 17.5532 63.2502 16.8074C63.9961 16.0615 65.0077 15.6425 66.0625 15.6425C67.1173 15.6425 68.129 16.0615 68.8748 16.8074C69.6207 17.5532 70.0397 18.5648 70.0397 19.6197Z"
|
||||
fill="#708097"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_dd"
|
||||
x="5.49919"
|
||||
y="53.0038"
|
||||
width="121.001"
|
||||
height="88.3916"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB">
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feMorphology radius="0.516129" operator="erode" in="SourceAlpha" result="effect1_dropShadow" />
|
||||
<feOffset dy="1.03226" />
|
||||
<feGaussianBlur stdDeviation="1.03226" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feMorphology radius="0.516129" operator="erode" in="SourceAlpha" result="effect2_dropShadow" />
|
||||
<feOffset dy="2.06452" />
|
||||
<feGaussianBlur stdDeviation="1.54839" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" />
|
||||
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape" />
|
||||
</filter>
|
||||
<clipPath id="clip0">
|
||||
<path
|
||||
d="M54.1289 22.6026C54.1289 16.0129 59.4709 10.671 66.0605 10.671C72.6502 10.671 77.9922 16.0129 77.9922 22.6026C77.9922 29.1923 72.6502 34.5342 66.0605 34.5342C59.4709 34.5342 54.1289 29.1923 54.1289 22.6026Z"
|
||||
fill="white"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserCalendarIllustration;
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
|
||||
import customTemplate, { hasTemplateIntegration } from "@lib/webhooks/integrationTemplate";
|
||||
|
||||
import { DialogFooter } from "@components/Dialog";
|
||||
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
import { TWebhook } from "@components/webhook/WebhookListItem";
|
||||
import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
|
||||
|
||||
@@ -22,13 +22,6 @@ export default function WebhookDialogForm(props: {
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const handleSubscriberUrlChange = (e) => {
|
||||
form.setValue("subscriberUrl", e.target.value);
|
||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
||||
setUseCustomPayloadTemplate(true);
|
||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
||||
}
|
||||
};
|
||||
const {
|
||||
defaultValues = {
|
||||
id: "",
|
||||
@@ -88,7 +81,13 @@ export default function WebhookDialogForm(props: {
|
||||
{...form.register("subscriberUrl")}
|
||||
required
|
||||
type="url"
|
||||
onChange={handleSubscriberUrlChange}
|
||||
onChange={(e) => {
|
||||
form.setValue("subscriberUrl", e.target.value);
|
||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
||||
setUseCustomPayloadTemplate(true);
|
||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
|
||||
@@ -2,14 +2,15 @@ import classNames from "classnames";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogContent } from "@components/Dialog";
|
||||
import { List, ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
import Button from "@components/ui/Button";
|
||||
import WebhookDialogForm from "@components/webhook/WebhookDialogForm";
|
||||
import WebhookListItem, { TWebhook } from "@components/webhook/WebhookListItem";
|
||||
|
||||
@@ -42,7 +43,7 @@ export default function WebhookListContainer(props: WebhookListContainerType) {
|
||||
<ListItem className={classNames("flex-col")}>
|
||||
<div
|
||||
className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
|
||||
<Image width={40} height={40} src="/integrations/webhooks.svg" alt="Webhooks" />
|
||||
<Image width={40} height={40} src="/apps/webhooks.svg" alt="Webhooks" />
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3">Webhooks</ListItemTitle>
|
||||
<ListItemText component="p">{t("automation")}</ListItemText>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { ListItem } from "@components/List";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import { useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { InputGroupBox } from "@calcom/ui/form/fields";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { InputGroupBox } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function WebhookTestDisclosure() {
|
||||
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
|
||||
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SAML Registration with Identity Providers
|
||||
|
||||
This guide explains the settings you’d need to use to configure SAML with your Identity Provider. Once this is set up you should get an XML metadata file that should then be uploaded on your Cal.com self-hosted instance.
|
||||
This guide explains the settings you need to use to configure SAML with your Identity Provider. Once this is set up you should get an XML metadata file that should then be uploaded on your Cal.com self-hosted instance.
|
||||
|
||||
> **Note:** Please do not add a trailing slash at the end of the URLs. Create them exactly as shown below.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user