Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b896bee5c | ||
|
|
cc1985442e | ||
|
|
630f438b32 | ||
|
|
6fae01d45a | ||
|
|
7009373b11 | ||
|
|
c2f150dbab | ||
|
|
688541923b | ||
|
|
20402ac26c | ||
|
|
a54952b41a | ||
|
|
987756c4d4 | ||
|
|
3ee294ffc5 | ||
|
|
a638708871 | ||
|
|
3456cc5521 | ||
|
|
546f0c3881 | ||
|
|
7b18272d60 | ||
|
|
cf6efabdbb | ||
|
|
92d15a00f5 | ||
|
|
2f2b72dd54 | ||
|
|
8fd5d6b5b5 | ||
|
|
2078f12a85 | ||
|
|
77b8eb8a7f | ||
|
|
463787b607 | ||
|
|
1f4e7ca695 | ||
|
|
9eb52bbfb2 | ||
|
|
61e6f76bc6 | ||
|
|
2e6c3a6068 | ||
|
|
8f48ecafec | ||
|
|
8849d1148c | ||
|
|
98a4684a2b | ||
|
|
da0bdfacae | ||
|
|
68c7425922 | ||
|
|
c3418cbe1f | ||
|
|
3a641e0bc1 | ||
|
|
b7fcf6ade1 | ||
|
|
53d160e495 | ||
|
|
df4df6846b | ||
|
|
c1bbb9b139 | ||
|
|
56c07ec351 | ||
|
|
3080b47aec | ||
|
|
c1e23bfdc6 | ||
|
|
e7ff5508d7 | ||
|
|
5850d227ee | ||
|
|
ede987ba19 | ||
|
|
bc0d83164c | ||
|
|
30f0b080c5 | ||
|
|
ee39ea316f | ||
|
|
fbc2708259 | ||
|
|
9bb83aba7e | ||
|
|
3a67e5617e | ||
|
|
f401aa7669 | ||
|
|
a6e0678e59 | ||
|
|
e832015f26 | ||
|
|
5e86881e15 | ||
|
|
3b71f98460 | ||
|
|
144d57adb2 | ||
|
|
e1f1c15318 | ||
|
|
d88b1b2f8f | ||
|
|
2e61c15f2c | ||
|
|
df73473c7f | ||
|
|
7167973afd | ||
|
|
3c17bc2d1a | ||
|
|
a9a295dc54 | ||
|
+2 |
21dd1f4e95 | ||
|
|
5d775bd4a3 | ||
|
|
4e02b22472 | ||
|
|
4686d3ff48 | ||
|
|
02e9d8d45f | ||
|
|
7ce5096f13 | ||
|
|
83e51e9b9d | ||
|
|
dca3278c40 | ||
|
|
7501635fbf | ||
|
|
80c96ea5ac | ||
|
|
8c001e0d34 | ||
|
|
bf275497a2 | ||
|
|
6e351a4b20 | ||
|
|
5360cea3bf | ||
|
+2 |
2f36985462 | ||
|
|
6b26e9a7de | ||
|
|
d7655f0521 | ||
|
|
f4ed345a87 | ||
|
|
c3122c82e4 | ||
|
|
cc9b49131e | ||
|
|
190db1a0f6 | ||
|
|
bd48a72e99 | ||
|
|
a6836a1488 | ||
|
|
2b67ea9635 | ||
|
|
a9117c2d36 | ||
|
+1 |
10d3b5f1b9 | ||
|
|
b757cb6ba1 | ||
|
|
5968e0aa19 | ||
|
|
a06fffc4ec | ||
|
|
3312c84e7c | ||
|
|
d86710d5e9 | ||
|
|
2d8e65c160 | ||
|
|
f74eaf7cb3 | ||
|
|
bcfd57c953 | ||
|
|
c38588b93d | ||
|
|
32ed43574d | ||
|
|
38a5a22b9e | ||
|
|
2120e301c3 | ||
|
|
dd0aec4e97 | ||
|
|
3815b86258 | ||
|
|
7a6545bd03 | ||
|
|
11f467af5d | ||
|
|
0930cf50b6 | ||
|
|
c37cddd0b7 | ||
|
|
09253f83bf | ||
|
|
1cdcebac01 | ||
|
|
58270fa653 | ||
|
|
1a26a254ab | ||
|
|
a36d15894d | ||
|
|
cf077fd33c | ||
|
|
0c979d3604 | ||
|
|
33bc786bb3 | ||
|
|
a7f16462db | ||
|
|
2c7fcbcf51 | ||
|
|
d3e4d26fa5 | ||
|
|
3be6c05722 | ||
|
|
e293e590bd | ||
|
+1 |
d8c1c103a1 | ||
|
|
19d00e691c | ||
|
|
78aa4aecef | ||
|
|
3ab002e547 | ||
|
|
7f461bc275 | ||
|
|
571176102e | ||
|
|
8f1d3fbc50 | ||
|
|
5e88ad50ea | ||
|
|
056406bdd9 | ||
|
|
58af5a18a4 | ||
|
|
90b6581d76 | ||
|
|
b33dcc8841 | ||
|
|
d951a5b872 | ||
|
|
7122a9cfbf | ||
|
|
f1b92c49fc | ||
|
|
ef3ad36c2c | ||
|
|
072ce452b7 | ||
|
|
0e0940f5f0 | ||
|
|
310936125d | ||
|
|
a9df3309b9 | ||
|
|
148d658f14 | ||
|
|
242d571d7d | ||
|
|
dff49ec28a | ||
|
|
0c746ec10f | ||
|
|
901f54ca3a | ||
|
|
16fda5f599 | ||
|
|
d3e9adf382 | ||
|
|
cf559e0726 | ||
|
|
8447b1bbd5 | ||
|
|
0d03006653 | ||
|
|
d44c5fd8b0 | ||
|
|
74aa366f1e | ||
|
|
7ebea57602 | ||
|
|
26d675e162 | ||
|
|
e16d29e916 | ||
|
|
0e89fb9f6a | ||
|
|
43fb410a28 | ||
|
|
b8bbfe8a3f | ||
|
|
1f74fc394a | ||
|
|
9a12b5a0ac | ||
|
|
2ce2c1074b | ||
|
|
0ef80da260 | ||
|
|
1e50488cfe | ||
|
|
ae9f66c9ef | ||
|
|
798a4b0090 | ||
|
|
dcd9ff72b7 | ||
|
|
0b5a0186bc | ||
|
|
d9846dca4c | ||
|
|
2fd606c38e | ||
|
|
c066a0cba9 | ||
|
|
568cc22c60 | ||
|
|
b3243c53dc | ||
|
|
80ffcf8b58 | ||
|
+1 |
77bf14a797 | ||
|
|
b188368e8f | ||
|
|
e35631aee3 | ||
|
|
733a06a672 | ||
|
|
2de3e7fc59 | ||
|
|
3137a188a0 | ||
|
|
a2a4ff934f | ||
|
|
eda1d5f393 | ||
|
|
93a14b190e | ||
|
|
2a6dc340df | ||
|
|
9cff1e3439 | ||
|
|
bb9cbd6582 | ||
|
|
81721e7bb9 | ||
|
|
454311dd52 | ||
|
|
7df04c1d50 | ||
|
|
d9a8ab3864 | ||
|
|
e3a7fd006b | ||
|
|
38d8852819 | ||
|
|
c0f34a39d1 | ||
|
|
e09dd04e7f | ||
|
|
1c2c6a62e6 | ||
|
|
33271a88b1 | ||
|
|
4086a666ae | ||
|
|
b31b8cc6df | ||
|
|
6dce57440e | ||
|
|
22a030afab | ||
|
|
e04d81cf11 | ||
|
|
bd6888b758 | ||
|
|
ccc2bdd25e | ||
|
|
a36b46bd81 | ||
|
|
a94d51c4bd | ||
|
|
3854b9c58e | ||
|
|
ba6963573e | ||
|
|
596f723cff | ||
|
|
c408d71a53 | ||
|
|
d64400d66b | ||
|
|
16eede9bb0 | ||
|
|
ba5d448811 | ||
|
|
1070cc2892 | ||
|
|
df91425643 | ||
|
|
c98bafcd52 | ||
|
|
9441a79def | ||
|
|
994df45a19 | ||
|
|
c4662d79e1 | ||
|
|
50cd1fbd83 | ||
|
|
196549c124 | ||
|
|
91274d8ee0 | ||
|
|
fa026ee393 | ||
|
|
5793901b03 | ||
|
|
030f1c6990 | ||
|
|
b201067f86 | ||
|
|
cb96b12108 | ||
|
|
e7f557f7f9 | ||
|
|
e2921c3126 | ||
|
|
d8102615b1 | ||
|
|
1010e2894a | ||
|
|
c728174c14 | ||
|
|
3889b49fa1 | ||
|
|
461389fb0b | ||
|
|
a8186e3039 | ||
|
|
5e2db12396 | ||
|
|
6bc9d2fd94 | ||
|
|
37f9bbf3fa | ||
|
|
fb5b3bbcc6 | ||
|
|
d43ee154d0 | ||
|
|
6415d42a1d | ||
|
|
ee620c7fff | ||
|
|
112efbbda2 | ||
|
|
fac0b0fa32 | ||
|
|
12d47ab949 | ||
|
|
4fef7ba5e2 | ||
|
|
6af0428a18 | ||
|
|
d66f3d1dc9 | ||
|
|
a5249b6cb5 | ||
|
|
6771c11360 | ||
|
|
002375ac72 | ||
|
|
0402e46328 | ||
|
|
02e3738a17 | ||
|
|
f822177788 | ||
|
|
1edc9c50d5 | ||
|
|
7beb17c007 | ||
|
|
ad7af451ff | ||
|
|
f513ce8687 | ||
|
|
d83883e7e4 | ||
|
|
9e001c53e8 | ||
|
|
3937b0c4c6 | ||
|
|
cbe358bb6b | ||
|
|
2b91102f3f | ||
|
|
4e7804c46d | ||
|
|
d7226fc3d4 | ||
|
|
0cb278d109 | ||
|
|
a1430101d1 | ||
|
|
9092addd9c | ||
|
|
7f14e00119 | ||
|
|
abc5ff2d97 | ||
|
|
fcde1f7a9d | ||
|
|
30eea089ba | ||
|
|
3c8cb3a9ea | ||
|
|
54f4e665a3 | ||
|
|
ef3e7fae20 | ||
|
|
bb7815464c | ||
|
|
acb6f292ec | ||
|
|
0b2250caf9 | ||
|
|
db7c3fb52f | ||
|
|
6d67808627 | ||
|
|
557b57a261 | ||
|
|
31875f7535 | ||
|
|
29c4efe4a8 | ||
|
|
7bda15aaa3 | ||
|
|
4176086ce0 | ||
|
|
04e62a10e6 | ||
|
|
0ff38dece2 | ||
|
|
8416b76bf6 | ||
|
|
c3a427346d | ||
|
|
a73b3fcaed |
+10
-1
@@ -11,6 +11,10 @@
|
||||
# - ZOOM
|
||||
# - GIPHY
|
||||
# - VITAL
|
||||
# - ZAPIER
|
||||
# - LARK
|
||||
# - WEB3
|
||||
# - SALESFORCE
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
@@ -88,7 +92,7 @@ VITAL_REGION="us"
|
||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md
|
||||
ZAPIER_INVITE_LINK=""
|
||||
|
||||
# - LARK
|
||||
# - LARK
|
||||
# Needed to enable Lark Calendar integration and Login with Lark
|
||||
# @see <https://open.larksuite.com/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/g>
|
||||
LARK_OPEN_APP_ID=""
|
||||
@@ -100,4 +104,9 @@ LARK_OPEN_VERIFICATION_TOKEN=""
|
||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/rainbow/README.md
|
||||
ALCHEMY_API_KEY=""
|
||||
INFURA_API_KEY=""
|
||||
|
||||
# - SALESFORCE
|
||||
# Used for the Salesforce (Sales Cloud) app
|
||||
SALESFORCE_CONSUMER_KEY=""
|
||||
SALESFORCE_CONSUMER_SECRET=""
|
||||
# *********************************************************************************************************
|
||||
|
||||
+16
-9
@@ -7,14 +7,13 @@
|
||||
# - E-MAIL SETTINGS
|
||||
|
||||
# - LICENSE *************************************************************************************************
|
||||
# Set this value to 'agree' to accept our license:
|
||||
# LICENSE: https://github.com/calendso/calendso/blob/main/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=''
|
||||
#
|
||||
# To enable enterprise-only features, fill your license key in here.
|
||||
# @see https://console.cal.com
|
||||
CALCOM_LICENSE_KEY=
|
||||
@@ -86,6 +85,9 @@ SENDGRID_EMAIL=
|
||||
TWILIO_SID=
|
||||
TWILIO_TOKEN=
|
||||
TWILIO_MESSAGING_SID=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
|
||||
# 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
|
||||
@@ -96,11 +98,10 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID=
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID=
|
||||
STRIPE_PRIVATE_KEY=
|
||||
STRIPE_CLIENT_ID=
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
@@ -138,5 +139,11 @@ CLOSECOM_API_KEY=
|
||||
# Sendgrid internal sync service
|
||||
SENDGRID_SYNC_API_KEY=
|
||||
|
||||
# Sendgrid internal email sender
|
||||
SENDGRID_API_KEY=
|
||||
# Sentry
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_IGNORE_API_RESOLUTION_ERROR=
|
||||
|
||||
# Change your Brand
|
||||
NEXT_PUBLIC_APP_NAME="Cal.com"
|
||||
NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS="help@cal.com"
|
||||
NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc."
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Add bugs to Bug Hunting
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- labeled
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
add-to-project:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/9
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
labeled: 🙋🏻♂️help wanted
|
||||
@@ -1,4 +1,5 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
# Pattern matched against refs/tags
|
||||
tags:
|
||||
@@ -8,4 +9,9 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: curl -X POST -d {} ${{ secrets.VERCEL_VERSIONED_DEPLOY_HOOK }}
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: main # Always checkout main even for tagged releases
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- run: git push origin +main:production
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Unit tests
|
||||
on:
|
||||
push:
|
||||
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
||||
branches: [ feat/routing-forms/reporting ] # TODO: Remove this after merged in main
|
||||
pull_request_target: # So we can test on forks
|
||||
branches:
|
||||
- main
|
||||
@@ -21,7 +21,8 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
||||
fetch-depth: 2
|
||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
||||
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
|
||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=6144"' >> $GITHUB_ENV
|
||||
- name: Use Node 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"projectId": "cl4u26bwz7962859ply7ibuo43t",
|
||||
"databaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||
"targetDatabaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "0.3.0"
|
||||
"version": "0.22.3"
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -6,5 +6,6 @@
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"spellright.language": ["en"],
|
||||
"spellright.documentTypes": ["markdown", "typescript"]
|
||||
"spellright.documentTypes": ["markdown", "typescript"],
|
||||
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]]
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ You can deploy Cal on [Railway](https://railway.app/) using the button above. Th
|
||||
|
||||
Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY,NEXT_PUBLIC_LICENSE_CONSENT&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
|
||||
|
||||
<!-- ROADMAP -->
|
||||
|
||||
@@ -354,56 +354,6 @@ following
|
||||
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
|
||||
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
|
||||
|
||||
### Obtaining Slack Client ID and Secret and Signing Secret
|
||||
|
||||
To test this you will need to create a Slack app for yourself on [their apps website](https://api.slack.com/apps).
|
||||
|
||||
Copy and paste the app manifest below into the setting on your slack app. Be sure to replace `YOUR_DOMAIN` with your own domain or your proxy host if you're testing locally.
|
||||
|
||||
<details>
|
||||
<summary>App Manifest</summary>
|
||||
|
||||
```yaml
|
||||
display_information:
|
||||
name: Cal.com Slack
|
||||
features:
|
||||
bot_user:
|
||||
display_name: Cal.com Slack
|
||||
always_online: false
|
||||
slash_commands:
|
||||
- command: /create-event
|
||||
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
|
||||
description: Create an event within Cal!
|
||||
should_escape: false
|
||||
- command: /today
|
||||
url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler
|
||||
description: View all your bookings for today
|
||||
should_escape: false
|
||||
oauth_config:
|
||||
redirect_urls:
|
||||
- https://YOUR_DOMAIN/api/integrations/slackmessaging/callback
|
||||
scopes:
|
||||
bot:
|
||||
- chat:write
|
||||
- commands
|
||||
- chat:write.public
|
||||
settings:
|
||||
interactivity:
|
||||
is_enabled: true
|
||||
request_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
|
||||
message_menu_options_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Add the integration as normal - slack app - add. Follow the oauth flow to add it to a server.
|
||||
|
||||
Next make sure you have your app running `yarn dx`. Then in the slack chat type one of these commands: `/create-event` or `/today`
|
||||
|
||||
> NOTE: Next you will need to setup a proxy server like [ngrok](https://ngrok.com/) to allow your local host machine to be hosted on a public https server.
|
||||
|
||||
### Obtaining Zoom Client ID and Secret
|
||||
|
||||
@@ -437,7 +387,7 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspotothercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
@@ -469,14 +419,18 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
||||
2. Click ‘Get a Twilio phone number’
|
||||
3. Copy Account SID to your .env file into the TWILIO_SID field
|
||||
4. Copy Auth Token to your .env file into the TWILIO_TOKEN field
|
||||
5. Create a messaging service (Develop -> Messaging -> Services)
|
||||
6. Choose any name for the messaging service
|
||||
7. Click 'Add Senders'
|
||||
8. Choose phone number as sender type
|
||||
9. Add the listed phone number
|
||||
10. Leave all other fields as they are
|
||||
11. Complete setup and click ‘View my new Messaging Service’
|
||||
12. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
||||
5. Copy your Twilio phone number to your .env file into the TWILIO_PHONE_NUMBER field
|
||||
6. Add your own sender id to the .env file into the NEXT_PUBLIC_SENDER_ID field (fallback is Cal)
|
||||
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||
8. Choose any name for the messaging service
|
||||
9. Click 'Add Senders'
|
||||
10. Choose phone number as sender type
|
||||
11. Add the listed phone number
|
||||
12. Leave all other fields as they are
|
||||
13. Complete setup and click ‘View my new Messaging Service’
|
||||
14. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
||||
15. Create a verify service
|
||||
16. Copy Verify Service SID to your .env file into the TWILIO_VERIFY_SID field
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* This is a Playwright script Checkly generated for you based on your Vercel project.
|
||||
* To learn more about Browser checks and Playwright visit: https://www.checklyhq.com/docs/browser-checks
|
||||
*/
|
||||
|
||||
// Create a Chromium browser
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
// Checkly supports top level await, but we wrap your code in an async function so you can run it locally too.
|
||||
async function run() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const targetUrl = process.env.ENVIRONMENT_URL || "https://cal.com";
|
||||
await page.goto(`${targetUrl}/rick/test-location-link`);
|
||||
|
||||
await page.waitForSelector('[data-testid="day"][data-disabled="false"]');
|
||||
await page.click('[data-testid="day"][data-disabled="false"]');
|
||||
|
||||
await page.waitForSelector('[data-testid="time"]');
|
||||
await page.click('[data-testid="time"]');
|
||||
|
||||
await page.waitForSelector("#name");
|
||||
await page.click("#name");
|
||||
|
||||
await page.type("#name", "Calcom");
|
||||
|
||||
await page.type('[name="email"]', "cal@cal.com");
|
||||
|
||||
await page.waitForSelector('[data-testid="confirm-book-button"]');
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -33,11 +33,6 @@
|
||||
"generator": "secret",
|
||||
"required": "true"
|
||||
},
|
||||
"NEXT_PUBLIC_LICENSE_CONSENT": {
|
||||
"description": "License consent",
|
||||
"value": "true",
|
||||
"required": "true"
|
||||
},
|
||||
"CRON_API_KEY": {
|
||||
"description": "ApiKey for cronjobs",
|
||||
"value": ""
|
||||
|
||||
+1
-1
Submodule apps/api updated: 0f5017010f...12f19ff7c0
+1
-1
Submodule apps/console updated: 2219900e06...ad226cef6e
@@ -4,6 +4,7 @@ module.exports = {
|
||||
stories: [
|
||||
"../intro.stories.mdx",
|
||||
"../../../packages/ui/components/**/*.stories.mdx",
|
||||
"../../../packages/features/**/*.stories.mdx",
|
||||
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
|
||||
],
|
||||
addons: [
|
||||
@@ -50,6 +51,21 @@ module.exports = {
|
||||
vm: false,
|
||||
zlib: false,
|
||||
};
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
"style-loader",
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: true, // Enable modules to help you using className
|
||||
},
|
||||
},
|
||||
],
|
||||
include: path.resolve(__dirname, "../src"),
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,7 +15,11 @@ export const parameters = {
|
||||
},
|
||||
};
|
||||
|
||||
addDecorator((storyFn) => <I18nextProvider i18n={i18n}>{storyFn()}</I18nextProvider>);
|
||||
addDecorator((storyFn) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<div style={{ margin: "2rem" }}>{storyFn()}</div>
|
||||
</I18nextProvider>
|
||||
));
|
||||
|
||||
window.getEmbedNamespace = () => {
|
||||
const url = new URL(document.URL);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { classNames } from "@calcom/lib";
|
||||
interface ExampleProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
isFullWidth?: boolean;
|
||||
}
|
||||
export const Example = ({ children, title }: ExampleProps) => {
|
||||
export const Example = ({ children, title, isFullWidth = false }: ExampleProps) => {
|
||||
return (
|
||||
<div className="examples-item">
|
||||
<span className="examples-item-title">{title}</span>
|
||||
<div className={classNames("examples-item", isFullWidth && "w-full")}>
|
||||
<span className={classNames("examples-item-title", isFullWidth && "mb-0 mt-2")}>{title}</span>
|
||||
<div className="examples-item-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function VariantsTable({
|
||||
<div
|
||||
className={classNames(
|
||||
isDark &&
|
||||
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#22252A]"
|
||||
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
|
||||
)}>
|
||||
<div className="z-1 relative mx-auto w-full max-w-[1200px] overflow-auto pr-8 pt-6">
|
||||
<table>
|
||||
|
||||
@@ -49,6 +49,6 @@
|
||||
"storybook-react-i18next": "^1.1.2",
|
||||
"tailwindcss": "^3.2.1",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^3.1.0"
|
||||
"vite": "^2.9.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import "../../../packages/ui/styles/shared-globals.css"
|
||||
@import "../../../packages/ui/styles/shared-globals.css"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url("../../../packages/features/calendars/weeklyview/styles/styles.css");
|
||||
|
||||
.sbdocs {
|
||||
font-family: 'Inter var' !important;
|
||||
padding: 0!important;
|
||||
@@ -36,7 +38,7 @@
|
||||
|
||||
/** Docs table **/
|
||||
.custom-args-wrapper{
|
||||
max-height: 600px;
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
+3
-1
@@ -67,4 +67,6 @@ tsconfig.tsbuildinfo
|
||||
public/embed
|
||||
|
||||
# Copied app-store images
|
||||
public/app-store
|
||||
public/app-store
|
||||
# Sentry
|
||||
.sentryclirc
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
declare module "@wojtekmaj/react-daterange-picker/dist/entry.nostyle" {
|
||||
import { CalendarProps } from "react-calendar";
|
||||
export type DateRangePickerCalendarProps = Omit<
|
||||
CalendarProps,
|
||||
"calendarClassName" | "onChange" | "value"
|
||||
> & {
|
||||
calendarClassName?: string;
|
||||
onChange: (value: [Date, Date]) => void;
|
||||
value: [Date, Date];
|
||||
clearIcon: JSX.Element | null;
|
||||
calendarIcon: JSX.Element | null;
|
||||
rangeDivider: JSX.Element | null;
|
||||
};
|
||||
export default function DateRangePicker(props: DateRangePickerCalendarProps): JSX.Element;
|
||||
}
|
||||
@@ -598,60 +598,6 @@ paths:
|
||||
summary: Gets and stores the OAuth token
|
||||
tags:
|
||||
- Integrations
|
||||
/api/user/profile:
|
||||
patch:
|
||||
description: Updates a user's profile.
|
||||
summary: Updates a user's profile
|
||||
tags:
|
||||
- User
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
avatar:
|
||||
type: string
|
||||
timeZone:
|
||||
type: string
|
||||
weekStart:
|
||||
type: string
|
||||
hideBranding:
|
||||
type: string
|
||||
theme:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
/api/me:
|
||||
get:
|
||||
description: Gets current user's profile.
|
||||
@@ -686,71 +632,6 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
/api/user/membership:
|
||||
get:
|
||||
description: Get a list of the teams the user has joined.
|
||||
summary: Get the teams a user is joined to
|
||||
tags:
|
||||
- User
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
patch:
|
||||
description: Accept team invitation
|
||||
summary: Accept team invitation.
|
||||
tags:
|
||||
- User
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
teamId:
|
||||
type: string
|
||||
delete:
|
||||
description: Leave team or decline membership invite of current user
|
||||
summary: Leave team or decline team invite.
|
||||
tags:
|
||||
- User
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
teamId:
|
||||
type: string
|
||||
"/api/{team}":
|
||||
delete:
|
||||
description: Deletes a team
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
export default function AddToHomescreen() {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -36,7 +36,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
||||
|
||||
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: true }]);
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import { Button, SkeletonButton } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
||||
|
||||
const Component = ({
|
||||
name,
|
||||
type,
|
||||
logo,
|
||||
body,
|
||||
categories,
|
||||
author,
|
||||
price = 0,
|
||||
commission,
|
||||
isGlobal = false,
|
||||
feeType,
|
||||
docs,
|
||||
website,
|
||||
email,
|
||||
tos,
|
||||
privacy,
|
||||
isProOnly,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const handleOpenChange = () => {
|
||||
utils.invalidateQueries(["viewer.integrations"]);
|
||||
router.replace("/apps/installed");
|
||||
};
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: () => {
|
||||
showToast("App successfully installed", "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || "App could not be installed", "error");
|
||||
},
|
||||
});
|
||||
|
||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price);
|
||||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const appCredentials = trpc.useQuery(["viewer.appCredentialsByType", { appType: type }], {
|
||||
onSuccess(data) {
|
||||
setExistingCredentials(data);
|
||||
},
|
||||
});
|
||||
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
||||
|
||||
return (
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="px-8">
|
||||
<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">
|
||||
<Icon.FiChevronLeft 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 rounded-sm" src={logo} alt={name} />
|
||||
<header className="px-4 py-2">
|
||||
<div className="flex items-center">
|
||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
||||
</div>
|
||||
<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">
|
||||
{!appCredentials.isLoading ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
|
||||
{existingCredentials.length > 0
|
||||
? t("active_install", { count: existingCredentials.length })
|
||||
: t("globally_install")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button StartIcon={Icon.FiPlus} {...props} data-testid="install-app-button">
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : existingCredentials.length > 0 ? (
|
||||
<DisconnectIntegration
|
||||
id={existingCredentials[0]}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid={type + "-integration-disconnect-button"}>
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={handleOpenChange}
|
||||
/>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button data-testid="install-app-button" {...props}>
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<SkeletonButton width="24" height="10" />
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<small className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="justify-between px-8 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}>
|
||||
<Icon.FiBookOpen 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}>
|
||||
<Icon.FiExternalLink 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}>
|
||||
<Icon.FiMail 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}>
|
||||
<Icon.FiFile 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}>
|
||||
<Icon.FiShield 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">
|
||||
<Icon.FiFlag className="inline h-3 w-3" /> Report App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App(props: {
|
||||
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;
|
||||
licenseRequired: AppType["licenseRequired"];
|
||||
isProOnly: AppType["isProOnly"];
|
||||
}) {
|
||||
return (
|
||||
<Shell large isPublic>
|
||||
{props.licenseRequired ? (
|
||||
<LicenseRequired>
|
||||
<Component {...props} />
|
||||
</LicenseRequired>
|
||||
) : (
|
||||
<Component {...props} />
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const DestinationCalendarSelector = ({
|
||||
destinationCalendar,
|
||||
}: Props): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
// Extra styles to show prefixed text in react-select
|
||||
|
||||
+251
-221
@@ -4,14 +4,22 @@ import { NextRouter, useRouter } from "next/router";
|
||||
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
||||
import { components, ControlProps } from "react-select";
|
||||
|
||||
import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
|
||||
import { HorizontalTabs, showToast, Switch } from "@calcom/ui/v2";
|
||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/v2/core/Dialog";
|
||||
|
||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
HorizontalTabs,
|
||||
Icon,
|
||||
InputLeading,
|
||||
Label,
|
||||
showToast,
|
||||
Switch,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import ColorPicker from "@components/ui/colorpicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
@@ -36,6 +44,7 @@ type PreviewState = {
|
||||
palette: {
|
||||
brandColor: string;
|
||||
};
|
||||
hideEventTypeDetails: boolean;
|
||||
};
|
||||
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
||||
|
||||
@@ -117,10 +126,12 @@ const getEmbedUIInstructionString = ({
|
||||
apiName,
|
||||
theme,
|
||||
brandColor,
|
||||
hideEventTypeDetails,
|
||||
}: {
|
||||
apiName: string;
|
||||
theme?: string;
|
||||
brandColor: string;
|
||||
hideEventTypeDetails: boolean;
|
||||
}) => {
|
||||
theme = theme !== "auto" ? theme : undefined;
|
||||
return getInstructionString({
|
||||
@@ -133,6 +144,7 @@ const getEmbedUIInstructionString = ({
|
||||
brandColor,
|
||||
},
|
||||
},
|
||||
hideEventTypeDetails: hideEventTypeDetails,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -245,18 +257,21 @@ const getEmbedTypeSpecificString = ({
|
||||
apiName: string;
|
||||
theme: PreviewState["theme"];
|
||||
brandColor: string;
|
||||
hideEventTypeDetails: boolean;
|
||||
};
|
||||
if (embedFramework === "react") {
|
||||
uiInstructionStringArg = {
|
||||
apiName: "cal",
|
||||
theme: previewState.theme,
|
||||
brandColor: previewState.palette.brandColor,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
};
|
||||
} else {
|
||||
uiInstructionStringArg = {
|
||||
apiName: "Cal",
|
||||
theme: previewState.theme,
|
||||
brandColor: previewState.palette.brandColor,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
};
|
||||
}
|
||||
if (!frameworkCodes[embedType]) {
|
||||
@@ -489,7 +504,9 @@ const tabs = [
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
||||
<small className="flex py-4 text-neutral-500">
|
||||
{t("place_where_cal_widget_appear", { appName: APP_NAME })}
|
||||
</small>
|
||||
</div>
|
||||
<TextArea
|
||||
data-testid="embed-code"
|
||||
@@ -610,13 +627,13 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DialogContent type="creation" useOwnActionButtons size="lg">
|
||||
<DialogContent type="creation" size="lg">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("how_you_want_add_cal_site")}
|
||||
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site")}</p>
|
||||
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site", { appName: APP_NAME })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
@@ -679,6 +696,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
theme: Theme.auto,
|
||||
floatingPopup: {},
|
||||
elementClick: {},
|
||||
hideEventTypeDetails: false,
|
||||
palette: {
|
||||
brandColor: "#000000",
|
||||
},
|
||||
@@ -743,6 +761,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
name: "ui",
|
||||
arg: {
|
||||
theme: previewState.theme,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
styles: {
|
||||
branding: {
|
||||
...previewState.palette,
|
||||
@@ -788,7 +807,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<DialogContent size="xl" className="p-0.5" type="creation" useOwnActionButtons>
|
||||
<DialogContent size="xl" className="p-0.5" type="creation">
|
||||
<div className="flex">
|
||||
<div className="flex w-1/3 flex-col bg-gray-50 p-8">
|
||||
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||
@@ -801,245 +820,258 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
{embed.title}
|
||||
</h3>
|
||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full items-center text-base font-medium text-neutral-900">
|
||||
<div>
|
||||
{embedType === "inline"
|
||||
? "Inline Embed Customization"
|
||||
: embedType === "floating-popup"
|
||||
? "Floating Popup Customization"
|
||||
: "Element Click Customization"}
|
||||
</div>
|
||||
<Icon.FiChevronRight
|
||||
className={`${
|
||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="text-sm">
|
||||
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
||||
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
||||
<div className="text-sm">Embed Window Sizing</div>
|
||||
<div className="justify-left flex items-center">
|
||||
<TextField
|
||||
name="width"
|
||||
labelProps={{ className: "hidden" }}
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
const width = e.target.value || "100%";
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
width,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>W</InputLeading>}
|
||||
/>
|
||||
<span className="p-2">×</span>
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
name="height"
|
||||
value={previewState.inline.height}
|
||||
required
|
||||
onChange={(e) => {
|
||||
const height = e.target.value || "100%";
|
||||
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
height,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>H</InputLeading>}
|
||||
/>
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full items-center text-base font-medium text-neutral-900">
|
||||
<div>
|
||||
{embedType === "inline"
|
||||
? "Inline Embed Customization"
|
||||
: embedType === "floating-popup"
|
||||
? "Floating Popup Customization"
|
||||
: "Element Click Customization"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2 text-sm">Button Text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
name="buttonText"
|
||||
labelProps={{ className: "hidden" }}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonText: e.target.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue="Book my Cal"
|
||||
required
|
||||
<Icon.FiChevronRight
|
||||
className={`${
|
||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-start",
|
||||
embedType === "floating-popup" ? "space-x-2" : "hidden"
|
||||
)}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
hideButtonIcon: !checked,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm">Display Calendar Icon Button</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2">Position of Button</div>
|
||||
<Select
|
||||
onChange={(position) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonPosition: position?.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue={FloatingPopupPositionOptions[0]}
|
||||
options={FloatingPopupPositionOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Button Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="text-sm">
|
||||
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
||||
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
||||
<div className="text-sm">Embed Window Sizing</div>
|
||||
<div className="justify-left flex items-center">
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
const width = e.target.value || "100%";
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
width,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>W</InputLeading>}
|
||||
/>
|
||||
<span className="p-2">×</span>
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
value={previewState.inline.height}
|
||||
required
|
||||
onChange={(e) => {
|
||||
const height = e.target.value || "100%";
|
||||
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
height,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>H</InputLeading>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2 text-sm">Button Text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
buttonText: e.target.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue="Book my Cal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Text Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-start",
|
||||
embedType === "floating-popup" ? "space-x-2" : "hidden"
|
||||
)}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
hideButtonIcon: !checked,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm">Display Calendar Icon Button</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
<div className="mt-4 font-medium">
|
||||
<Collapsible
|
||||
open={isBookingCustomizationOpen}
|
||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger className="flex w-full" type="button">
|
||||
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
|
||||
<Icon.FiChevronRight
|
||||
className={`${
|
||||
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<Label className="">
|
||||
<div className="mb-2">Theme</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "" : "hidden"
|
||||
)}>
|
||||
<div className="mb-2">Position of Button</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
onChange={(position) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
theme: option.value,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonPosition: position?.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}
|
||||
defaultValue={FloatingPopupPositionOptions[0]}
|
||||
options={FloatingPopupPositionOptions}
|
||||
/>
|
||||
</Label>
|
||||
{[
|
||||
{ name: "brandColor", title: "Brand Color" },
|
||||
// { name: "lightColor", title: "Light Color" },
|
||||
// { name: "lighterColor", title: "Lighter Color" },
|
||||
// { name: "lightestColor", title: "Lightest Color" },
|
||||
// { name: "highlightColor", title: "Highlight Color" },
|
||||
// { name: "medianColor", title: "Median Color" },
|
||||
].map((palette) => (
|
||||
<Label key={palette.name} className="pb-4">
|
||||
<div className="mb-2 pt-2">{palette.title}</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
[palette.name as keyof typeof previewState["palette"]]: color,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Button Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("mt-4", embedType === "floating-popup" ? "" : "hidden")}>
|
||||
<div>Text Color</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
<div className="mt-4 font-medium">
|
||||
<Collapsible
|
||||
open={isBookingCustomizationOpen}
|
||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger className="flex w-full" type="button">
|
||||
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
|
||||
<Icon.FiChevronRight
|
||||
className={`${
|
||||
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<div className="mb-4 flex items-center justify-start space-x-2">
|
||||
<Switch
|
||||
checked={previewState.hideEventTypeDetails}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
hideEventTypeDetails: checked,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm">{t("hide_eventtype_details")}</div>
|
||||
</div>
|
||||
<Label className="">
|
||||
<div className="mb-2">Theme</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
theme: option.value,
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}
|
||||
/>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{[
|
||||
{ name: "brandColor", title: "Brand Color" },
|
||||
// { name: "lightColor", title: "Light Color" },
|
||||
// { name: "lighterColor", title: "Lighter Color" },
|
||||
// { name: "lightestColor", title: "Lightest Color" },
|
||||
// { name: "highlightColor", title: "Highlight Color" },
|
||||
// { name: "medianColor", title: "Median Color" },
|
||||
].map((palette) => (
|
||||
<Label key={palette.name} className="pb-4">
|
||||
<div className="mb-2 pt-2">{palette.title}</div>
|
||||
<div className="w-full">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
[palette.name as keyof typeof previewState["palette"]]: color,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-2/3 flex-col p-8">
|
||||
@@ -1084,9 +1116,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
{t("copy_code")}
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("Close")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useViewerI18n() {
|
||||
return trpc.useQuery(["viewer.public.i18n"], {
|
||||
return trpc.viewer.public.i18n.useQuery(undefined, {
|
||||
staleTime: Infinity,
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui";
|
||||
|
||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||
@@ -157,12 +156,8 @@ export default function ImageUploader({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</DialogClose>
|
||||
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
* @deprecated Use custom Skeletons instead
|
||||
**/
|
||||
export { default } from "@calcom/ui/Loader";
|
||||
export { Loader as default } from "@calcom/ui";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { ComponentProps } from "react";
|
||||
|
||||
import ErrorBoundary from "@calcom/ui/ErrorBoundary";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import { ErrorBoundary, Icon, Shell } from "@calcom/ui";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React from "react";
|
||||
import { OptionProps } from "react-select";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Select } from "@calcom/ui/v2";
|
||||
import { Button, Icon, Select } from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -42,7 +39,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
||||
|
||||
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: true }]);
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
@@ -64,12 +61,10 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
<Select
|
||||
name="additionalCalendar"
|
||||
placeholder={
|
||||
<div className="flex justify-start text-black">
|
||||
<Icon.FiPlus className="-ml-1 h-4 w-4 ltr:mr-2 rtl:ml-2 rtl:-mr-1" />
|
||||
<p>{t("add")}</p>
|
||||
</div>
|
||||
<Button StartIcon={Icon.FiPlus} color="secondary">
|
||||
{t("add")}
|
||||
</Button>
|
||||
}
|
||||
className="min-w-44"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isLoading={isLoading}
|
||||
@@ -83,11 +78,16 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
width: "max-content",
|
||||
right: "0",
|
||||
}),
|
||||
indicatorSeparator: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
display: "none",
|
||||
}),
|
||||
control: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
padding: "0",
|
||||
border: "0",
|
||||
borderRadius: "inherit",
|
||||
borderRadius: "6px",
|
||||
minHeight: "auto",
|
||||
}),
|
||||
dropdownIndicator: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
|
||||
@@ -4,16 +4,14 @@ import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { showToast, SkeletonText } from "@calcom/ui/v2";
|
||||
import { SkeletonButton, Shell } from "@calcom/ui/v2";
|
||||
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
|
||||
import { Button, Icon, Shell, showToast, SkeletonButton, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import HeadSeo from "@components/seo/head-seo";
|
||||
|
||||
@@ -43,7 +41,8 @@ const Component = ({
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -58,13 +57,18 @@ const Component = ({
|
||||
}).format(price);
|
||||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const appCredentials = trpc.useQuery(["viewer.appCredentialsByType", { appType: type }], {
|
||||
onSuccess(data) {
|
||||
setExistingCredentials(data);
|
||||
},
|
||||
});
|
||||
const appCredentials = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
onSuccess(data) {
|
||||
setExistingCredentials(data);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
||||
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
|
||||
// Such apps, can only be installed once.
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
@@ -119,6 +123,7 @@ const Component = ({
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
@@ -143,6 +148,7 @@ const Component = ({
|
||||
</div>
|
||||
) : existingCredentials.length > 0 ? (
|
||||
<DisconnectIntegration
|
||||
buttonProps={{ color: "secondary" }}
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
@@ -156,6 +162,7 @@ const Component = ({
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
@@ -190,7 +197,7 @@ const Component = ({
|
||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||
<span>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
t("free_to_use_apps")
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
@@ -268,8 +275,10 @@ const Component = ({
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-8" />
|
||||
<span className="leading-1 block text-xs text-gray-500">{t("every_app_published")}</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<span className="leading-1 block text-xs text-gray-500">
|
||||
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
|
||||
</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
|
||||
<Icon.FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -3,18 +3,21 @@ import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { Alert, EmptyScreen } from "@calcom/ui/v2";
|
||||
import { List } from "@calcom/ui/v2/core/List";
|
||||
import { ShellSubHeading } from "@calcom/ui/v2/core/Shell";
|
||||
import Switch from "@calcom/ui/v2/core/Switch";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
EmptyScreen,
|
||||
Icon,
|
||||
List,
|
||||
ShellSubHeading,
|
||||
showToast,
|
||||
SkeletonLoader,
|
||||
Switch,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -77,7 +80,7 @@ function CalendarSwitch(props: {
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
await utils.viewer.integrations.invalidate();
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||
@@ -107,7 +110,7 @@ function CalendarSwitch(props: {
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: false }]);
|
||||
const query = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
@@ -144,7 +147,9 @@ function CalendarList(props: Props) {
|
||||
// todo: @hariom extract this into packages/apps-store as "GeneralAppSettings"
|
||||
function ConnectedCalendarsList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
||||
const query = trpc.viewer.connectedCalendars.useQuery(undefined, {
|
||||
suspense: true,
|
||||
});
|
||||
const { fromOnboarding } = props;
|
||||
return (
|
||||
<QueryCell
|
||||
@@ -235,19 +240,19 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
const utils = trpc.useContext();
|
||||
const onChanged = () =>
|
||||
Promise.allSettled([
|
||||
utils.invalidateQueries(["viewer.integrations", { variant: "calendar", onlyInstalled: true }], {
|
||||
exact: true,
|
||||
}),
|
||||
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
||||
utils.viewer.integrations.invalidate(
|
||||
{ variant: "calendar", onlyInstalled: true },
|
||||
{
|
||||
exact: true,
|
||||
}
|
||||
),
|
||||
utils.viewer.connectedCalendars.invalidate(),
|
||||
]);
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const installedCalendars = trpc.useQuery([
|
||||
"viewer.integrations",
|
||||
{ variant: "calendar", onlyInstalled: true },
|
||||
]);
|
||||
const mutation = trpc.useMutation("viewer.setDestinationCalendar", {
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
const installedCalendars = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: true });
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.connectedCalendars"]);
|
||||
utils.viewer.connectedCalendars.invalidate();
|
||||
},
|
||||
});
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,7 @@ const DestinationCalendarSelector = ({
|
||||
destinationCalendar,
|
||||
}: Props): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
// Extra styles to show prefixed text in react-select
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useRouter } from "next/router";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/v2/core/List";
|
||||
import { Icon, ListItem, ListItemText, ListItemTitle, showToast } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
@@ -27,6 +26,13 @@ function IntegrationListItem(props: {
|
||||
const [highlight, setHighlight] = useState(hl === props.slug);
|
||||
const title = props.name || props.title;
|
||||
|
||||
// The highlight is to show a newly installed app, coming from the app's
|
||||
// redirection after installation, so we proceed to show the corresponding
|
||||
// message
|
||||
if (highlight) {
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setHighlight(false), 3000);
|
||||
return () => {
|
||||
@@ -38,7 +44,7 @@ function IntegrationListItem(props: {
|
||||
expanded={!!props.children}
|
||||
className={classNames(
|
||||
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
||||
"my-0 flex-col border transition-colors duration-500 ",
|
||||
"my-0 flex-col border transition-colors duration-500",
|
||||
highlight ? "bg-yellow-100" : ""
|
||||
)}>
|
||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import useDigitInput from "react-digit-input";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, Input } from "@calcom/ui/form/fields";
|
||||
import { Label, Input } from "@calcom/ui";
|
||||
|
||||
export default function TwoFactor({ center = true }) {
|
||||
const [value, onChange] = useState("");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui/v2";
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import Tooltip from "@calcom/ui/v2/core/Tooltip";
|
||||
import { Icon, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { Props } from "./pages/AvailabilityPage";
|
||||
|
||||
@@ -15,14 +15,18 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
||||
}
|
||||
return (
|
||||
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<Icon.FiLink className="dark:text-darkgray-600 mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 " />
|
||||
) : (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={locationKeyToString(location)}>
|
||||
<p className="truncate">{locationKeyToString(location)}</p>
|
||||
</Tooltip>
|
||||
|
||||
@@ -7,8 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
|
||||
import { SkeletonContainer, SkeletonText, ToggleGroup } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
@@ -152,13 +151,13 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
{isLoading && !slots.length && (
|
||||
<>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Icon, Badge } from "@calcom/ui";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||
@@ -39,7 +42,35 @@ interface Props {
|
||||
|
||||
const BookingDescription: FC<Props> = (props) => {
|
||||
const { profile, eventType, isBookingPage = false, children } = props;
|
||||
const { date: bookingDate } = useRouterQuery("date");
|
||||
const { t } = useLocale();
|
||||
const { duration, setQuery: setDuration } = useRouterQuery("duration");
|
||||
useEffect(() => {
|
||||
if (eventType.metadata?.multipleDuration !== undefined) {
|
||||
if (!duration) {
|
||||
setDuration(eventType.length);
|
||||
} else {
|
||||
if (!eventType.metadata?.multipleDuration.includes(Number(duration))) {
|
||||
setDuration(eventType.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
let requiresConfirmation = eventType?.requiresConfirmation;
|
||||
let requiresConfirmationText = t("requires_confirmation");
|
||||
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
||||
if (rcThreshold) {
|
||||
if (isBookingPage) {
|
||||
if (dayjs(bookingDate).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
|
||||
requiresConfirmation = false;
|
||||
}
|
||||
} else {
|
||||
requiresConfirmationText = t("requires_confirmation_threshold", {
|
||||
...rcThreshold,
|
||||
unit: rcThreshold.unit.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<UserAvatars
|
||||
@@ -55,7 +86,7 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold text-gray-900">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="dark:text-darkgray-600 flex flex-col space-y-3 text-sm font-medium text-gray-600">
|
||||
<div className="dark:text-darkgray-600 flex flex-col space-y-4 text-sm font-medium text-gray-600">
|
||||
{eventType?.description && (
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -75,29 +106,61 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
{requiresConfirmation && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center",
|
||||
"items-top flex",
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
)}>
|
||||
<div>
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] inline-block h-4 w-4 " />
|
||||
</div>
|
||||
{t("requires_confirmation")}
|
||||
{requiresConfirmationText}
|
||||
</div>
|
||||
)}
|
||||
<AvailableEventLocations
|
||||
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
||||
/>
|
||||
<p
|
||||
<div
|
||||
className={classNames(
|
||||
"text-sm font-medium",
|
||||
"flex flex-nowrap text-sm font-medium",
|
||||
isBookingPage && "dark:text-darkgray-600 text-gray-600"
|
||||
)}>
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
<Icon.FiClock
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 mr-[10px] ml-[2px] inline-block",
|
||||
isBookingPage && "mt-[2px]"
|
||||
)}
|
||||
/>
|
||||
{eventType.metadata?.multipleDuration !== undefined ? (
|
||||
!isBookingPage ? (
|
||||
<ul className="-mt-1 flex flex-wrap gap-1">
|
||||
{eventType.metadata.multipleDuration.map((dur, idx) => (
|
||||
<li key={idx}>
|
||||
<Badge
|
||||
variant="gray"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
duration === dur.toString()
|
||||
? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast"
|
||||
: "hover:bg-darkgray-200 dark:hover:bg-darkmodebrand hover:text-darkgray-900 dark:hover:text-darkmodebrandcontrast dark:bg-darkgray-200 bg-gray-200 text-gray-900 dark:text-white",
|
||||
"cursor-pointer"
|
||||
)}
|
||||
onClick={() => {
|
||||
setDuration(dur);
|
||||
}}>
|
||||
{dur} {t("minute_timeUnit")}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
`${duration} ${t("minutes")}`
|
||||
)
|
||||
) : (
|
||||
`${eventType.length} ${t("minutes")}`
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -8,15 +8,21 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { inferQueryInput, inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
import MeetingTimeInTimezones from "@calcom/ui/v2/core/MeetingTimeInTimezones";
|
||||
import Tooltip from "@calcom/ui/v2/core/Tooltip";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
MeetingTimeInTimezones,
|
||||
showToast,
|
||||
TextArea,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
@@ -24,13 +30,13 @@ import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["status"];
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringInfo: inferQueryOutput<"viewer.bookings">["recurringInfo"][number] | undefined;
|
||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
@@ -42,15 +48,19 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const mutation = trpc.useMutation(["viewer.bookings.confirm"], {
|
||||
onSuccess: () => {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
utils.invalidateQueries("viewer.bookings");
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.invalidateQueries("viewer.bookings");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,7 +118,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
|
||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/cancel/${booking.uid}${isTabRecurring && isRecurring ? "?allRemainingBookings=true" : ""}`,
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}`,
|
||||
icon: Icon.FiX,
|
||||
},
|
||||
{
|
||||
@@ -158,11 +170,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const setLocationMutation = trpc.useMutation("viewer.bookings.editLocation", {
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.invalidateQueries("viewer.bookings");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -181,26 +193,12 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
||||
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
||||
|
||||
const location = booking.location || "";
|
||||
|
||||
const onClickTableData = () => {
|
||||
router.push({
|
||||
pathname: "/success",
|
||||
pathname: `/booking/${booking.uid}`,
|
||||
query: {
|
||||
date: booking.startTime,
|
||||
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
|
||||
type: booking.eventType.id || 0,
|
||||
eventSlug: booking.eventType.slug,
|
||||
username: user?.username || "",
|
||||
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
|
||||
allRemainingBookings: isTabRecurring,
|
||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||
location: location,
|
||||
eventName: booking.eventType.eventName || "",
|
||||
bookingId: booking.id,
|
||||
recur: booking.recurringEventId,
|
||||
reschedule: isConfirmed,
|
||||
listingStatus: booking.listingStatus,
|
||||
status: booking.status,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -237,9 +235,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose />
|
||||
|
||||
<Button
|
||||
disabled={mutation.isLoading}
|
||||
@@ -284,6 +280,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.paid && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
||||
{t("paid")}
|
||||
</Badge>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||
@@ -467,8 +468,9 @@ const FirstAttendee = ({
|
||||
user: UserProps;
|
||||
currentEmail: string | null | undefined;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return user.email === currentEmail ? (
|
||||
<div className="inline-block">You</div>
|
||||
<div className="inline-block">{t("you")}</div>
|
||||
) : (
|
||||
<a
|
||||
key={user.email}
|
||||
@@ -480,19 +482,19 @@ const FirstAttendee = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Attendee: React.FC<{ email: string; children: React.ReactNode }> = ({ email, children }) => {
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const Attendee = ({ email, name }: AttendeeProps) => {
|
||||
return (
|
||||
<a className=" hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
<a className="hover:text-blue-500" href={"mailto:" + email} onClick={(e) => e.stopPropagation()}>
|
||||
{name || email}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
interface AttendeeProps {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const DisplayAttendees = ({
|
||||
attendees,
|
||||
user,
|
||||
@@ -500,42 +502,33 @@ const DisplayAttendees = ({
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
user: UserProps | null;
|
||||
currentEmail: string | null | undefined;
|
||||
currentEmail?: string | null;
|
||||
}) => {
|
||||
if (attendees.length === 1) {
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
<span> and </span>
|
||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
||||
</div>
|
||||
);
|
||||
} else if (attendees.length === 2) {
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
<span>, </span>
|
||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
||||
<div className="inline-block text-sm text-gray-900"> and </div>
|
||||
<Attendee email={attendees[1].email}>{attendees[1].name}</Attendee>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
<span>, </span>
|
||||
<Attendee email={attendees[0].email}>{attendees[0].name}</Attendee>
|
||||
<span> & </span>
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee, key) => (
|
||||
<p key={key}>{attendee.name}</p>
|
||||
))}>
|
||||
<div className="inline-block">{attendees.length - 1} more</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} />
|
||||
{attendees.length > 1 && (
|
||||
<>
|
||||
<div className="inline-block text-sm text-gray-900"> {t("and")} </div>
|
||||
{attendees.length > 2 ? (
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee) => (
|
||||
<p key={attendee.email}>
|
||||
<Attendee {...attendee} />
|
||||
</p>
|
||||
))}>
|
||||
<div className="inline-block">{t("plus_more", { count: attendees.length - 1 })}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Attendee {...attendees[1]} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button, Icon, TextArea } from "@calcom/ui";
|
||||
|
||||
type Props = {
|
||||
booking: {
|
||||
@@ -22,13 +21,14 @@ type Props = {
|
||||
team?: string | null;
|
||||
setIsCancellationMode: (value: boolean) => void;
|
||||
theme: string | null;
|
||||
allRemainingBookings: boolean;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { booking, profile, team } = props;
|
||||
const { booking, profile, team, allRemainingBookings } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
@@ -37,7 +37,7 @@ export default function CancelBooking(props: Props) {
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div>
|
||||
<div className="mt-8">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<Icon.FiX className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
@@ -51,30 +51,23 @@ export default function CancelBooking(props: Props) {
|
||||
{!error && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
||||
<textarea
|
||||
<TextArea
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="mt-2 mb-3 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:mb-3 "
|
||||
className="mt-2 mb-4 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white "
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse sm:flex-row">
|
||||
{!props.recurringEvent && (
|
||||
<div className="border-bookinglightest mt-5 flex w-full justify-center border-t pt-3 sm:mt-0 sm:justify-start sm:border-0 sm:pt-0">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="border-0 sm:border"
|
||||
onClick={() => router.push(`/reschedule/${booking?.uid}`)}>
|
||||
{t("reschedule_this")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 flex w-full space-x-2 text-right sm:mb-0 ">
|
||||
<Button color="secondary" onClick={() => props.setIsCancellationMode(false)}>
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
<div className="ml-auto flex w-full space-x-4 ">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
onClick={() => props.setIsCancellationMode(false)}>
|
||||
{t("nevermind")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex grow justify-center"
|
||||
className="flex justify-center"
|
||||
data-testid="cancel"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
@@ -82,6 +75,7 @@ export default function CancelBooking(props: Props) {
|
||||
const payload = {
|
||||
id: booking?.id,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
};
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
@@ -95,11 +89,7 @@ export default function CancelBooking(props: Props) {
|
||||
});
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
await router.push(
|
||||
`/cancel/success?name=${props.profile.name}&title=${booking?.title}&eventPage=${
|
||||
profile.slug
|
||||
}&team=${team ? 1 : 0}&recurring=${!!props.recurringEvent}`
|
||||
);
|
||||
await router.replace(router.asPath);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(
|
||||
@@ -110,7 +100,7 @@ export default function CancelBooking(props: Props) {
|
||||
}
|
||||
}}
|
||||
loading={loading}>
|
||||
{t("cancel_event")}
|
||||
{props.allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui/v2";
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/v2/core/TimezoneSelect";
|
||||
import { ITimezoneOption, TimezoneSelect } from "@calcom/ui";
|
||||
|
||||
import { timeZone } from "../../lib/clock";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export const UserAvatars = ({
|
||||
users,
|
||||
...props
|
||||
}: {
|
||||
profile: { image: string | null; name?: string | null };
|
||||
profile: { image: string | null; name?: string | null; username?: string | null };
|
||||
showMembers: boolean;
|
||||
users: { username: string | null; name?: string | null }[];
|
||||
} & Pick<AvatarGroupProps, "size" | "truncateAfter">) => {
|
||||
@@ -17,15 +17,26 @@ export const UserAvatars = ({
|
||||
border="border-2 dark:border-darkgray-100 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
{
|
||||
image: profile.image,
|
||||
alt: profile.name,
|
||||
title: profile.name,
|
||||
href: profile.username ? `${CAL_URL}/${profile.username}` : undefined,
|
||||
},
|
||||
...(showMembers
|
||||
? users.map((user) => ({
|
||||
title: user.name,
|
||||
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||
alt: user.name || undefined,
|
||||
href: user.username ? `${CAL_URL}/${user.username}` : undefined,
|
||||
}))
|
||||
: []),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
].filter((item) => !!item.image) as {
|
||||
image: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
href?: string;
|
||||
}[]
|
||||
}
|
||||
size={props.size}
|
||||
truncateAfter={props.truncateAfter}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { EventType } from "@prisma/client";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
@@ -14,12 +13,12 @@ import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedStyles,
|
||||
useEmbedUiConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
@@ -28,13 +27,10 @@ import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
|
||||
import { Icon, DatePicker } from "@calcom/ui";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
// import { timeZone } from "@lib/clock";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import Gates, { Gate, GateState } from "@components/Gates";
|
||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||
@@ -47,23 +43,6 @@ import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
// Get router variables
|
||||
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
|
||||
const router = useRouter();
|
||||
const path = router.asPath.split("/");
|
||||
path.pop(); // Remove the last item (where we currently are)
|
||||
path.shift(); // Removes first item e.g. if we were visitng "/teams/test/30mins" the array will new look like ["teams","test"]
|
||||
const slug = path.join("/");
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<button title={t("profile")} onClick={() => router.replace(`${WEBSITE_URL}/${slug}`)}>
|
||||
<Icon.FiArrowLeft className="dark:text-darkgray-600 h-4 w-4 text-black transition-opacity hover:cursor-pointer" />
|
||||
<p className="sr-only">Go Back</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useSlots = ({
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
@@ -71,6 +50,7 @@ const useSlots = ({
|
||||
endTime,
|
||||
usernameList,
|
||||
timeZone,
|
||||
duration,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
eventTypeSlug: string;
|
||||
@@ -78,20 +58,21 @@ const useSlots = ({
|
||||
endTime?: Dayjs;
|
||||
usernameList: string[];
|
||||
timeZone?: string;
|
||||
duration?: string;
|
||||
}) => {
|
||||
const { data, isLoading, isPaused } = trpc.useQuery(
|
||||
[
|
||||
"viewer.public.slots.getSchedule",
|
||||
{
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
usernameList,
|
||||
startTime: startTime?.toISOString() || "",
|
||||
endTime: endTime?.toISOString() || "",
|
||||
timeZone,
|
||||
},
|
||||
],
|
||||
{ enabled: !!startTime && !!endTime }
|
||||
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
||||
{
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
usernameList,
|
||||
startTime: startTime?.toISOString() || "",
|
||||
endTime: endTime?.toISOString() || "",
|
||||
timeZone,
|
||||
duration,
|
||||
},
|
||||
{
|
||||
enabled: !!startTime && !!endTime,
|
||||
}
|
||||
);
|
||||
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
||||
|
||||
@@ -128,6 +109,7 @@ const SlotPicker = ({
|
||||
}) => {
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||
const { duration } = useRouterQuery("duration");
|
||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||
const router = useRouter();
|
||||
@@ -153,7 +135,7 @@ const SlotPicker = ({
|
||||
setSelectedDate(dayjs.tz(date, timeZone));
|
||||
}
|
||||
}
|
||||
}, [router.isReady, month, date, timeZone]);
|
||||
}, [router.isReady, month, date, duration, timeZone]);
|
||||
|
||||
const { i18n, isLocaleReady } = useLocale();
|
||||
const { slots: _1 } = useSlots({
|
||||
@@ -163,6 +145,7 @@ const SlotPicker = ({
|
||||
startTime: selectedDate?.startOf("day"),
|
||||
endTime: selectedDate?.endOf("day"),
|
||||
timeZone,
|
||||
duration,
|
||||
});
|
||||
const { slots: _2, isLoading } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
@@ -171,6 +154,7 @@ const SlotPicker = ({
|
||||
startTime: browsingDate?.startOf("month"),
|
||||
endTime: browsingDate?.endOf("month"),
|
||||
timeZone,
|
||||
duration,
|
||||
});
|
||||
|
||||
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
||||
@@ -231,14 +215,14 @@ function TimezoneDropdown({
|
||||
|
||||
return (
|
||||
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 inline-block rounded-md px-2 py-2 text-left text-gray-600">
|
||||
<p className="text-sm font-medium">
|
||||
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
|
||||
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
|
||||
<p className="flex items-center text-sm font-medium">
|
||||
<Icon.FiGlobe className="min-h-4 min-w-4 mr-[10px] ml-[2px] -mt-[2px] inline-block" />
|
||||
{timeZone}
|
||||
{isTimeOptionsOpen ? (
|
||||
<Icon.FiChevronUp className="ml-1 inline-block h-4 w-4" />
|
||||
<Icon.FiChevronUp className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||
) : (
|
||||
<Icon.FiChevronDown className="ml-1 inline-block h-4 w-4" />
|
||||
<Icon.FiChevronDown className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||
)}
|
||||
</p>
|
||||
</Popover.Trigger>
|
||||
@@ -260,23 +244,6 @@ const dateQuerySchema = z.object({
|
||||
timeZone: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
const useRouterQuery = <T extends string>(name: T) => {
|
||||
const router = useRouter();
|
||||
const existingQueryParams = router.asPath.split("?")[1];
|
||||
|
||||
const urlParams = new URLSearchParams(existingQueryParams);
|
||||
const query = Object.fromEntries(urlParams);
|
||||
|
||||
const setQuery = (newValue: string | number | null | undefined) => {
|
||||
router.replace({ query: { ...router.query, [name]: newValue } }, undefined, { shallow: true });
|
||||
router.replace({ query: { ...router.query, ...query, [name]: newValue } }, undefined, { shallow: true });
|
||||
};
|
||||
|
||||
return { [name]: query[name], setQuery } as {
|
||||
[K in T]: string | undefined;
|
||||
} & { setQuery: typeof setQuery };
|
||||
};
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
@@ -287,6 +254,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
useTheme(profile.theme);
|
||||
const { t } = useLocale();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
//TODO: Plan to remove shouldAlignCentrallyInEmbed config
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
@@ -311,9 +279,6 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||
}, []);
|
||||
|
||||
// TODO: Improve this;
|
||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
@@ -326,7 +291,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
);
|
||||
}
|
||||
}, [telemetry]);
|
||||
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
// get dynamic user list here
|
||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||
|
||||
@@ -339,6 +304,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
|
||||
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||
|
||||
// Define conditional gates here
|
||||
const gates = [
|
||||
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
||||
@@ -372,7 +339,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
<div>
|
||||
<main
|
||||
className={classNames(
|
||||
"flex-col md:mx-4 lg:flex",
|
||||
"flex flex-col md:mx-4",
|
||||
shouldAlignCentrally ? "items-center" : "items-start",
|
||||
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
||||
)}>
|
||||
@@ -387,60 +354,55 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
isEmbed && "mx-auto"
|
||||
)}>
|
||||
<div className="overflow-hidden md:flex">
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
{showEventTypeDetails && (
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</BookingDescription>
|
||||
)}
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</BookingDescription>
|
||||
|
||||
{!isEmbed && (
|
||||
<div className="mt-auto hidden md:block">
|
||||
<GoBackToPreviousPage t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
||||
@@ -453,7 +415,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
@@ -479,7 +442,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
{/* FIXME: We don't show branding in Embed yet because we need to place branding on top of the main content. Keeping it outside the main content would have visibility issues because outside main content background is transparent */}
|
||||
{!restProps.isBrandingHidden && !isEmbed && <PoweredByCal />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState, useReducer } from "react";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
@@ -14,11 +14,11 @@ import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import {
|
||||
locationKeyToString,
|
||||
getEventLocationValue,
|
||||
getEventLocationType,
|
||||
EventLocationType,
|
||||
getEventLocationType,
|
||||
getEventLocationValue,
|
||||
getHumanReadableLocationValue,
|
||||
locationKeyToString,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
@@ -26,28 +26,26 @@ import { LocationObject, LocationType } from "@calcom/core/location";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedUiConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import AddressInput from "@calcom/ui/form/AddressInputLazy";
|
||||
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
|
||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||
import { AddressInput, Button, EmailInput, Form, Icon, PhoneInput, Tooltip } from "@calcom/ui";
|
||||
import { Group, RadioField } from "@calcom/ui";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { ensureArray } from "@lib/ensureArray";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
@@ -90,10 +88,9 @@ const BookingPage = ({
|
||||
...restProps
|
||||
}: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const { duration: queryDuration } = useRouterQuery("duration");
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const router = useRouter();
|
||||
@@ -108,6 +105,11 @@ const BookingPage = ({
|
||||
{}
|
||||
);
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
// Define duration now that we support multiple duration eventTypes
|
||||
let duration = eventType.length;
|
||||
if (queryDuration && !isNaN(Number(queryDuration))) {
|
||||
duration = Number(queryDuration);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
@@ -122,7 +124,7 @@ const BookingPage = ({
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { id, paymentUid } = responseData;
|
||||
const { uid, paymentUid } = responseData;
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
@@ -136,19 +138,11 @@ const BookingPage = ({
|
||||
}
|
||||
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
pathname: `/booking/${uid}`,
|
||||
query: {
|
||||
date,
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
username: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: bookingForm.getValues("name"),
|
||||
email: bookingForm.getValues("email"),
|
||||
location: responseData.location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -156,31 +150,14 @@ const BookingPage = ({
|
||||
|
||||
const recurringMutation = useMutation(createRecurringBooking, {
|
||||
onSuccess: async (responseData = []) => {
|
||||
const { attendees = [], id, recurringEventId } = responseData[0] || {};
|
||||
const location = (function humanReadableLocation(location) {
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (location.includes("integration")) {
|
||||
return t("web_conferencing_details_to_follow");
|
||||
}
|
||||
return location;
|
||||
})(responseData[0].location);
|
||||
const { uid } = responseData[0] || {};
|
||||
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
pathname: `/booking/${uid}`,
|
||||
query: {
|
||||
date,
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
recur: recurringEventId,
|
||||
username: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -265,7 +242,7 @@ const BookingPage = ({
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
email: z.string().trim().email(),
|
||||
phone: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
@@ -340,13 +317,29 @@ const BookingPage = ({
|
||||
{}
|
||||
);
|
||||
|
||||
if (eventType.customInputs.length > 0) {
|
||||
// find all required custom inputs and ensure they are filled out in the booking form
|
||||
const requiredCustomInputs = eventType.customInputs.filter((input) => input.required);
|
||||
const missingRequiredCustomInputs = requiredCustomInputs.filter(
|
||||
(input) => !booking?.customInputs?.[input.id]
|
||||
);
|
||||
if (missingRequiredCustomInputs.length > 0) {
|
||||
missingRequiredCustomInputs.forEach((input) => {
|
||||
bookingForm.setError(`customInputs.${input.id}`, {
|
||||
type: "required",
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (recurringDates.length) {
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...booking,
|
||||
start: dayjs(recurringDate).format(),
|
||||
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
||||
end: dayjs(recurringDate).add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
@@ -379,7 +372,7 @@ const BookingPage = ({
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
end: dayjs(date).add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
@@ -431,6 +424,7 @@ const BookingPage = ({
|
||||
}
|
||||
});
|
||||
}
|
||||
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
|
||||
// Define conditional gates here
|
||||
@@ -454,7 +448,7 @@ const BookingPage = ({
|
||||
eventTypeTitle: eventType.title,
|
||||
profileName: profile.name,
|
||||
})}{" "}
|
||||
| Cal.com
|
||||
| {APP_NAME}
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
@@ -464,7 +458,7 @@ const BookingPage = ({
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "" : "sm:my-24",
|
||||
"my-0 max-w-3xl "
|
||||
"my-0 max-w-3xl"
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -473,95 +467,97 @@ const BookingPage = ({
|
||||
"dark:border-darkgray-300 rounded-md sm:border"
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
||||
<Icon.FiRefreshCw className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringEventCount,
|
||||
})}
|
||||
{showEventTypeDetails && (
|
||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
||||
<div className="text-sm font-medium">
|
||||
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
||||
{!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
||||
return <p key={key}>{timeFormatted}</p>;
|
||||
})}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||
<p key={key}>{timeFormatted}</p>
|
||||
))}>
|
||||
<p className="dark:text-darkgray-600 text-sm">
|
||||
{t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="line-through ">
|
||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
||||
<Icon.FiRefreshCw className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiUser
|
||||
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
} mb-2 font-medium`}>
|
||||
{booking
|
||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||
</p>
|
||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
||||
<div className="text-sm font-medium">
|
||||
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
||||
{!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
||||
return <p key={key}>{timeFormatted}</p>;
|
||||
})}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||
<p key={key}>{timeFormatted}</p>
|
||||
))}>
|
||||
<p className="dark:text-darkgray-600 text-sm">
|
||||
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</BookingDescription>
|
||||
</div>
|
||||
<div className="p-6 sm:w-1/2">
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="line-through ">
|
||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiUser
|
||||
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
} mb-2 font-medium`}>
|
||||
{booking
|
||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</BookingDescription>
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames("p-6", showEventTypeDetails ? "sm:w-1/2" : "w-full")}>
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
@@ -650,7 +646,7 @@ const BookingPage = ({
|
||||
)}
|
||||
</>
|
||||
{/* TODO: Change name and id ="phone" to something generic */}
|
||||
{AttendeeInput && (
|
||||
{AttendeeInput && !disableInput && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor={
|
||||
@@ -687,7 +683,6 @@ const BookingPage = ({
|
||||
: ""
|
||||
}
|
||||
required
|
||||
disabled={disableInput}
|
||||
/>
|
||||
</div>
|
||||
{bookingForm.formState.errors.phone && (
|
||||
@@ -705,8 +700,11 @@ const BookingPage = ({
|
||||
{input.type !== EventTypeCustomInputType.BOOL && (
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
className={classNames(
|
||||
"mb-1 block text-sm font-medium text-gray-700 transition-colors dark:text-white",
|
||||
bookingForm.formState.errors.customInputs?.[input.id] && "!text-red-700"
|
||||
)}>
|
||||
{input.label} {input.required && <span className="text-red-700">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
||||
@@ -770,6 +768,31 @@ const BookingPage = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{input.options && input.type === EventTypeCustomInputType.RADIO && (
|
||||
<div className="flex">
|
||||
<Group
|
||||
required={input.required}
|
||||
onValueChange={(e) => {
|
||||
bookingForm.setValue(`customInputs.${input.id}`, e);
|
||||
}}>
|
||||
<>
|
||||
{input.options.map((option, i) => (
|
||||
<RadioField
|
||||
label={option.label}
|
||||
key={`option.${i}.radio`}
|
||||
value={option.label}
|
||||
id={`option.${i}.radio`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
{bookingForm.formState.errors.customInputs?.[input.id] && (
|
||||
<div className="mt-px flex items-center text-xs text-red-700 ">
|
||||
<p>{t("required")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!eventType.disableGuests && guestToggle && (
|
||||
@@ -868,7 +891,7 @@ const BookingPage = ({
|
||||
) : (
|
||||
<textarea
|
||||
{...bookingForm.register("notes")}
|
||||
required={!!eventType.metadata.additionalNotesRequired}
|
||||
required={!!eventType.metadata?.additionalNotesRequired}
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
@@ -883,7 +906,7 @@ const BookingPage = ({
|
||||
{!eventType.disableGuests && !guestToggle && (
|
||||
<Button
|
||||
type="button"
|
||||
color="minimalSecondary"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
tooltip={t("additional_guests")}
|
||||
StartIcon={Icon.FiUserPlus}
|
||||
@@ -901,7 +924,6 @@ const BookingPage = ({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast mr-auto dark:border-transparent"
|
||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||
loading={mutation.isLoading || recurringMutation.isLoading}>
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
@@ -934,7 +956,7 @@ function ErrorMessage({ error }: { error: unknown }) {
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||
{error instanceof HttpError || error instanceof Error ? error.message : "Unknown error"}
|
||||
{error instanceof HttpError || error instanceof Error ? t(error.message) : "Unknown error"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
export type DeleteStripeDialogContentProps = {
|
||||
cancelAllBookingsBtnText?: string;
|
||||
removeBtnText?: string;
|
||||
cancelBtnText?: string;
|
||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
onRemove?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
title: string;
|
||||
variety?: "danger" | "warning" | "success";
|
||||
};
|
||||
|
||||
export default function DeleteStripeDialogContent(props: PropsWithChildren<DeleteStripeDialogContentProps>) {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
title,
|
||||
variety,
|
||||
cancelAllBookingsBtnText,
|
||||
removeBtnText,
|
||||
cancelBtnText = t("cancel"),
|
||||
onConfirm,
|
||||
onRemove,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<div className="flex">
|
||||
{variety && (
|
||||
<div className="mt-0.5 ltr:mr-3">
|
||||
{variety === "danger" && (
|
||||
<div className="mx-auto rounded-full bg-red-100 p-2 text-center">
|
||||
<Icon.FiAlertTriangle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "warning" && (
|
||||
<div className="mx-auto rounded-full bg-orange-100 p-2 text-center">
|
||||
<Icon.FiAlertTriangle className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "success" && (
|
||||
<div className="mx-auto rounded-full bg-green-100 p-2 text-center">
|
||||
<Icon.FiCheck className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DialogPrimitive.Title className="font-cal text-xl text-gray-900">{title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-sm text-neutral-500">
|
||||
{children}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
||||
<DialogClose onClick={onConfirm} asChild>
|
||||
<Button color="alert">{cancelAllBookingsBtnText}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose onClick={onRemove} asChild>
|
||||
<Button color="alert2">{removeBtnText}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{cancelBtnText}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -3,30 +3,28 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { components } from "react-select";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
LocationType,
|
||||
getEventLocationType,
|
||||
EventLocationType,
|
||||
getEventLocationType,
|
||||
getHumanReadableLocationValue,
|
||||
getMessageForOrganizer,
|
||||
LocationObject,
|
||||
LocationType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { getMessageForOrganizer } from "@calcom/app-store/locations";
|
||||
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, Form, Icon, PhoneInput } from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
type OptionTypeBase = {
|
||||
label: string;
|
||||
@@ -42,6 +40,7 @@ interface ISetLocationDialog {
|
||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isOpenDialog: boolean;
|
||||
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
|
||||
setEditingLocationType?: (param: string) => void;
|
||||
}
|
||||
|
||||
const LocationInput = (props: {
|
||||
@@ -79,9 +78,10 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
isOpenDialog,
|
||||
defaultValues,
|
||||
setSelectedLocation,
|
||||
setEditingLocationType,
|
||||
} = props;
|
||||
const { t } = useLocale();
|
||||
const locationsQuery = trpc.useQuery(["viewer.locationOptions"]);
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (selection) {
|
||||
@@ -212,13 +212,13 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog}>
|
||||
<DialogContent asChild>
|
||||
<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">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon.FiMapPin className="text-primary-600 h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon.FiMapPin className="text-primary-600 h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("edit_location")}
|
||||
</h3>
|
||||
@@ -226,118 +226,152 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left" />
|
||||
</div>
|
||||
{booking && (
|
||||
<>
|
||||
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
|
||||
<p className="mb-2 ml-1 text-sm text-black">
|
||||
{getHumanReadableLocationValue(booking.location, t)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Form
|
||||
form={locationFormMethods}
|
||||
handleSubmit={async (values) => {
|
||||
const { locationType: newLocation, displayLocationPublicly } = values;
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
||||
|
||||
let details = {};
|
||||
if (newLocation === LocationType.InPerson) {
|
||||
details = {
|
||||
address: values.locationAddress,
|
||||
};
|
||||
}
|
||||
const eventLocationType = getEventLocationType(newLocation);
|
||||
{booking && (
|
||||
<>
|
||||
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
|
||||
<p className="mb-2 ml-1 text-sm text-black">
|
||||
{getHumanReadableLocationValue(booking.location, t)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Form
|
||||
form={locationFormMethods}
|
||||
handleSubmit={async (values) => {
|
||||
const { locationType: newLocation, displayLocationPublicly } = values;
|
||||
|
||||
// TODO: There can be a property that tells if it is to be saved in `link`
|
||||
if (
|
||||
newLocation === LocationType.Link ||
|
||||
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
||||
) {
|
||||
details = { link: values.locationLink };
|
||||
}
|
||||
let details = {};
|
||||
if (newLocation === LocationType.InPerson) {
|
||||
details = {
|
||||
address: values.locationAddress,
|
||||
};
|
||||
}
|
||||
const eventLocationType = getEventLocationType(newLocation);
|
||||
|
||||
if (newLocation === LocationType.UserPhone) {
|
||||
details = { hostPhoneNumber: values.locationPhoneNumber };
|
||||
}
|
||||
// TODO: There can be a property that tells if it is to be saved in `link`
|
||||
if (
|
||||
newLocation === LocationType.Link ||
|
||||
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
||||
) {
|
||||
details = { link: values.locationLink };
|
||||
}
|
||||
|
||||
if (eventLocationType?.organizerInputType) {
|
||||
details = {
|
||||
...details,
|
||||
displayLocationPublicly,
|
||||
};
|
||||
}
|
||||
if (newLocation === LocationType.UserPhone) {
|
||||
details = { hostPhoneNumber: values.locationPhoneNumber };
|
||||
}
|
||||
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
locationFormMethods.unregister([
|
||||
"locationType",
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
}}>
|
||||
<QueryCell
|
||||
query={locationsQuery}
|
||||
success={({ data: locationOptions }) => {
|
||||
if (!locationOptions.length) return null;
|
||||
return (
|
||||
<Controller
|
||||
name="locationType"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<Select
|
||||
maxMenuHeight={150}
|
||||
name="location"
|
||||
defaultValue={selection}
|
||||
options={
|
||||
booking
|
||||
? locationOptions.filter(
|
||||
(location) =>
|
||||
location.value !== "phone" && location.value !== "attendeeInPerson"
|
||||
)
|
||||
: locationOptions
|
||||
}
|
||||
isSearchable
|
||||
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
locationFormMethods.unregister([
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
locationFormMethods.clearErrors([
|
||||
"locationLink",
|
||||
"locationPhoneNumber",
|
||||
"locationAddress",
|
||||
]);
|
||||
setSelectedLocation?.(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
locationFormMethods.unregister("locationType");
|
||||
if (eventLocationType?.organizerInputType) {
|
||||
details = {
|
||||
...details,
|
||||
displayLocationPublicly,
|
||||
};
|
||||
}
|
||||
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
locationFormMethods.unregister([
|
||||
"locationType",
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
}}>
|
||||
<QueryCell
|
||||
query={locationsQuery}
|
||||
success={({ data: locationOptions }) => {
|
||||
if (!locationOptions.length) return null;
|
||||
if (booking) {
|
||||
locationOptions.forEach((location) => {
|
||||
if (location.label === "phone") {
|
||||
location.options.filter((l) => l.value !== "phone");
|
||||
} else if (location.label === "in person") {
|
||||
location.options.filter((l) => l.value !== "attendeeInPerson");
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Controller
|
||||
name="locationType"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<Select<{ label: string; value: string; icon?: string }>
|
||||
maxMenuHeight={300}
|
||||
name="location"
|
||||
defaultValue={selection}
|
||||
options={locationOptions}
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<div className="flex items-center gap-3">
|
||||
{props.data.icon && (
|
||||
<img src={props.data.icon} alt="cover" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span
|
||||
className={classNames(
|
||||
"text-sm font-medium",
|
||||
props.isSelected ? "text-white" : "text-gray-900"
|
||||
)}>
|
||||
{props.data.label}
|
||||
</span>
|
||||
</div>
|
||||
</components.Option>
|
||||
),
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
formatGroupLabel={(e) => (
|
||||
<p className="text-xs font-medium text-gray-600">{e.label}</p>
|
||||
)}
|
||||
isSearchable
|
||||
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
locationFormMethods.unregister([
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
locationFormMethods.clearErrors([
|
||||
"locationLink",
|
||||
"locationPhoneNumber",
|
||||
"locationAddress",
|
||||
]);
|
||||
setSelectedLocation?.(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("update")}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<DialogFooter>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
setEditingLocationType?.("");
|
||||
locationFormMethods.unregister("locationType");
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button type="submit">{t("update")}</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import React, { useState, Dispatch, SetStateAction } from "react";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
showToast,
|
||||
TextArea,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface IRescheduleDialog {
|
||||
isOpenDialog: boolean;
|
||||
@@ -20,11 +26,11 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
|
||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||
|
||||
const { mutate: rescheduleApi, isLoading } = trpc.useMutation("viewer.bookings.requestReschedule", {
|
||||
const { mutate: rescheduleApi, isLoading } = trpc.viewer.bookings.requestReschedule.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(t("reschedule_request_sent"), "success");
|
||||
setIsOpenDialog(false);
|
||||
await utils.invalidateQueries(["viewer.bookings"]);
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError() {
|
||||
showToast(t("unexpected_error_try_again"), "error");
|
||||
@@ -41,8 +47,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
</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="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>
|
||||
@@ -56,9 +61,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose />
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={isLoading}
|
||||
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
import { FormValues } from "pages/event-types/[type]";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { SingleValueProps, OptionProps, components } from "react-select";
|
||||
import { components, OptionProps, SingleValueProps } from "react-select";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
@@ -8,12 +8,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { SettingsToggle } from "@calcom/ui/v2";
|
||||
import Select from "@calcom/ui/v2/core/form/select";
|
||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
import { Badge, Button, Icon, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
|
||||
@@ -25,12 +20,13 @@ type AvailabilityOption = {
|
||||
|
||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||
const { label, isDefault } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
Default
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.Option>
|
||||
@@ -39,12 +35,13 @@ const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||
|
||||
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||
const { label, isDefault } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.SingleValue {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
Default
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.SingleValue>
|
||||
@@ -61,7 +58,9 @@ const AvailabilitySelect = ({
|
||||
onBlur: () => void;
|
||||
onChange: (value: AvailabilityOption | null) => void;
|
||||
}) => {
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.availability.list"]);
|
||||
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
if (isLoading) {
|
||||
return <SelectSkeletonLoader />;
|
||||
}
|
||||
@@ -82,6 +81,7 @@ const AvailabilitySelect = ({
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
onChange={props.onChange}
|
||||
@@ -183,7 +183,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
};
|
||||
|
||||
const scheduleId = watch("schedule");
|
||||
const { isLoading, data: schedule } = trpc.useQuery(["viewer.availability.schedule", { scheduleId }]);
|
||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId });
|
||||
|
||||
const filterDays = (dayNum: number) =>
|
||||
schedule?.schedule.availability.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||
@@ -1,349 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
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 "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Form, InputLeading, TextAreaField, TextField } from "@calcom/ui/form/fields";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { slugify } from "@lib/slugify";
|
||||
|
||||
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
|
||||
export interface EventTypeParent {
|
||||
teamId: number | null | undefined; // if undefined, then it's a profile
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
interface CreateEventTypeBtnProps {
|
||||
// set true for use on the team settings page
|
||||
canAddEvents: boolean;
|
||||
// set true when in use on the team settings page
|
||||
isIndividualTeam?: boolean;
|
||||
// EventTypeParent can be a profile (as first option) or a team for the rest.
|
||||
options: EventTypeParent[];
|
||||
}
|
||||
|
||||
export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const router = useRouter();
|
||||
// URL encoded params
|
||||
const teamId: number | undefined =
|
||||
typeof router.query.teamId === "string" && router.query.teamId
|
||||
? parseInt(router.query.teamId)
|
||||
: undefined;
|
||||
const pageSlug = router.query.eventPage || props.options[0].slug;
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
||||
|
||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||
resolver: zodResolver(createEventTypeInput),
|
||||
});
|
||||
const { setValue, watch, register } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
|
||||
const title: string =
|
||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
||||
const length: number =
|
||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
||||
const description: string =
|
||||
typeof router.query.description === "string" && router.query.description
|
||||
? router.query.description
|
||||
: "";
|
||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
||||
|
||||
setValue("title", title);
|
||||
setValue("length", length);
|
||||
setValue("description", description);
|
||||
setValue("slug", slug);
|
||||
// If query params change, update the form
|
||||
}, [router.isReady, router.query, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name, type }) => {
|
||||
if (name === "title" && type === "change") {
|
||||
if (value.title) setValue("slug", slugify(value.title));
|
||||
else setValue("slug", "");
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, setValue]);
|
||||
|
||||
const createMutation = trpc.useMutation("viewer.eventTypes.create", {
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.replace("/event-types/" + eventType.id);
|
||||
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "BAD_REQUEST") {
|
||||
const message = `${err.data.code}: URL already exists.`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
const message = `${err.data.code}: You are not able to create this event`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openModal = (option: EventTypeParent) => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "new-eventtype",
|
||||
eventPage: option.slug,
|
||||
teamId: option.teamId,
|
||||
};
|
||||
if (!option.teamId) {
|
||||
delete query.teamId;
|
||||
}
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
if (!isLocaleReady) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
name="new-eventtype"
|
||||
clearQueryParamsOnClose={["eventPage", "teamId", "type", "description", "title", "length", "slug"]}>
|
||||
<CreateEventTypeTrigger
|
||||
hasTeams={hasTeams}
|
||||
canAddEvents={props.canAddEvents}
|
||||
isIndividualTeam={props.isIndividualTeam}
|
||||
openModal={openModal}
|
||||
options={props.options}
|
||||
/>
|
||||
|
||||
<DialogContent>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
|
||||
</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">
|
||||
{teamId && (
|
||||
<TextField
|
||||
type="hidden"
|
||||
labelProps={{ style: { display: "none" } }}
|
||||
{...register("teamId", { valueAsNumber: true })}
|
||||
value={teamId}
|
||||
/>
|
||||
)}
|
||||
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
|
||||
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
|
||||
<TextField
|
||||
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
|
||||
required
|
||||
addOnLeading={<InputLeading>/{pageSlug}/</InputLeading>}
|
||||
{...register("slug")}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
label={t("url")}
|
||||
required
|
||||
addOnLeading={
|
||||
<InputLeading>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
||||
</InputLeading>
|
||||
}
|
||||
{...register("slug")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextAreaField
|
||||
label={t("description")}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<TextField
|
||||
type="number"
|
||||
required
|
||||
min="10"
|
||||
placeholder="15"
|
||||
label={t("length")}
|
||||
className="pr-20"
|
||||
{...register("length", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 mt-1.5 flex items-center pt-4 pr-3 text-sm text-gray-400">
|
||||
{t("minutes")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{teamId && (
|
||||
<div className="mb-4">
|
||||
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
|
||||
{t("scheduling_type")}
|
||||
</label>
|
||||
{form.formState.errors.schedulingType && (
|
||||
<Alert
|
||||
className="mt-1"
|
||||
severity="error"
|
||||
message={form.formState.errors.schedulingType.message}
|
||||
/>
|
||||
)}
|
||||
<RadioArea.Group
|
||||
{...register("schedulingType")}
|
||||
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
|
||||
className="relative mt-1 flex space-x-6 rounded-sm rtl:space-x-reverse">
|
||||
<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}
|
||||
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>
|
||||
</RadioArea.Group>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
type CreateEventTypeTrigger = {
|
||||
isIndividualTeam?: boolean;
|
||||
// EventTypeParent can be a profile (as first option) or a team for the rest.
|
||||
options: EventTypeParent[];
|
||||
hasTeams: boolean;
|
||||
// set true for use on the team settings page
|
||||
canAddEvents: boolean;
|
||||
openModal: (option: EventTypeParent) => void;
|
||||
};
|
||||
|
||||
export function CreateEventTypeTrigger(props: CreateEventTypeTrigger) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!props.hasTeams || props.isIndividualTeam ? (
|
||||
<Button
|
||||
onClick={() => props.openModal(props.options[0])}
|
||||
data-testid="new-event-type"
|
||||
StartIcon={Icon.FiPlus}
|
||||
disabled={!props.canAddEvents}>
|
||||
{t("new_event_type_btn")}
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button EndIcon={Icon.FiChevronDown}>{t("new_event_type_btn")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("new_event_subtitle")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{props.options.map((option) => (
|
||||
<CreateEventTeamsItem
|
||||
key={option.slug}
|
||||
option={option}
|
||||
openModal={() => props.openModal(option)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateEventTeamsItem(props: {
|
||||
openModal: (option: EventTypeParent) => void;
|
||||
option: EventTypeParent;
|
||||
}) {
|
||||
const session = useSession();
|
||||
const membershipQuery = trpc.useQuery([
|
||||
"viewer.teams.getMembershipbyUser",
|
||||
{
|
||||
memberId: session.data?.user.id as number,
|
||||
teamId: props.option.teamId as number,
|
||||
},
|
||||
]);
|
||||
|
||||
const isDisabled = membershipQuery.data?.role === "MEMBER";
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={props.option.slug}
|
||||
className={classNames(
|
||||
"cursor-pointer px-3 py-2 focus:outline-none",
|
||||
isDisabled ? "cursor-default !text-gray-300" : "hover:bg-neutral-100"
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onSelect={() => props.openModal(props.option)}>
|
||||
<Avatar
|
||||
alt={props.option.name || ""}
|
||||
imageSrc={props.option.image || WEBAPP_URL + "/" + props.option.slug + "/avatar.png"}
|
||||
size={6}
|
||||
className="inline ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
{props.option.name ? props.option.name : props.option.slug}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import type { CustomInputParsed } from "pages/event-types/[type]";
|
||||
import { FC } from "react";
|
||||
import { Control, Controller, useFieldArray, useForm, UseFormRegister, useWatch } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Icon, Label, Select, TextField } from "@calcom/ui";
|
||||
|
||||
interface OptionTypeBase {
|
||||
label: string;
|
||||
value: EventTypeCustomInputType;
|
||||
options?: { label: string; type: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (output: CustomInputParsed) => void;
|
||||
onCancel: () => void;
|
||||
selectedCustomInput?: CustomInputParsed;
|
||||
}
|
||||
|
||||
type IFormInput = CustomInputParsed;
|
||||
|
||||
/**
|
||||
* Getting a random ID gives us the option to know WHICH field is changed
|
||||
* when the user edits a custom field.
|
||||
* This UUID is only used to check for changes in the UI and not the ID we use in the DB
|
||||
* There is very very very slim chance that this will cause a collision
|
||||
* */
|
||||
const randomId = () => Math.floor(Math.random() * 1000000 + new Date().getTime());
|
||||
|
||||
const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const inputOptions: OptionTypeBase[] = [
|
||||
{ value: EventTypeCustomInputType.TEXT, label: t("text") },
|
||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||
{
|
||||
value: EventTypeCustomInputType.RADIO,
|
||||
label: t("radio"),
|
||||
},
|
||||
];
|
||||
|
||||
const { selectedCustomInput } = props;
|
||||
|
||||
const defaultValues = selectedCustomInput
|
||||
? { ...selectedCustomInput, id: selectedCustomInput?.id || randomId() }
|
||||
: {
|
||||
id: randomId(),
|
||||
type: EventTypeCustomInputType.TEXT,
|
||||
};
|
||||
|
||||
const { register, control, getValues } = useForm<IFormInput>({
|
||||
defaultValues,
|
||||
});
|
||||
const selectedInputType = useWatch({ name: "type", control });
|
||||
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value);
|
||||
|
||||
const onCancel = () => {
|
||||
props.onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
{t("input_type")}
|
||||
</label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
id="type"
|
||||
defaultValue={selectedInputOption}
|
||||
options={inputOptions}
|
||||
isSearchable={false}
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
|
||||
onChange={(option) => option && field.onChange(option.value)}
|
||||
value={selectedInputOption}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label={t("label")}
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
{(selectedInputType === EventTypeCustomInputType.TEXT ||
|
||||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
|
||||
<TextField
|
||||
label={t("placeholder")}
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
)}
|
||||
{selectedInputType === EventTypeCustomInputType.RADIO && (
|
||||
<RadioInputHandler control={control} register={register} />
|
||||
)}
|
||||
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="required"
|
||||
type="checkbox"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 ltr:mr-2 rtl:ml-2"
|
||||
defaultChecked={selectedCustomInput?.required ?? true}
|
||||
{...register("required")}
|
||||
/>
|
||||
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||
{t("is_required")}
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
id="eventTypeId"
|
||||
value={selectedCustomInput?.eventTypeId || -1}
|
||||
{...register("eventTypeId", { valueAsNumber: true })}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
id="id"
|
||||
value={selectedCustomInput?.id || -1}
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.onSubmit(getValues());
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function RadioInputHandler({
|
||||
register,
|
||||
control,
|
||||
}: {
|
||||
register: UseFormRegister<IFormInput>;
|
||||
control: Control<IFormInput>;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { fields, append, remove } = useFieldArray<IFormInput>({
|
||||
control,
|
||||
name: "options",
|
||||
shouldUnregister: true,
|
||||
});
|
||||
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col ">
|
||||
<Label htmlFor="radio_options">{t("options")}</Label>
|
||||
<ul
|
||||
className="flex max-h-80 w-full flex-col space-y-1 overflow-y-scroll rounded-md bg-gray-50 p-4"
|
||||
ref={animateRef}>
|
||||
<>
|
||||
{fields.map((option, index) => (
|
||||
<li key={`${option.id}`}>
|
||||
<TextField
|
||||
id={option.id}
|
||||
placeholder={t("enter_option", { index: index + 1 })}
|
||||
addOnFilled={false}
|
||||
label={t("option", { index: index + 1 })}
|
||||
labelSrOnly
|
||||
{...register(`options.${index}.label` as const, { required: true })}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiX}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiPlus}
|
||||
className="!text-sm !font-medium"
|
||||
onClick={() => {
|
||||
append({ label: "", type: "text" });
|
||||
}}>
|
||||
{t("add_an_option")}
|
||||
</Button>
|
||||
</>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomInputTypeForm;
|
||||
+61
-41
@@ -1,20 +1,33 @@
|
||||
import { EventTypeCustomInput } from "@prisma/client/";
|
||||
import Link from "next/link";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import type { CustomInputParsed, EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Checkbox, Button, TextField, Label } from "@calcom/ui/components";
|
||||
import { CustomInputItem, Dialog, DialogContent, SettingsToggle, showToast, Tooltip } from "@calcom/ui/v2";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
CustomInputItem,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Icon,
|
||||
Label,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import CustomInputTypeForm from "@components/v2/eventtype/CustomInputTypeForm";
|
||||
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
const generateHashedLink = (id: number) => {
|
||||
const translator = short();
|
||||
@@ -24,18 +37,19 @@ const generateHashedLink = (id: number) => {
|
||||
};
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||
const [customInputs, setCustomInputs] = useState<CustomInputParsed[]>(
|
||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<CustomInputParsed | undefined>(undefined);
|
||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
|
||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||
@@ -74,6 +88,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
defaultValue={eventType.destinationCalendar || undefined}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DestinationCalendarSelector
|
||||
destinationCalendar={eventType.destinationCalendar}
|
||||
value={value ? value.externalId : undefined}
|
||||
onChange={onChange}
|
||||
hidePlaceholder
|
||||
@@ -88,7 +103,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
<TextField
|
||||
label={t("event_name")}
|
||||
type="text"
|
||||
placeholder={t("meeting_with_user")}
|
||||
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
@@ -97,7 +112,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
StartIcon={Icon.FiEdit}
|
||||
size="icon"
|
||||
color="minimal"
|
||||
className="hover:stroke-3 hover:bg-transparent hover:text-black"
|
||||
className="hover:stroke-3 min-w-fit px-0 hover:bg-transparent hover:text-black"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}
|
||||
/>
|
||||
}
|
||||
@@ -112,15 +127,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
onCheckedChange={(e) => {
|
||||
if (e && customInputs.length === 0) {
|
||||
// Push a placeholders
|
||||
setSelectedCustomInput(undefined);
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
} else if (!e) {
|
||||
setCustomInputs([]);
|
||||
formMethods.setValue("customInputs", []);
|
||||
}
|
||||
}}>
|
||||
<ul className="my-4">
|
||||
{customInputs.map((customInput: EventTypeCustomInput, idx: number) => (
|
||||
<ul className="my-4 rounded-md border">
|
||||
{customInputs.map((customInput, idx) => (
|
||||
<CustomInputItem
|
||||
key={idx}
|
||||
question={customInput.label}
|
||||
@@ -143,24 +156,17 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
setSelectedCustomInput(undefined);
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
}}>
|
||||
Add an input
|
||||
{t("add_input")}
|
||||
</Button>
|
||||
)}
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
<hr />
|
||||
<Controller
|
||||
name="requiresConfirmation"
|
||||
defaultValue={eventType.requiresConfirmation}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("requires_confirmation")}
|
||||
description={t("requires_confirmation_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
disabled={seatsEnabled}
|
||||
/>
|
||||
)}
|
||||
<RequiresConfirmationController
|
||||
seatsEnabled={seatsEnabled}
|
||||
metadata={eventType.metadata}
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
@@ -243,7 +249,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
<SettingsToggle
|
||||
data-testid="hashedLinkCheck"
|
||||
title={t("private_link")}
|
||||
description={t("private_link_description")}
|
||||
description={t("private_link_description", { appName: APP_NAME })}
|
||||
checked={hashedLinkVisible}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||
@@ -296,6 +302,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
if (e) {
|
||||
formMethods.setValue("disableGuests", true);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
@@ -341,15 +348,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
<DialogContent
|
||||
title={t("custom_event_name")}
|
||||
description={t("custom_event_name_description")}
|
||||
type="creation"
|
||||
actionText="Create"
|
||||
// Set event name back to what it was on close
|
||||
actionOnClose={() => formMethods.setValue("eventName", eventType.eventName ?? "")}
|
||||
actionOnClick={() => setShowEventNameTip(false)}>
|
||||
type="creation">
|
||||
<TextField
|
||||
label={t("event_name")}
|
||||
type="text"
|
||||
placeholder={t("meeting_with_user")}
|
||||
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
/>
|
||||
@@ -359,6 +362,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
|
||||
<p>{`{LOCATION} = ${t("event_location")}`}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")} />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
@@ -372,29 +381,40 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
type="creation"
|
||||
Icon={Icon.FiPlus}
|
||||
title={t("add_new_custom_input_field")}
|
||||
useOwnActionButtons
|
||||
description={t("this_input_will_shown_booking_this_event")}>
|
||||
<CustomInputTypeForm
|
||||
selectedCustomInput={selectedCustomInput}
|
||||
onSubmit={(values) => {
|
||||
const customInput: EventTypeCustomInput = {
|
||||
const customInput: CustomInputParsed = {
|
||||
id: -1,
|
||||
eventTypeId: -1,
|
||||
label: values.label,
|
||||
placeholder: values.placeholder,
|
||||
required: values.required,
|
||||
type: values.type,
|
||||
options: values.options,
|
||||
};
|
||||
|
||||
if (selectedCustomInput) {
|
||||
selectedCustomInput.label = customInput.label;
|
||||
selectedCustomInput.placeholder = customInput.placeholder;
|
||||
selectedCustomInput.required = customInput.required;
|
||||
selectedCustomInput.type = customInput.type;
|
||||
selectedCustomInput.options = customInput.options || undefined;
|
||||
// Update by id
|
||||
const inputIndex = customInputs.findIndex((input) => input.id === values.id);
|
||||
customInputs[inputIndex] = selectedCustomInput;
|
||||
setCustomInputs(customInputs);
|
||||
formMethods.setValue("customInputs", customInputs);
|
||||
} else {
|
||||
setCustomInputs(customInputs.concat(customInput));
|
||||
formMethods.setValue("customInputs", customInputs.concat(customInput));
|
||||
const concatted = customInputs.concat({
|
||||
...customInput,
|
||||
options: customInput.options,
|
||||
});
|
||||
console.log(concatted);
|
||||
setCustomInputs(concatted);
|
||||
formMethods.setValue("customInputs", concatted);
|
||||
}
|
||||
|
||||
setSelectedCustomInputModalOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
+6
-13
@@ -1,5 +1,4 @@
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||
@@ -7,11 +6,8 @@ import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated";
|
||||
import { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
||||
import { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import ErrorBoundary from "@calcom/ui/ErrorBoundary";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { EmptyScreen } from "@calcom/ui/v2";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Button, EmptyScreen, ErrorBoundary, Icon } from "@calcom/ui";
|
||||
|
||||
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
|
||||
EventTypeAppCardComponentProps["eventType"];
|
||||
@@ -22,7 +18,7 @@ function AppCardWrapper({
|
||||
getAppData,
|
||||
setAppData,
|
||||
}: {
|
||||
app: inferQueryOutput<"viewer.apps">[number];
|
||||
app: RouterOutputs["viewer"]["apps"][number];
|
||||
eventType: EventType;
|
||||
getAppData: GetAppData;
|
||||
setAppData: SetAppData;
|
||||
@@ -44,12 +40,9 @@ function AppCardWrapper({
|
||||
|
||||
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
const { t } = useLocale();
|
||||
const { data: eventTypeApps, isLoading } = trpc.useQuery([
|
||||
"viewer.apps",
|
||||
{
|
||||
extendsFeature: "EventType",
|
||||
},
|
||||
]);
|
||||
const { data: eventTypeApps, isLoading } = trpc.viewer.apps.useQuery({
|
||||
extendsFeature: "EventType",
|
||||
});
|
||||
const methods = useFormContext<FormValues>();
|
||||
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
|
||||
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
|
||||
+112
-18
@@ -1,23 +1,25 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext, Controller, useWatch } from "react-hook-form";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import convertToNewDurationType, { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PeriodType } from "@calcom/prisma/client";
|
||||
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { Label, Input, MinutesField } from "@calcom/ui/components/form";
|
||||
import { Select, SettingsToggle } from "@calcom/ui/v2";
|
||||
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
|
||||
import { Button, DateRangePicker, Icon, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType } = props;
|
||||
const minimumBookingNoticeType = useRef<DurationType>(findDurationType(eventType.minimumBookingNotice));
|
||||
const prevBookingNoticeType = useRef<DurationType>(minimumBookingNoticeType.current);
|
||||
|
||||
const minimumBookingNoticeInDurationTypeFormValue = formMethods.watch("minimumBookingNoticeInDurationType");
|
||||
|
||||
const PERIOD_TYPES = [
|
||||
{
|
||||
type: "ROLLING" as const,
|
||||
@@ -47,6 +49,57 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
defaultValue: periodType?.type,
|
||||
});
|
||||
|
||||
const onMinimumNoticeDurationTypeChange = useMemo(
|
||||
() => (durationType?: DurationType) => {
|
||||
if (typeof durationType === "undefined") return;
|
||||
|
||||
// Store current selected type in ref to use in previous run for comparrison.
|
||||
minimumBookingNoticeType.current = durationType;
|
||||
|
||||
// Uses the previous selected type and converts the value to the new type.
|
||||
// Eg day was selected before, user selects hours now, so we multiple * 24 and round up.
|
||||
const minimumBookingNoticeInDurationType = convertToNewDurationType(
|
||||
prevBookingNoticeType.current,
|
||||
durationType,
|
||||
Number(minimumBookingNoticeInDurationTypeFormValue)
|
||||
);
|
||||
|
||||
// Uses the round up value in the new type to calculate the value in minutes.
|
||||
// We should NOT use minimumBookingNoticeInDurationTypeFormValue here, since that
|
||||
// would break in the case that the user enters 90 minutes, changes the type to
|
||||
// hours (that will show 2 hours), but the value in minutes would then remain 90,
|
||||
// which is something else than the user things they save.
|
||||
const minimumBookingNoticeInMinutes = convertToNewDurationType(
|
||||
durationType,
|
||||
"minutes",
|
||||
Number(minimumBookingNoticeInDurationType)
|
||||
);
|
||||
|
||||
// Updates both form values as well as stores the current type as previous type in ref.
|
||||
formMethods.setValue("minimumBookingNotice", minimumBookingNoticeInMinutes);
|
||||
formMethods.setValue("minimumBookingNoticeInDurationType", minimumBookingNoticeInDurationType);
|
||||
prevBookingNoticeType.current = durationType;
|
||||
},
|
||||
[formMethods, minimumBookingNoticeInDurationTypeFormValue]
|
||||
);
|
||||
|
||||
/**
|
||||
* When the user inputs a new value for minimumBookingNoticeInDurationType,
|
||||
* we calculate the value in minutes, and update this hidden field as well.
|
||||
*/
|
||||
const onMinimumNoticeChange = useMemo(
|
||||
() => (duration: string) => {
|
||||
const minimumBookingNoticeInMinutes = convertToNewDurationType(
|
||||
minimumBookingNoticeType.current,
|
||||
"minutes",
|
||||
Number(duration)
|
||||
);
|
||||
|
||||
formMethods.setValue("minimumBookingNotice", minimumBookingNoticeInMinutes);
|
||||
},
|
||||
[formMethods]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
@@ -116,13 +169,54 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 pt-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<MinutesField
|
||||
required
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="120"
|
||||
{...formMethods.register("minimumBookingNotice", { valueAsNumber: true })}
|
||||
<div className="flex w-full items-end justify-end">
|
||||
<Controller
|
||||
name="minimumBookingNotice"
|
||||
control={formMethods.control}
|
||||
render={() => {
|
||||
const durationTypeOptions = [
|
||||
{
|
||||
label: t("minutes"),
|
||||
value: "minutes",
|
||||
},
|
||||
{
|
||||
label: t("hours"),
|
||||
value: "hours",
|
||||
},
|
||||
{
|
||||
label: t("days"),
|
||||
value: "days",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-1/2 md:w-3/4">
|
||||
<InputField
|
||||
required
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="120"
|
||||
className="mr-2 mb-0 h-[38px] rounded-[4px]"
|
||||
{...formMethods.register("minimumBookingNoticeInDurationType", {
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onMinimumNoticeChange(event.target.value),
|
||||
})}
|
||||
/>
|
||||
<input type="hidden" {...formMethods.register("minimumBookingNotice")} />
|
||||
</div>
|
||||
<Select
|
||||
isSearchable={false}
|
||||
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
defaultValue={durationTypeOptions.find(
|
||||
(option) => option.value === minimumBookingNoticeType.current
|
||||
)}
|
||||
onChange={(input) => onMinimumNoticeDurationTypeChange(input?.value as DurationType)}
|
||||
options={durationTypeOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
@@ -136,7 +230,7 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
label: t("slot_interval_default"),
|
||||
value: -1,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60].map((minutes) => ({
|
||||
...[5, 10, 15, 20, 30, 45, 60, 75, 90, 105, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
value: minutes,
|
||||
})),
|
||||
@@ -191,7 +285,7 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
<SettingsToggle
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
checked={value !== "UNLIMITED"}
|
||||
checked={value && value !== "UNLIMITED"}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={watchPeriodType}
|
||||
+132
-28
@@ -4,19 +4,17 @@ import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||
import { MultiValue } from "react-select";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getEventLocationType, EventLocationType } from "@calcom/app-store/locations";
|
||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { Label, TextField } from "@calcom/ui/components/form";
|
||||
import { Select, Skeleton } from "@calcom/ui/v2";
|
||||
import { Button, Icon, Label, Select, Skeleton, TextField, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
import { slugify } from "@lib/slugify";
|
||||
|
||||
import { EditLocationDialog } from "@components/v2/eventtype/EditLocationDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
|
||||
type OptionTypeBase = {
|
||||
label: string;
|
||||
@@ -31,7 +29,24 @@ export const EventSetupTab = (
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType, locationOptions, team } = props;
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
|
||||
|
||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
||||
value: mins,
|
||||
label: t("multiple_duration_mins", { count: mins }),
|
||||
}));
|
||||
|
||||
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
||||
MultiValue<{
|
||||
value: number;
|
||||
label: string;
|
||||
}>
|
||||
>(multipleDurationOptions.filter((mdOpt) => multipleDuration?.includes(mdOpt.value)));
|
||||
const [defaultDuration, setDefaultDuration] = useState(
|
||||
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
|
||||
);
|
||||
|
||||
const openLocationModal = (type: EventLocationType["type"]) => {
|
||||
setSelectedLocation(locationOptions.find((option) => option.value === type));
|
||||
@@ -46,14 +61,23 @@ export const EventSetupTab = (
|
||||
);
|
||||
};
|
||||
|
||||
const addLocation = (newLocationType: EventLocationType["type"], details = {}) => {
|
||||
const existingIdx = formMethods.getValues("locations").findIndex((loc) => newLocationType === loc.type);
|
||||
const saveLocation = (newLocationType: EventLocationType["type"], details = {}) => {
|
||||
const locationType = editingLocationType !== "" ? editingLocationType : newLocationType;
|
||||
const existingIdx = formMethods.getValues("locations").findIndex((loc) => locationType === loc.type);
|
||||
if (existingIdx !== -1) {
|
||||
const copy = formMethods.getValues("locations");
|
||||
copy[existingIdx] = {
|
||||
...formMethods.getValues("locations")[existingIdx],
|
||||
...details,
|
||||
};
|
||||
if (editingLocationType !== "") {
|
||||
copy[existingIdx] = {
|
||||
...details,
|
||||
type: newLocationType,
|
||||
};
|
||||
} else {
|
||||
copy[existingIdx] = {
|
||||
...formMethods.getValues("locations")[existingIdx],
|
||||
...details,
|
||||
};
|
||||
}
|
||||
|
||||
formMethods.setValue("locations", copy);
|
||||
} else {
|
||||
formMethods.setValue(
|
||||
@@ -61,6 +85,8 @@ export const EventSetupTab = (
|
||||
formMethods.getValues("locations").concat({ type: newLocationType, ...details })
|
||||
);
|
||||
}
|
||||
|
||||
setEditingLocationType("");
|
||||
setShowLocationModal(false);
|
||||
};
|
||||
|
||||
@@ -104,6 +130,7 @@ export const EventSetupTab = (
|
||||
{validLocations.length === 0 && (
|
||||
<div className="flex">
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
@@ -118,7 +145,7 @@ export const EventSetupTab = (
|
||||
if (eventLocationType.organizerInputType) {
|
||||
openLocationModal(newLocationType);
|
||||
} else {
|
||||
addLocation(newLocationType);
|
||||
saveLocation(newLocationType);
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -138,10 +165,10 @@ export const EventSetupTab = (
|
||||
<div key={index} className="flex flex-grow items-center">
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className="h-6 w-6"
|
||||
className="h-4 w-4"
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="truncate text-sm ltr:ml-2 rtl:mr-2">
|
||||
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">
|
||||
{location[eventLocationType.defaultValueVariable] || eventLocationType.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -153,6 +180,7 @@ export const EventSetupTab = (
|
||||
locationFormMethods.unregister("locationLink");
|
||||
locationFormMethods.unregister("locationAddress");
|
||||
locationFormMethods.unregister("locationPhoneNumber");
|
||||
setEditingLocationType(location.type);
|
||||
openLocationModal(location.type);
|
||||
}}
|
||||
aria-label={t("edit")}
|
||||
@@ -185,7 +213,7 @@ export const EventSetupTab = (
|
||||
<div className="space-y-8">
|
||||
<TextField
|
||||
required
|
||||
label={t("Title")}
|
||||
label={t("title")}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
@@ -209,17 +237,92 @@ export const EventSetupTab = (
|
||||
setValueAs: (v) => slugify(v),
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
name="length"
|
||||
type="number"
|
||||
label={t("duration")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("length", Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
{multipleDuration ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("available_durations")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
isMulti
|
||||
defaultValue={selectedMultipleDuration}
|
||||
name="metadata.multipleDuration"
|
||||
isSearchable={false}
|
||||
className="h-auto !min-h-[36px] text-sm"
|
||||
options={multipleDurationOptions}
|
||||
value={selectedMultipleDuration}
|
||||
onChange={(options) => {
|
||||
let newOptions = [...options];
|
||||
newOptions = newOptions.sort((a, b) => {
|
||||
return a?.value - b?.value;
|
||||
});
|
||||
const values = newOptions.map((opt) => opt.value);
|
||||
setMultipleDuration(values);
|
||||
setSelectedMultipleDuration(newOptions);
|
||||
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||
if (newOptions.length > 0) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
} else {
|
||||
setDefaultDuration(null);
|
||||
}
|
||||
}
|
||||
if (newOptions.length === 1 && defaultDuration === null) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
}
|
||||
formMethods.setValue("metadata.multipleDuration", values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("default_duration")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
value={defaultDuration}
|
||||
isSearchable={false}
|
||||
name="length"
|
||||
className="text-sm"
|
||||
noOptionsMessage={() => t("default_duration_no_options")}
|
||||
options={selectedMultipleDuration}
|
||||
onChange={(option) => {
|
||||
setDefaultDuration(
|
||||
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||
);
|
||||
formMethods.setValue("length", Number(option?.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TextField
|
||||
required
|
||||
name="length"
|
||||
type="number"
|
||||
label={t("duration")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("length", Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||
<SettingsToggle
|
||||
title={t("allow_booker_to_select_duration")}
|
||||
checked={multipleDuration !== undefined}
|
||||
onCheckedChange={() => {
|
||||
if (multipleDuration !== undefined) {
|
||||
setMultipleDuration(undefined);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||
formMethods.setValue("length", eventType.length);
|
||||
} else {
|
||||
setMultipleDuration([]);
|
||||
formMethods.setValue("metadata.multipleDuration", []);
|
||||
formMethods.setValue("length", 0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
@@ -237,12 +340,13 @@ export const EventSetupTab = (
|
||||
<EditLocationDialog
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={addLocation}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation ? { value: selectedLocation.value, label: selectedLocation.label } : undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
+1
-4
@@ -5,10 +5,7 @@ import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Button, Avatar } from "@calcom/ui/components";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { Select, CheckedTeamSelect } from "@calcom/ui/v2";
|
||||
import { Avatar, Button, CheckedTeamSelect, Icon, Label, Select } from "@calcom/ui";
|
||||
|
||||
interface IMemberToValue {
|
||||
id: number | null;
|
||||
+21
-15
@@ -10,20 +10,27 @@ import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button, ButtonGroup, Label } from "@calcom/ui/components";
|
||||
import { HorizontalTabs, showToast, Switch, Tooltip, VerticalTabs } from "@calcom/ui/v2";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import { Dialog } from "@calcom/ui/v2/core/Dialog";
|
||||
import Divider from "@calcom/ui/v2/core/Divider";
|
||||
import Dropdown, {
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
Divider,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/v2/core/Dropdown";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
|
||||
import { Skeleton } from "@calcom/ui/v2/core/skeleton";
|
||||
HorizontalTabs,
|
||||
Icon,
|
||||
Label,
|
||||
Shell,
|
||||
showToast,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
VerticalDivider,
|
||||
VerticalTabs,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { ClientSuspense } from "@components/ClientSuspense";
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
@@ -60,7 +67,7 @@ function getNavigation(props: {
|
||||
name: "availability",
|
||||
href: `/event-types/${eventType.id}?tabName=availability`,
|
||||
icon: Icon.FiCalendar,
|
||||
info: `Working Hours`, // TODO: Get this from props
|
||||
info: `default_schedule_name`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "event_limit_tab_title",
|
||||
@@ -115,9 +122,9 @@ function EventTypeSingleLayout({
|
||||
|
||||
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
|
||||
|
||||
const deleteMutation = trpc.useMutation("viewer.eventTypes.delete", {
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.invalidateQueries(["viewer.eventTypes"]);
|
||||
await utils.viewer.eventTypes.invalidate();
|
||||
showToast(t("event_type_deleted_successfully"), "success");
|
||||
await router.push("/event-types");
|
||||
setDeleteDialogOpen(false);
|
||||
@@ -202,7 +209,6 @@ function EventTypeSingleLayout({
|
||||
color="secondary"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiLink}
|
||||
combined
|
||||
tooltip={t("copy_link")}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export { default } from "@calcom/features/ee/workflows/components/v2/EventWorkflowsTab";
|
||||
export { default } from "@calcom/features/ee/workflows/components/EventWorkflowsTab";
|
||||
+1
-2
@@ -5,8 +5,7 @@ import { useFormContext } from "react-hook-form";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import { Select, SettingsToggle } from "@calcom/ui/v2";
|
||||
import { Alert, Select, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
type RecurringEventControllerProps = {
|
||||
recurringEvent: RecurringEvent | null;
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { UnitTypeLongPlural } from "dayjs";
|
||||
import { Trans } from "next-i18next";
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import z from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Alert, Input, Label, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
type RequiresConfirmationControllerProps = {
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
requiresConfirmation: boolean;
|
||||
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
|
||||
seatsEnabled: boolean;
|
||||
};
|
||||
|
||||
export default function RequiresConfirmationController({
|
||||
metadata,
|
||||
requiresConfirmation,
|
||||
onRequiresConfirmation,
|
||||
seatsEnabled,
|
||||
}: RequiresConfirmationControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const [requiresConfirmationSetup, setRequiresConfirmationSetup] = useState(
|
||||
metadata?.requiresConfirmationThreshold
|
||||
);
|
||||
const defaultRequiresConfirmationSetup = { time: 30, unit: "minutes" as UnitTypeLongPlural };
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresConfirmation) {
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
}
|
||||
}, [requiresConfirmation]);
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className={!seatsEnabled ? "w-full" : ""}>
|
||||
{seatsEnabled ? (
|
||||
<Alert severity="warning" title="Seats option doesn't support confirmation requirement" />
|
||||
) : (
|
||||
<Controller
|
||||
name="requiresConfirmation"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("requires_confirmation")}
|
||||
description={t("requires_confirmation_description")}
|
||||
checked={requiresConfirmation}
|
||||
onCheckedChange={(val) => {
|
||||
formMethods.setValue("requiresConfirmation", val);
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<div className="-ml-2 flex flex-col flex-wrap justify-start gap-y-2 space-y-2">
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
id="always"
|
||||
value="always"
|
||||
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor="always" className="!m-0 flex items-center">
|
||||
{t("always_requires_confirmation")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
id="notice"
|
||||
value="notice"
|
||||
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor="notice" className="!m-0 flex items-center">
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold.time", val);
|
||||
}}
|
||||
className="!m-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<select
|
||||
onChange={(evt) => {
|
||||
const val = evt.target.value as UnitTypeLongPlural;
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: val,
|
||||
});
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold.unit", val);
|
||||
}}
|
||||
className="ml-2 block h-9 rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
|
||||
defaultValue={
|
||||
metadata?.requiresConfirmationThreshold?.unit ||
|
||||
defaultRequiresConfirmationSetup.unit
|
||||
}>
|
||||
<option value="minutes">{t("minute_timeUnit")}</option>
|
||||
<option value="hours">{t("hour_timeUnit")}</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Icon, SkeletonAvatar, SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
/** @deprecated Use `apps/web/components/v2/eventtype/SkeletonLoader.tsx` */
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-4 flex items-center">
|
||||
<SkeletonAvatar width="8" height="8" />
|
||||
<div className="space-y-1">
|
||||
<SkeletonAvatar className="h-8 w-8" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
</div>
|
||||
@@ -28,17 +26,17 @@ function SkeletonItem() {
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div>
|
||||
<SkeletonText width="32" height="5" />
|
||||
<SkeletonText className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="">
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon.FiClock className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText width="12" height="4" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<Icon.FiUser className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText width="16" height="4" />
|
||||
<SkeletonText className="h-4 w-12" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
interface ICalendarItem {
|
||||
title: string;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Switch } from "@calcom/ui/v2";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { showToast, Switch } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
@@ -58,7 +57,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
await utils.viewer.integrations.invalidate();
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${title}""`, "error");
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferMutationInput, trpc } from "@calcom/trpc/react";
|
||||
import { RouterInputs, trpc } from "@calcom/trpc/react";
|
||||
|
||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
|
||||
interface ICreateEventsOnCalendarSelectProps {
|
||||
calendar?: inferMutationInput<"viewer.setDestinationCalendar"> | null;
|
||||
calendar?: RouterInputs["viewer"]["setDestinationCalendar"] | null;
|
||||
}
|
||||
|
||||
const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps) => {
|
||||
const { calendar } = props;
|
||||
const { t } = useLocale();
|
||||
const mutation = trpc.useMutation(["viewer.setDestinationCalendar"]);
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { List } from "@calcom/ui/List";
|
||||
import { SkeletonAvatar, SkeletonText, SkeletonButton } from "@calcom/ui/v2";
|
||||
import { SkeletonAvatar, SkeletonText, SkeletonButton, List } from "@calcom/ui";
|
||||
|
||||
import { CalendarItem } from "../components/CalendarItem";
|
||||
import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem";
|
||||
@@ -16,12 +15,9 @@ interface IConnectCalendarsProps {
|
||||
|
||||
const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||
const { nextStep } = props;
|
||||
const queryConnectedCalendars = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery();
|
||||
const { t } = useLocale();
|
||||
const queryIntegrations = trpc.useQuery([
|
||||
"viewer.integrations",
|
||||
{ variant: "calendar", onlyInstalled: false },
|
||||
]);
|
||||
const queryIntegrations = trpc.viewer.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
|
||||
|
||||
const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
|
||||
(item) => item.calendars && item.calendars?.length > 0
|
||||
|
||||
@@ -7,8 +7,7 @@ import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc, TRPCClientErrorLike } from "@calcom/trpc/react";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
import { Button, Form } from "@calcom/ui";
|
||||
|
||||
interface ISetupAvailabilityProps {
|
||||
nextStep: () => void;
|
||||
@@ -24,9 +23,12 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
const router = useRouter();
|
||||
let queryAvailability;
|
||||
if (defaultScheduleId) {
|
||||
queryAvailability = trpc.useQuery(["viewer.availability.schedule", { scheduleId: defaultScheduleId }], {
|
||||
enabled: router.isReady,
|
||||
});
|
||||
queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: defaultScheduleId },
|
||||
{
|
||||
enabled: router.isReady,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const availabilityForm = useForm({
|
||||
@@ -43,8 +45,8 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
nextStep();
|
||||
},
|
||||
};
|
||||
const createSchedule = trpc.useMutation("viewer.availability.schedule.create", mutationOptions);
|
||||
const updateSchedule = trpc.useMutation("viewer.availability.schedule.update", mutationOptions);
|
||||
const createSchedule = trpc.viewer.availability.schedule.create.useMutation(mutationOptions);
|
||||
const updateSchedule = trpc.viewer.availability.schedule.update.useMutation(mutationOptions);
|
||||
return (
|
||||
<Form
|
||||
className="w-full bg-white text-black dark:bg-opacity-5 dark:text-white"
|
||||
|
||||
@@ -4,24 +4,20 @@ import { FormEvent, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components";
|
||||
import { TextArea } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/v2";
|
||||
import ImageUploader from "@calcom/ui/v2/core/ImageUploader";
|
||||
import { Button, ImageUploader, showToast, TextArea } from "@calcom/ui";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
|
||||
interface IUserProfile {
|
||||
user?: User;
|
||||
}
|
||||
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||
|
||||
type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
interface IUserProfileProps {
|
||||
user: IOnboardingPageProps["user"];
|
||||
}
|
||||
|
||||
const UserProfile = (props: IUserProfile) => {
|
||||
const UserProfile = (props: IUserProfileProps) => {
|
||||
const { user } = props;
|
||||
const { t } = useLocale();
|
||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||
@@ -32,17 +28,17 @@ const UserProfile = (props: IUserProfile) => {
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({ defaultValues: { bio: user?.bio || "" } });
|
||||
const { data: eventTypes } = trpc.useQuery(["viewer.eventTypes.list"]);
|
||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const createEventType = trpc.useMutation("viewer.eventTypes.create");
|
||||
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (_data, context) => {
|
||||
if (context.avatar) {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.refetchQueries(["viewer.me"]);
|
||||
await utils.viewer.me.refetch();
|
||||
} else {
|
||||
try {
|
||||
if (eventTypes?.length === 0) {
|
||||
@@ -56,7 +52,7 @@ const UserProfile = (props: IUserProfile) => {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await utils.refetchQueries(["viewer.me"]);
|
||||
await utils.viewer.me.refetch();
|
||||
router.push("/");
|
||||
}
|
||||
},
|
||||
@@ -103,7 +99,14 @@ const UserProfile = (props: IUserProfile) => {
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && <AvatarSSR user={user} alt="Profile picture" className="h-16 w-16" />}
|
||||
{user && (
|
||||
<Avatar
|
||||
alt={user.username || "user avatar"}
|
||||
gravatarFallbackMd5={user.emailMd5}
|
||||
size="lg"
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { useRef, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Button, TimezoneSelect } from "@calcom/ui";
|
||||
|
||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||
|
||||
interface IUserSettingsProps {
|
||||
user: User;
|
||||
user: IOnboardingPageProps["user"];
|
||||
nextStep: () => void;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,9 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
control,
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
name: user?.name || "",
|
||||
username: user?.username || "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
@@ -36,45 +34,25 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const onSuccess = async () => {
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
await utils.viewer.me.invalidate();
|
||||
nextStep();
|
||||
};
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: onSuccess,
|
||||
});
|
||||
const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
|
||||
const paymentRequired = stripeCustomer?.isPremium ? !stripeCustomer?.paidForPremium : false;
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (paymentRequired) {
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
name: data.name,
|
||||
timeZone: selectedTimeZone,
|
||||
});
|
||||
});
|
||||
|
||||
const [currentUsername, setCurrentUsername] = useState(user.username || "");
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-6">
|
||||
{/* Username textfield */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="username"
|
||||
render={({ field: { value, ref, onChange } }) => (
|
||||
<UsernameAvailability
|
||||
readonly={true}
|
||||
currentUsername={currentUsername}
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={value}
|
||||
usernameRef={ref}
|
||||
setInputUsernameValue={onChange}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<UsernameAvailabilityField user={user} />
|
||||
|
||||
{/* Full name textfield */}
|
||||
<div className="w-full">
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { List } from "@calcom/ui/List";
|
||||
import { ShellSubHeading } from "@calcom/ui/Shell";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import AdditionalCalendarSelector from "@components/AdditionalCalendarSelector";
|
||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
|
||||
import DisconnectIntegration from "./DisconnectIntegration";
|
||||
import IntegrationListItem from "./IntegrationListItem";
|
||||
import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections";
|
||||
|
||||
type Props = {
|
||||
onChanged: () => unknown | Promise<unknown>;
|
||||
fromOnboarding?: boolean;
|
||||
};
|
||||
|
||||
function CalendarSwitch(props: {
|
||||
type: string;
|
||||
externalId: string;
|
||||
title: string;
|
||||
defaultSelected: boolean;
|
||||
}) {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const mutation = useMutation<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
isOn: boolean;
|
||||
}
|
||||
>(
|
||||
async ({ isOn }) => {
|
||||
const body = {
|
||||
integration: props.type,
|
||||
externalId: props.externalId,
|
||||
};
|
||||
if (isOn) {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
} else {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="py-1">
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
label={props.title}
|
||||
defaultChecked={props.defaultSelected}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
mutation.mutate({ isOn });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: false }]);
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<List>
|
||||
{data.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
name={item.name}
|
||||
slug={item.slug}
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
logo={item.logo}
|
||||
description={item.description}
|
||||
actions={
|
||||
<InstallAppButton
|
||||
type={item.type}
|
||||
render={(buttonProps) => (
|
||||
<Button color="secondary" {...buttonProps}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
onChanged={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectedCalendarsList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
||||
const { fromOnboarding } = props;
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
empty={() => null}
|
||||
success={({ data }) => {
|
||||
if (!data.connectedCalendars.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
<IntegrationListItem
|
||||
slug={item.integration.slug}
|
||||
title={item.integration.title}
|
||||
logo={item.integration.logo}
|
||||
description={item.primary?.externalId || "No external Id"}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
externalId={item.primary?.externalId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={props.onChanged}
|
||||
/>
|
||||
}>
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="px-4 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-2 p-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</IntegrationListItem>
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("something_went_wrong")}
|
||||
message={t("calendar_error")}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarListContainer(props: {
|
||||
heading?: boolean;
|
||||
items?: inferQueryOutput<"viewer.integrations">["items"];
|
||||
fromOnboarding?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { heading = true, fromOnboarding } = props;
|
||||
const utils = trpc.useContext();
|
||||
const onChanged = () =>
|
||||
Promise.allSettled([
|
||||
utils.invalidateQueries(["viewer.integrations", { variant: "calendar", onlyInstalled: true }], {
|
||||
exact: true,
|
||||
}),
|
||||
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
||||
]);
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const installedCalendars = trpc.useQuery([
|
||||
"viewer.integrations",
|
||||
{ variant: "calendar", onlyInstalled: true },
|
||||
]);
|
||||
const mutation = trpc.useMutation("viewer.setDestinationCalendar");
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader className="mt-10" />}
|
||||
success={({ data }) => {
|
||||
return (
|
||||
<>
|
||||
{(!!data.connectedCalendars.length || !!installedCalendars.data?.items.length) && (
|
||||
<>
|
||||
{heading && (
|
||||
<ShellSubHeading
|
||||
className="mt-10 mb-0"
|
||||
title={
|
||||
<SubHeadingTitleWithConnections
|
||||
title="Calendars"
|
||||
numConnections={data.connectedCalendars.length}
|
||||
/>
|
||||
}
|
||||
subtitle={t("configure_how_your_event_types_interact")}
|
||||
actions={
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||
<div className="sm:min-w-80 block max-w-full">
|
||||
<DestinationCalendarSelector
|
||||
onChange={mutation.mutate}
|
||||
isLoading={mutation.isLoading}
|
||||
value={data.destinationCalendar?.externalId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!!data.connectedCalendars.length && (
|
||||
<div className="sm:min-w-80 inline max-w-full">
|
||||
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ConnectedCalendarsList onChanged={onChanged} fromOnboarding={fromOnboarding} />
|
||||
</>
|
||||
)}
|
||||
{fromOnboarding && (
|
||||
<>
|
||||
{!!query.data?.connectedCalendars.length && (
|
||||
<ShellSubHeading
|
||||
className="mt-4"
|
||||
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
|
||||
/>
|
||||
)}
|
||||
<CalendarList onChanged={onChanged} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
export default function DisconnectIntegration(props: {
|
||||
/** Integration credential id */
|
||||
id: number;
|
||||
externalId?: string;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
const { id, externalId = "" } = props;
|
||||
const { t } = useLocale();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const mutation = trpc.useMutation("viewer.deleteCredential", {
|
||||
onSettled: async () => {
|
||||
await props.onOpenChange(modalOpen);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast("Integration deleted successfully", "success");
|
||||
setModalOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
throw new Error("Something went wrong");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_app")}
|
||||
confirmBtnText={t("yes_remove_app")}
|
||||
cancelBtnText="Cancel"
|
||||
onConfirm={() => {
|
||||
mutation.mutate({ id, externalId });
|
||||
}}>
|
||||
{t("are_you_sure_you_want_to_remove_this_app")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
{props.render({
|
||||
onClick() {
|
||||
setModalOpen(true);
|
||||
},
|
||||
disabled: modalOpen,
|
||||
loading: mutation.isLoading,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent";
|
||||
|
||||
export default function DisconnectStripeIntegration(props: {
|
||||
/** Integration credential id */
|
||||
id: number;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
async (action: string) => {
|
||||
const res = await fetch("/api/integrations", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.id, action }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await props.onOpenChange(modalOpen);
|
||||
},
|
||||
onSuccess(data) {
|
||||
showToast(data.message, "success");
|
||||
setModalOpen(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DeleteStripeDialogContent
|
||||
variety="warning"
|
||||
title="Disconnect Stripe Integration"
|
||||
cancelAllBookingsBtnText="Cancel all bookings"
|
||||
removeBtnText="Remove payment"
|
||||
cancelBtnText="Cancel"
|
||||
onConfirm={() => {
|
||||
mutation.mutate("cancel");
|
||||
}}
|
||||
onRemove={() => {
|
||||
mutation.mutate("remove");
|
||||
}}>
|
||||
If you have unpaid and unconfirmed bookings, you must choose to cancel them or remove the required
|
||||
payment field.
|
||||
</DeleteStripeDialogContent>
|
||||
</Dialog>
|
||||
{props.render({
|
||||
onClick() {
|
||||
setModalOpen(true);
|
||||
},
|
||||
disabled: modalOpen,
|
||||
loading: mutation.isLoading,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/List";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
function IntegrationListItem(props: {
|
||||
imageSrc?: string;
|
||||
slug: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
logo: string;
|
||||
}): JSX.Element {
|
||||
const title = props.name || props.title;
|
||||
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")}>
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
props.logo && <img className="h-10 w-10" src={props.logo} alt={title} />
|
||||
}
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3">
|
||||
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
|
||||
</ListItemTitle>
|
||||
<ListItemText component="p">{props.description}</ListItemText>
|
||||
</div>
|
||||
<div>{props.actions}</div>
|
||||
</div>
|
||||
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default IntegrationListItem;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Badge } from "@calcom/ui";
|
||||
|
||||
function pluralize(opts: { num: number; plural: string; singular: string }) {
|
||||
if (opts.num === 0) {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
|
||||
import React, { FC } from "react";
|
||||
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
interface OptionTypeBase {
|
||||
label: string;
|
||||
value: EventTypeCustomInputType;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: SubmitHandler<IFormInput>;
|
||||
onCancel: () => void;
|
||||
selectedCustomInput?: EventTypeCustomInput;
|
||||
}
|
||||
|
||||
type IFormInput = EventTypeCustomInput;
|
||||
|
||||
const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const inputOptions: OptionTypeBase[] = [
|
||||
{ value: EventTypeCustomInputType.TEXT, label: t("text") },
|
||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||
];
|
||||
const { selectedCustomInput } = props;
|
||||
const defaultValues = selectedCustomInput || { type: inputOptions[0].value };
|
||||
const { register, control, handleSubmit } = useForm<IFormInput>({
|
||||
defaultValues,
|
||||
});
|
||||
const selectedInputType = useWatch({ name: "type", control });
|
||||
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value);
|
||||
|
||||
const onCancel = () => {
|
||||
props.onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(props.onSubmit)}>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
{t("input_type")}
|
||||
</label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
id="type"
|
||||
defaultValue={selectedInputOption}
|
||||
options={inputOptions}
|
||||
isSearchable={false}
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
|
||||
onChange={(option) => option && field.onChange(option.value)}
|
||||
value={selectedInputOption}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
|
||||
{t("label")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(selectedInputType === EventTypeCustomInputType.TEXT ||
|
||||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
|
||||
<div className="mb-2">
|
||||
<label htmlFor="placeholder" className="block text-sm font-medium text-gray-700">
|
||||
{t("placeholder")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="required"
|
||||
type="checkbox"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 ltr:mr-2 rtl:ml-2"
|
||||
defaultChecked={selectedCustomInput?.required ?? true}
|
||||
{...register("required")}
|
||||
/>
|
||||
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||
{t("is_required")}
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
id="eventTypeId"
|
||||
value={selectedCustomInput?.eventTypeId || -1}
|
||||
{...register("eventTypeId", { valueAsNumber: true })}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
id="id"
|
||||
value={selectedCustomInput?.id || -1}
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 flex space-x-2 sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInputTypeForm;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Attendee, Booking, User } from "@prisma/client";
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import type { EventReservation, Person, ReservationStatusType } from "schema-dts";
|
||||
|
||||
type EventSchemaUser = Pick<User, "name" | "email">;
|
||||
type EventSchemaAttendee = Pick<Attendee, "name" | "email">;
|
||||
|
||||
interface EventReservationSchemaInterface {
|
||||
reservationId: Booking["uid"];
|
||||
eventName: Booking["title"];
|
||||
startTime: Booking["startTime"];
|
||||
endTime: Booking["endTime"];
|
||||
organizer: EventSchemaUser | null;
|
||||
attendees: EventSchemaAttendee[];
|
||||
location: Booking["location"];
|
||||
description: Booking["description"];
|
||||
status: Booking["status"];
|
||||
}
|
||||
|
||||
const EventReservationSchema: FC<EventReservationSchemaInterface> = ({
|
||||
reservationId,
|
||||
eventName,
|
||||
startTime,
|
||||
endTime,
|
||||
organizer,
|
||||
attendees,
|
||||
location,
|
||||
description,
|
||||
status,
|
||||
}) => {
|
||||
const reservationStatus = useMemo<ReservationStatusType>(() => {
|
||||
switch (status) {
|
||||
case "ACCEPTED":
|
||||
return "ReservationConfirmed";
|
||||
case "REJECTED":
|
||||
case "CANCELLED":
|
||||
return "ReservationCancelled";
|
||||
case "PENDING":
|
||||
return "ReservationPending";
|
||||
default:
|
||||
return "ReservationHold";
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<JsonLd<EventReservation>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "EventReservation",
|
||||
reservationId,
|
||||
reservationStatus,
|
||||
reservationFor: {
|
||||
"@type": "Event",
|
||||
name: eventName,
|
||||
startDate: startTime.toString(),
|
||||
endDate: endTime.toString(),
|
||||
organizer: organizer
|
||||
? ({ "@type": "Person", name: organizer.name, email: organizer.email } as Person)
|
||||
: undefined,
|
||||
attendee: attendees?.map(
|
||||
(person) => ({ "@type": "Person", name: person.name, email: person.email } as Person)
|
||||
),
|
||||
location: location || undefined,
|
||||
description: description || undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventReservationSchema;
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { SyntheticEvent, useState } from "react";
|
||||
import { SyntheticEvent, useState } from "react";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
|
||||
const ChangePasswordSection = () => {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
|
||||
@@ -3,10 +3,7 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { PasswordField } from "@calcom/ui/components/form";
|
||||
import { Form, Label } from "@calcom/ui/form/fields";
|
||||
import { Button, Dialog, DialogContent, Form, Label, PasswordField } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { Badge, Button, showToast } from "@calcom/ui";
|
||||
|
||||
const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonation: boolean }) => {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
await utils.viewer.public.i18n.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
import { Button, Dialog, DialogContent, Form } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Badge, Button } from "@calcom/ui";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from "react";
|
||||
|
||||
import { ShieldCheckIcon } from "@calcom/ui/Icon";
|
||||
import { ShieldCheckIcon } from "@calcom/ui";
|
||||
|
||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MeetingImageProps,
|
||||
} from "@calcom/lib/OgImages";
|
||||
import { getBrowserInfo } from "@calcom/lib/browser/browser.utils";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { truncateOnWord } from "@calcom/lib/text";
|
||||
|
||||
import { getSeoImage, seoConfig } from "@lib/config/next-seo.config";
|
||||
@@ -79,7 +80,7 @@ export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
|
||||
const image = getSeoImage("ogImage") + constructGenericImage({ title, description });
|
||||
const truncatedDescription = truncateOnWord(description, 158);
|
||||
|
||||
const pageTitle = title + " | Cal.com";
|
||||
const pageTitle = title + " | " + APP_NAME;
|
||||
let seoObject = buildSeoMeta({
|
||||
title: pageTitle,
|
||||
image,
|
||||
|
||||
@@ -2,10 +2,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import Switch from "@calcom/ui/v2/core/Switch";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { Badge, Icon, showToast, Switch } from "@calcom/ui";
|
||||
|
||||
export function CalendarSwitch(props: {
|
||||
type: string;
|
||||
@@ -57,7 +54,7 @@ export function CalendarSwitch(props: {
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
await utils.viewer.integrations.invalidate();
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||
|
||||
@@ -3,10 +3,7 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, Label } from "@calcom/ui/components/form";
|
||||
import { PasswordField } from "@calcom/ui/components/form";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/v2/core/Dialog";
|
||||
import { Button, Dialog, DialogContent, Form, Label, PasswordField } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -75,11 +72,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={t("disable_2fa")}
|
||||
description={t("disable_2fa_recommendation")}
|
||||
type="creation"
|
||||
useOwnActionButtons>
|
||||
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
||||
<Form form={form} handleSubmit={handleDisable}>
|
||||
<div className="mb-4">
|
||||
<PasswordField
|
||||
|
||||
@@ -3,9 +3,7 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/v2/core/Dialog";
|
||||
import { Button, Dialog, DialogContent, Form, TextField } from "@calcom/ui";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
|
||||
@@ -132,11 +130,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={t("enable_2fa")}
|
||||
description={setupDescriptions[step]}
|
||||
type="creation"
|
||||
useOwnActionButtons>
|
||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/DisableTeamImpersonation.tsx` */
|
||||
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]);
|
||||
|
||||
const mutation = trpc.useMutation("viewer.teams.updateMembership", {
|
||||
onSuccess: async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
if (query.isLoading) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-cal mt-7 pb-4 text-xl leading-6 text-gray-900">{t("settings")}</h3>
|
||||
<div className="-mx-0 rounded-sm border border-neutral-200 bg-white px-4 pb-4 sm:px-6">
|
||||
<div className="flex flex-col justify-between pt-4 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal font-bold leading-6 text-gray-900">
|
||||
{t("user_impersonation_heading")}
|
||||
</h2>
|
||||
<Badge
|
||||
className="ml-2 text-xs"
|
||||
variant={!query.data?.disableImpersonation ? "success" : "gray"}>
|
||||
{!query.data?.disableImpersonation ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{t("team_impersonation_description")}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
!query.data?.disableImpersonation
|
||||
? mutation.mutate({ teamId, memberId, disableImpersonation: true })
|
||||
: mutation.mutate({ teamId, memberId, disableImpersonation: false })
|
||||
}>
|
||||
{!query.data?.disableImpersonation ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableTeamImpersonation;
|
||||
@@ -1,115 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { SyntheticEvent, useMemo, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type MembershipRoleOption = {
|
||||
label: string;
|
||||
value: MembershipRole;
|
||||
};
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberChangeRoleModal.tsx` */
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
isOpen: boolean;
|
||||
currentMember: MembershipRole;
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("member"),
|
||||
value: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
label: t("admin"),
|
||||
value: MembershipRole.ADMIN,
|
||||
},
|
||||
{
|
||||
label: t("owner"),
|
||||
value: MembershipRole.OWNER,
|
||||
},
|
||||
].filter(({ value }) => value !== MembershipRole.OWNER || props.currentMember === MembershipRole.OWNER);
|
||||
}, [t, props.currentMember]);
|
||||
|
||||
const [role, setRole] = useState<MembershipRoleOption>(
|
||||
options.find((option) => option.value === props.initialRole) || {
|
||||
label: t("member"),
|
||||
value: MembershipRole.MEMBER,
|
||||
}
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function changeRole(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
changeRoleMutation.mutate({
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
role: role.value,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<ModalContainer isOpen={props.isOpen} onExit={props.onExit}>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("change_member_role")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={changeRole}>
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={options}
|
||||
value={role}
|
||||
onChange={(option) => option && setRole(option)}
|
||||
id="role"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 text-sm"
|
||||
/>
|
||||
</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">
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</ModalContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import React, { useState, SyntheticEvent, useMemo } from "react";
|
||||
|
||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type MemberInvitationModalProps = {
|
||||
isOpen: boolean;
|
||||
team: TeamWithMembers | null;
|
||||
currentMember: MembershipRole;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
type MembershipRoleOption = {
|
||||
value: MembershipRole;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberInvitationModal.tsx` */
|
||||
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"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function inviteMember(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!props.team) return;
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
elements: {
|
||||
role: { value: MembershipRole };
|
||||
inviteUser: { value: string };
|
||||
sendInviteEmail: { checked: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
language: i18n.language,
|
||||
role: target.elements["role"].value,
|
||||
usernameOrEmail: target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Icon.FiUser 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>
|
||||
</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={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 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-sm text-black"
|
||||
/>
|
||||
</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">
|
||||
<Icon.FiInfo 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,22 +0,0 @@
|
||||
import { inferQueryOutput } from "@calcom/trpc/react";
|
||||
|
||||
import MemberListItem from "./MemberListItem";
|
||||
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
members: inferQueryOutput<"viewer.teams.get">["members"];
|
||||
}
|
||||
|
||||
export default function MemberList(props: Props) {
|
||||
if (!props.members.length) return <></>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="-mx-4 mb-2 divide-y divide-gray-200 rounded border bg-white px-4 sm:mx-0 sm:px-4">
|
||||
{props.members?.map((member) => (
|
||||
<MemberListItem key={member.id} member={member} team={props.team} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import TeamAvailabilityModal from "@calcom/features/ee/teams/components/TeamAvailabilityModal";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamPill, { TeamRole } from "./TeamPill";
|
||||
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/MemberListItem.tsx` */
|
||||
export default function MemberListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
|
||||
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
|
||||
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const ownersInTeam = () => {
|
||||
const { members } = props.team;
|
||||
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
|
||||
return owners.length;
|
||||
};
|
||||
|
||||
const currentUserId = useCurrentUserId();
|
||||
|
||||
const name =
|
||||
props.member.name ||
|
||||
(() => {
|
||||
const emailName = props.member.email.split("@")[0];
|
||||
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
|
||||
})();
|
||||
|
||||
const removeMember = () =>
|
||||
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
|
||||
|
||||
return (
|
||||
<li className="divide-y">
|
||||
<div className="my-4 flex justify-between">
|
||||
<div className="flex w-full flex-col justify-between sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={WEBAPP_URL + "/" + props.member.username + "/avatar.png"}
|
||||
alt={name || ""}
|
||||
className="h-9 w-9 rounded-full"
|
||||
/>
|
||||
<div className="ml-3 inline-block">
|
||||
<span className="text-sm font-bold text-neutral-700">{name}</span>
|
||||
<span
|
||||
className="-mt-1 block text-xs text-gray-400"
|
||||
data-testid="member-email"
|
||||
data-email={props.member.email}>
|
||||
{props.member.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex ltr:mr-2 rtl:ml-2 sm:mt-0 sm:justify-center">
|
||||
{/* Tooltip doesn't show... WHY????? */}
|
||||
{props.member.isMissingSeat && (
|
||||
<Tooltip side="top" content={t("hidden_team_member_message")}>
|
||||
<TeamPill color="red" text={t("hidden")} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{!props.member.accepted && <TeamPill color="yellow" text={t("invitee")} />}
|
||||
{props.member.role && <TeamRole role={props.member.role} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Tooltip side="top" content={t("team_view_user_availability")}>
|
||||
<Button
|
||||
// Disabled buttons don't trigger Tooltips
|
||||
title={
|
||||
props.member.accepted
|
||||
? t("team_view_user_availability")
|
||||
: t("team_view_user_availability_disabled")
|
||||
}
|
||||
disabled={!props.member.accepted}
|
||||
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||
color="minimal"
|
||||
size="icon">
|
||||
<Icon.FiClock className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/" + props.member.username}>
|
||||
<a target="_blank">
|
||||
<Button color="minimal" StartIcon={Icon.FiExternalLink} className="w-full font-normal">
|
||||
{t("view_public_page")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{((props.team.membership.role === MembershipRole.OWNER &&
|
||||
(props.member.role !== MembershipRole.OWNER ||
|
||||
ownersInTeam() > 1 ||
|
||||
props.member.id !== currentUserId)) ||
|
||||
(props.team.membership.role === MembershipRole.ADMIN &&
|
||||
props.member.role !== MembershipRole.OWNER)) && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiEdit2}
|
||||
className="w-full flex-shrink-0 font-normal">
|
||||
{t("edit_role")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{/* Only show impersonate box if - The user has impersonation enabled,
|
||||
They have accepted the team invite, and it is enabled for this instance */}
|
||||
{!props.member.disableImpersonation &&
|
||||
props.member.accepted &&
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => setShowImpersonateModal(true)}
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiLock}
|
||||
className="w-full flex-shrink-0 font-normal">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={Icon.FiUserMinus}
|
||||
className="w-full font-normal">
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={removeMember}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
isOpen={showChangeMemberRoleModal}
|
||||
currentMember={props.team.membership.role}
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
onExit={() => setShowChangeMemberRoleModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showImpersonateModal && props.member.username && (
|
||||
<ModalContainer isOpen={showImpersonateModal} onExit={() => setShowImpersonateModal(false)}>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("impersonate")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("impersonation-auth", {
|
||||
username: props.member.username,
|
||||
teamId: props.team.id,
|
||||
});
|
||||
}}>
|
||||
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
||||
{t("impersonate_user_tip")}
|
||||
</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">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={() => setShowImpersonateModal(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</ModalContainer>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<ModalContainer
|
||||
wide
|
||||
noPadding
|
||||
isOpen={showTeamAvailabilityModal}
|
||||
onExit={() => setShowTeamAvailabilityModal(false)}>
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<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`} passHref>
|
||||
<Button color="secondary">{t("Open Team Availability")}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</ModalContainer>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Alert } from "@calcom/ui/v2/core/Alert";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/v2/core/Dialog";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TeamCreate(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
props.onClose();
|
||||
},
|
||||
onError: (e) => {
|
||||
setErrorMessage(e?.message || t("something_went_wrong"));
|
||||
},
|
||||
});
|
||||
|
||||
const createTeam = () => {
|
||||
createTeamMutation.mutate({ name: nameRef?.current?.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onClose}>
|
||||
<DialogContent type="creation" actionText={t("create_new_team")} actionOnClick={createTeam}>
|
||||
<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">
|
||||
<Icon.FiUsers 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("create_new_team")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
type PillColor = "blue" | "green" | "red" | "yellow";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
color?: PillColor;
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/TeamPill.tsx` */
|
||||
export default function TeamPill(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classNames("self-center rounded-md border px-3 py-1 text-xs capitalize ltr:mr-2 rtl:ml-2", {
|
||||
"border-gray-200 bg-gray-50 text-gray-700": !props.color,
|
||||
"border-blue-200 bg-blue-50 text-blue-700": props.color === "blue",
|
||||
"border-red-200 bg-red-50 text-red-700": props.color === "red",
|
||||
"border-yellow-200 bg-yellow-50 text-yellow-700": props.color === "yellow",
|
||||
"border-green-200 bg-green-50 text-green-600": props.color === "green",
|
||||
})}>
|
||||
{props.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamRole(props: { role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const keys: Record<MembershipRole, PillColor | undefined> = {
|
||||
[MembershipRole.OWNER]: undefined,
|
||||
[MembershipRole.ADMIN]: "red",
|
||||
[MembershipRole.MEMBER]: "blue",
|
||||
};
|
||||
return <TeamPill text={t(props.role.toLowerCase())} color={keys[props.role]} />;
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { objectKeys } from "@calcom/lib/objectKeys";
|
||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { TextField } from "@calcom/ui/form/fields";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import SettingInputContainer from "@components/ui/SettingInputContainer";
|
||||
|
||||
interface Props {
|
||||
team: TeamWithMembers | null | undefined;
|
||||
}
|
||||
|
||||
export default function TeamSettings(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const team = props.team;
|
||||
const hasLogo = !!team?.logo;
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const mutation = trpc.useMutation("viewer.teams.update", {
|
||||
onError: (err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
setHasErrors(false);
|
||||
},
|
||||
});
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
|
||||
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
function updateTeamData() {
|
||||
if (!team) return;
|
||||
const variables = {
|
||||
name: nameRef.current?.value,
|
||||
slug: teamUrlRef.current?.value,
|
||||
bio: descriptionRef.current?.value,
|
||||
hideBranding: hideBrandingRef.current?.checked,
|
||||
};
|
||||
// remove unchanged variables
|
||||
objectKeys(variables).forEach((key) => {
|
||||
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
|
||||
});
|
||||
mutation.mutate({ id: team.id, ...variables });
|
||||
}
|
||||
|
||||
function updateLogo(newLogo: string) {
|
||||
if (!team) return;
|
||||
logoRef.current.value = newLogo;
|
||||
mutation.mutate({ id: team.id, logo: newLogo });
|
||||
}
|
||||
|
||||
const removeLogo = () => updateLogo("");
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
||||
<div className="">
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
<form
|
||||
className="divide-y divide-gray-200 lg:col-span-9"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
updateTeamData();
|
||||
}}>
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<SettingInputContainer
|
||||
Icon={Icon.FiLink}
|
||||
label="Team URL"
|
||||
htmlFor="team-url"
|
||||
Input={
|
||||
<TextField
|
||||
name="" // typescript requires name but we don't want component to render name label
|
||||
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_WEBSITE_URL}/team/
|
||||
</span>
|
||||
}
|
||||
ref={teamUrlRef}
|
||||
defaultValue={team?.slug as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingInputContainer
|
||||
Icon={Icon.FiHash}
|
||||
label="Team Name"
|
||||
htmlFor="name"
|
||||
Input={
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder={t("your_team_name")}
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
|
||||
defaultValue={team?.name as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<hr />
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={Icon.FiInfo}
|
||||
label={t("about")}
|
||||
htmlFor="about"
|
||||
Input={
|
||||
<>
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
id="about"
|
||||
name="about"
|
||||
rows={3}
|
||||
defaultValue={team?.bio as string}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={Icon.FiImage}
|
||||
label="Logo"
|
||||
htmlFor="avatar"
|
||||
Input={
|
||||
<>
|
||||
<div className="mt-1 flex">
|
||||
<input
|
||||
ref={logoRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
|
||||
defaultValue={team?.logo ?? undefined}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="logo"
|
||||
id="logo-upload"
|
||||
buttonMsg={hasLogo ? t("edit_logo") : t("upload_a_logo")}
|
||||
handleAvatarChange={updateLogo}
|
||||
imageSrc={team?.logo ?? undefined}
|
||||
/>
|
||||
{hasLogo && (
|
||||
<Button
|
||||
onClick={removeLogo}
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="ml-1 py-1 text-xs">
|
||||
{t("remove_logo")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="hide-branding"
|
||||
name="hide-branding"
|
||||
type="checkbox"
|
||||
ref={hideBrandingRef}
|
||||
defaultChecked={team?.hideBranding}
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit" color="primary">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||
import LinkIconButton from "@components/ui/LinkIconButton";
|
||||
|
||||
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_WEBSITE_URL}/team/${props.team?.slug}`;
|
||||
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
router.push(`/settings/teams`);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
router.push(`/settings/teams`);
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam() {
|
||||
if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id });
|
||||
}
|
||||
function leaveTeam() {
|
||||
if (props.team?.id)
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
accept: false,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-2">
|
||||
{(props.role === MembershipRole.OWNER || props.role === MembershipRole.ADMIN) && (
|
||||
<CreateEventTypeButton
|
||||
isIndividualTeam
|
||||
canAddEvents={true}
|
||||
options={[
|
||||
{
|
||||
teamId: props.team?.id,
|
||||
name: props.team?.name,
|
||||
slug: props.team?.slug,
|
||||
image: props.team?.logo,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Link href={permalink} passHref={true}>
|
||||
<a target="_blank">
|
||||
<LinkIconButton Icon={Icon.FiExternalLink}>{t("preview")}</LinkIconButton>
|
||||
</a>
|
||||
</Link>
|
||||
<LinkIconButton
|
||||
Icon={Icon.FiLink}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_link_team")}
|
||||
</LinkIconButton>
|
||||
{props.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
Icon={Icon.FiTrash}>
|
||||
{t("disband_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
Icon={Icon.FiLogOut}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={leaveTeam}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
{props.team?.id && props.role !== MembershipRole.MEMBER && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<div className="mt-5 hidden space-y-1 sm:block">
|
||||
<LinkIconButton Icon={Icon.FiClock}>View Availability</LinkIconButton>
|
||||
<p className="mt-2 text-sm text-gray-500">See your team members availability at a glance.</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "@calcom/ui/Dialog";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
interface Props {
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
/** @deprecated Use `packages/features/ee/teams/components/UpgradeToFlexibleProModal.tsx` */
|
||||
export function UpgradeToFlexibleProModal(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const utils = trpc.useContext();
|
||||
const { data } = trpc.useQuery(["viewer.teams.getTeamSeats", { teamId: props.teamId }], {
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
const mutation = trpc.useMutation(["viewer.teams.upgradeTeam"], {
|
||||
onSuccess: (data) => {
|
||||
// if the user does not already have a Stripe subscription, this wi
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
if (data?.success) {
|
||||
utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("team_upgraded_successfully"), "success");
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={() => {
|
||||
setErrorMessage(null);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<a className="cursor-pointer underline">Upgrade Now</a>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("Purchase missing seats")} />
|
||||
|
||||
<p className="-mt-4 text-sm text-gray-600">{t("changed_team_billing_info")}</p>
|
||||
{data && (
|
||||
<p className="mt-2 text-sm italic text-gray-700">
|
||||
{t("team_upgrade_seats_details", {
|
||||
memberCount: data.totalMembers,
|
||||
unpaidCount: data.missingSeats,
|
||||
seatPrice: 12,
|
||||
totalCost: (data.totalMembers - data.freeSeats) * 12 + 12,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert severity="error" title={errorMessage} message={t("further_billing_help")} className="my-4" />
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Button color="secondary">{t("close")}</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
disabled={mutation.isLoading}
|
||||
onClick={() => {
|
||||
setErrorMessage(null);
|
||||
mutation.mutate({ teamId: props.teamId });
|
||||
}}>
|
||||
{t("upgrade_to_per_seat")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user