Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ecfe89a6f | ||
|
|
b1ced10626 | ||
|
|
b3ba89c074 | ||
|
|
77d339ae8f | ||
|
|
ac9b2d0577 | ||
|
|
03fd546e99 | ||
|
|
6484f1639c | ||
|
|
f934dc6f4f | ||
|
|
dd9d32815a | ||
|
|
5c2ad7827e | ||
|
|
5dc5eadf28 | ||
|
|
d6fc551950 | ||
|
|
5bb868d2c3 | ||
|
|
ec9b3a4261 | ||
|
|
ce04f78a56 | ||
|
|
9f362db60c | ||
|
|
7a93bec735 | ||
|
|
7b1e824a36 | ||
|
|
0746b1e372 | ||
|
|
39747a151c | ||
|
|
b6ae4529e9 | ||
|
|
8c76da8d79 | ||
|
|
4210f35e9c | ||
|
+9 |
eae60043bb | ||
|
|
bf34296864 | ||
|
|
d0a24f7c3c | ||
|
|
f009321deb | ||
|
|
4d04a4d9a6 | ||
|
|
888f60a94c | ||
|
|
d6c4970bf4 | ||
|
|
63ce4a68ac | ||
|
|
cddcd742f6 | ||
|
|
a7a82a6b51 | ||
|
|
1a104f172d | ||
|
|
142be70b89 | ||
|
|
5a0e4c8726 | ||
|
|
f2cdb75f91 | ||
|
|
352ce35b40 | ||
|
|
3fa52b0600 | ||
|
|
a4ff5d0cee | ||
|
|
5f1d20319a | ||
|
|
d597aa91e5 | ||
|
|
803ab6c2e9 | ||
|
|
0a23be4579 | ||
|
|
00c3e9680c | ||
|
|
68ae3c48a0 | ||
|
|
1c59c0dd06 | ||
|
|
2d5bf1ffc1 | ||
|
|
317909d20a | ||
|
|
16eb516a9b | ||
|
|
a0e93e1eeb | ||
|
|
b18eabc6e8 | ||
|
|
5305f31266 | ||
|
|
6232a111ef | ||
|
|
0ea58103c8 | ||
|
|
3d595e9299 | ||
|
|
f135f7baad | ||
|
|
9f1341e94e | ||
|
|
79df03de49 | ||
|
+9 |
b7892b10ef | ||
|
|
ff5fd43a9e | ||
|
|
34c13db872 | ||
|
+8 |
e044f963f8 | ||
|
|
0344130f68 | ||
|
|
5f78eceb89 | ||
|
|
39a3a1affb | ||
|
|
bc6f959c7a | ||
|
|
bb45b9617b | ||
|
+8 |
8725034b6c | ||
|
|
e53abd921e | ||
|
|
7bb3397503 | ||
|
|
72d19d1d62 | ||
|
|
2931f4f84a | ||
|
|
7756b9c3a1 | ||
|
|
477941545e | ||
|
|
237828e4ed | ||
|
|
e23f3b27bf | ||
|
|
ee084aa8bd | ||
|
|
8ece2525e5 | ||
|
|
865c67a962 | ||
|
|
b6be94fd14 | ||
|
|
f298865d9e | ||
|
|
9e85770435 | ||
|
|
1427c8e792 | ||
|
|
baa641d1f4 | ||
|
|
51e08a7642 | ||
|
|
373c255733 | ||
|
|
f4fb4ddad1 | ||
|
|
79c491986a | ||
|
|
759a89bb0c | ||
|
|
41689ecc92 | ||
|
|
9118ca328f | ||
|
|
8fc4d342fd | ||
|
|
7f2560e1e3 | ||
|
|
3e201c9fe8 | ||
|
|
5ab5af753a | ||
|
|
5c01467caa | ||
|
|
3cf47c2ee9 | ||
|
|
8272fcd5c7 | ||
|
|
95e3f55f26 | ||
|
|
431e4224fe | ||
|
|
59ef4fe459 | ||
|
|
4211ec1a82 | ||
|
|
2e169801e8 | ||
|
|
d0d793a719 | ||
|
|
05f303bfad | ||
|
+9 |
c1b41d8251 | ||
|
|
06732c5c6d | ||
|
|
aa038acafd | ||
|
|
9842c18d50 | ||
|
|
68f05ce2c7 | ||
|
|
1aade4fc57 | ||
|
|
33d71f2390 | ||
|
|
44ef1f80e9 | ||
|
|
9e3e1418c2 | ||
|
|
0284ffc75f | ||
|
|
aee1b3b596 | ||
|
|
c1bc0286d1 | ||
|
|
48d1e95509 | ||
|
|
d8489a662e | ||
|
|
0fc1449dd9 | ||
|
|
d5024d1b70 | ||
|
|
b7161694a5 | ||
|
|
cc7e4b3b33 | ||
|
|
3b9653df0e | ||
|
|
83442caf86 | ||
|
|
e6f912bf0c | ||
|
|
e75f200ff7 | ||
|
|
2d30f673e3 | ||
|
|
2e83c7fbf8 | ||
|
|
1b215e4faf | ||
|
|
49429c87cb | ||
|
|
cf423dda70 | ||
|
|
6ee9de6dd8 | ||
|
|
e54bdb4c46 | ||
|
|
1455a6f603 | ||
|
|
9b980b3b85 | ||
|
|
6420218cb3 | ||
|
|
f9ca763523 | ||
|
|
c4f3dec854 | ||
|
|
ff01da540d | ||
|
|
9021486cd7 | ||
|
|
550f98a636 | ||
|
|
9a4746844f | ||
|
|
271d4319b9 | ||
|
|
215e3c93d6 | ||
|
|
670aed9fe0 | ||
|
|
8583d9b2e6 | ||
|
|
88cad2e15f | ||
|
|
1c165bd934 | ||
|
|
74a16e28f4 | ||
|
|
3e417330b5 | ||
|
|
3af31860bb | ||
|
|
f0aded92db | ||
|
|
a28325e69a | ||
|
|
f949cf1bd1 | ||
|
|
ba31c9dcb5 | ||
|
|
e5af99f572 | ||
|
|
c40ff2e6bb | ||
|
|
b1656c7900 | ||
|
|
a9b18496e0 | ||
|
|
d35ad510c9 | ||
|
+9 |
6e834aa8eb | ||
|
|
5293db29d9 | ||
|
|
e772b4203e | ||
|
|
6cc8fcae52 | ||
|
|
7e5c686e26 | ||
|
|
2c6c768145 | ||
|
|
a5fee47989 | ||
|
|
bceeee70e0 | ||
|
|
baecd923af | ||
|
|
1fe1130af1 | ||
|
|
49805054ba | ||
|
|
e2362d8f2a | ||
|
|
9453c1fc3a | ||
|
|
47e496fded | ||
|
|
0289e727ad | ||
|
|
04b5125121 | ||
|
|
368d1afe27 | ||
|
|
2069fd7248 | ||
|
|
7ed21b692e | ||
|
|
4fcdc7bf5c | ||
|
|
2a0a293f8c | ||
|
|
1b15b71a24 | ||
|
|
5d9889a90c | ||
|
|
a7ee3366eb | ||
|
|
c68e7595fe | ||
|
|
3f6be3b2c0 | ||
|
|
f1647248a2 | ||
|
|
d7db1ad303 | ||
|
|
bd48fd01b1 | ||
|
|
fabf692a08 | ||
|
|
ab62fc5d47 | ||
|
|
0c4cb2e2b0 | ||
|
|
7d77132102 | ||
|
|
50ac5f2892 | ||
|
|
fd12fcc4b8 | ||
|
+2 |
58453d0f2f | ||
|
|
44fd1b9fef | ||
|
|
0098591a56 | ||
|
|
41c1e960f8 | ||
|
|
4e49d32c61 | ||
|
+9 |
0b29c5063a | ||
|
|
95a92567cb | ||
|
+7 |
9e183e6ad1 | ||
|
|
7900f8bab5 | ||
|
|
50904d2588 | ||
|
|
6f811f4f0a | ||
|
|
d9000d0cbe | ||
|
|
ff78e66051 | ||
|
|
7c30f8ca4b | ||
|
|
0ebce77a7b | ||
|
|
4282adac4b | ||
|
|
ef611e2360 | ||
|
|
10ab86b332 | ||
|
|
f04cfef0af | ||
|
|
50151403c4 | ||
|
|
7aa131f349 | ||
|
|
d88bdc368b | ||
|
|
255938fda0 | ||
|
|
5766d6f5f4 | ||
|
|
9d299c6d5f | ||
|
|
f49783b453 | ||
|
|
246fce3ab8 | ||
|
|
82f262f60d | ||
|
|
da8d2da368 | ||
|
|
f01058e073 | ||
|
|
8e369d3fb5 | ||
|
|
11c44db609 | ||
|
|
9d911cbb59 | ||
|
|
30a887df6f | ||
|
|
62fea25eb9 | ||
|
|
d43d8700d0 | ||
|
|
e7ed20ea27 | ||
|
|
5e12b5defe | ||
|
|
d241a522fb | ||
|
+2 |
ae929f27b0 | ||
|
|
369d7c4e28 | ||
|
|
4438ca2a63 | ||
|
|
75589db569 | ||
|
|
3752ac767a | ||
|
+3 |
f3e2cf5075 | ||
|
|
92a29593aa | ||
|
|
367c5d89cc | ||
|
|
f21f36547c | ||
|
|
d3f8d954e7 | ||
|
|
27372d624d | ||
|
|
a5d2f2444a | ||
|
|
6d45e1c7c7 | ||
|
|
48419469a6 | ||
|
|
045ebb6e01 | ||
|
|
c78ea5de0b | ||
|
|
d1a787315c | ||
|
|
ce1f1f3c47 | ||
|
|
2f8bd3c07a | ||
|
|
d2f27a0463 | ||
|
|
9134ad8c5e | ||
|
|
506036e751 | ||
|
|
5e80f5056d | ||
|
|
9304ca1415 | ||
|
|
fb246fdee6 | ||
|
|
7b9e77f43a | ||
|
|
47df21322d | ||
|
|
6017806763 | ||
|
|
b347f29d6c | ||
|
|
c26cf74aec | ||
|
|
760ed74a11 | ||
|
|
145fb09b73 | ||
|
|
dd14e827e1 | ||
|
|
246c90c10f | ||
|
|
a42f703c1d | ||
|
|
7256dbf84f | ||
|
|
f5faebad67 | ||
|
+3 |
be15affa5d | ||
|
|
58dd5308ac | ||
|
|
14a6c5a03f | ||
|
|
5af20f5a7b | ||
|
|
8478363a23 | ||
|
|
19b3157c94 | ||
|
|
d4dd451c26 | ||
|
|
6c046f6507 | ||
|
|
a792d31939 | ||
|
|
fc997739b4 | ||
|
|
542d9aa3c6 | ||
|
|
8f3e088db7 | ||
|
|
699313ac70 | ||
|
|
fca7087e7d | ||
|
|
a6b16490c0 | ||
|
|
5da735c418 | ||
|
|
f9ef883f3d | ||
|
|
9ddbaf0966 | ||
|
|
0695ce7edb | ||
|
|
74f0d39997 | ||
|
|
85ab3fb164 | ||
|
|
ab417ac267 | ||
|
|
24635af730 | ||
|
|
682f0c66ff | ||
|
|
ace27ca84e | ||
|
|
ce41397517 | ||
|
|
81aba55710 | ||
|
|
c5fc3a7e8e | ||
|
|
f628ac4366 | ||
|
|
6e2e252c49 | ||
|
|
10d3a126aa | ||
|
|
341c39cb6b | ||
|
|
91909281bc | ||
|
|
f14b13a1ba | ||
|
|
ca859c871c | ||
|
|
6e49a3e1c3 | ||
|
|
830f42bf9a | ||
|
|
0830523b84 | ||
|
|
612b293a70 | ||
|
|
3ecfa75f86 | ||
|
|
631e974769 | ||
|
|
42c128b790 | ||
|
|
7d1065d228 | ||
|
|
e2747f89d0 | ||
|
|
a1d4589e66 | ||
|
|
7d2f87a13e | ||
|
|
4e7200a44b | ||
|
|
52959fe257 | ||
|
|
61b010a60a | ||
|
|
4e6e484d8b | ||
|
|
0a2de203a6 | ||
|
|
48ee7a956c | ||
|
|
ae46d615ef | ||
|
|
9b6d5d2ff8 | ||
|
|
ec0352da20 | ||
|
|
7e65d5d61d | ||
|
|
f855f24d5c | ||
|
|
c2057c4cc2 | ||
|
|
1640264e40 | ||
|
|
579fd399c3 | ||
|
|
1de00d46bf | ||
|
|
01db3ad14e | ||
|
|
c1cb02a9d1 | ||
|
|
344710d1f9 | ||
|
|
6f0b4f94e6 | ||
|
|
c6ad2de2f9 | ||
|
|
9ae3a0eaba | ||
|
|
2f3d7318a4 | ||
|
|
6e71de7ca3 | ||
|
|
7d277ad89f | ||
|
|
ac276a9654 | ||
|
|
588748d78e | ||
|
|
25711ef18e | ||
|
|
440f53714b | ||
|
|
22f6d8ab9b | ||
|
|
57f0d115cf | ||
|
|
23bc6aa600 | ||
|
|
abdb196f56 |
No files matched your search
@@ -97,7 +97,7 @@ LARK_OPEN_VERIFICATION_TOKEN=""
|
|||||||
|
|
||||||
# - WEB3
|
# - WEB3
|
||||||
# Used for the Web3 plugin
|
# Used for the Web3 plugin
|
||||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/web3/README.md
|
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/rainbow/README.md
|
||||||
ALCHEMY_API_KEY=""
|
ALCHEMY_API_KEY=""
|
||||||
INFURA_API_KEY=""
|
INFURA_API_KEY=""
|
||||||
# *********************************************************************************************************
|
# *********************************************************************************************************
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
name: Add PRs to project
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add-PR-to-project:
|
|
||||||
name: Add PR to project
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/add-to-project@v0.1.0
|
|
||||||
with:
|
|
||||||
project-url: https://github.com/orgs/calcom/projects/1
|
|
||||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
||||||
@@ -17,6 +17,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@v0.1.0
|
- uses: actions/add-to-project@v0.1.0
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/calcom/projects/1
|
project-url: https://github.com/orgs/calcom/projects/9
|
||||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
labeled: 🐛 bug, 🎨 needs design, 🙋🏻♂️help wanted, 🚨 urgent
|
labeled: 🙋🏻♂️help wanted
|
||||||
@@ -5,7 +5,7 @@ name: 'Chromatic'
|
|||||||
|
|
||||||
# Event for the workflow
|
# Event for the workflow
|
||||||
on:
|
on:
|
||||||
pull_request_target: # So we can test on forks
|
pull_request: # So we can test on forks
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: Check types
|
name: Check types
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: E2E App-Store Apps
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
||||||
pull_request_target: # So we can test on forks
|
pull_request: # So we can test on forks
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: E2E Embed tests and booking flow(for non-embed as well)
|
name: E2E Embed tests and booking flow(for non-embed as well)
|
||||||
on:
|
on:
|
||||||
pull_request_target: # So we can test on forks
|
pull_request: # So we can test on forks
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
# Embed e2e - tests verify booking flow which is applicable to non-embed case also. So, don't ignore apps/web changes.
|
# Embed e2e - tests verify booking flow which is applicable to non-embed case also. So, don't ignore apps/web changes.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: E2E test
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
||||||
pull_request_target: # So we can test on forks
|
pull_request: # So we can test on forks
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
@@ -25,6 +25,7 @@ jobs:
|
|||||||
GOOGLE_LOGIN_ENABLED: true
|
GOOGLE_LOGIN_ENABLED: true
|
||||||
# CRON_API_KEY: xxx
|
# CRON_API_KEY: xxx
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
|
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: "Pull Request Labeler"
|
name: "Pull Request Labeler"
|
||||||
on:
|
on:
|
||||||
- pull_request_target
|
- pull_request
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Unit tests
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
branches: [fixes/e2e-consolidation] # TODO: Remove this after merged in main
|
||||||
pull_request_target: # So we can test on forks
|
pull_request: # So we can test on forks
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
Vendored
+1
-1
@@ -6,5 +6,5 @@
|
|||||||
},
|
},
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"spellright.language": ["en"],
|
"spellright.language": ["en"],
|
||||||
"spellright.documentTypes": ["markdown"]
|
"spellright.documentTypes": ["markdown", "typescript"]
|
||||||
}
|
}
|
||||||
@@ -4,23 +4,6 @@ Contributions are what make the open source community such an amazing place to l
|
|||||||
|
|
||||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||||
|
|
||||||
## Areas of expertise
|
|
||||||
|
|
||||||
### Legend
|
|
||||||
|
|
||||||
✅ = has knowledge
|
|
||||||
|
|
||||||
🥇 = is their main priority
|
|
||||||
|
|
||||||
⚠️ = is the only one with knowledge
|
|
||||||
|
|
||||||
👀 = has no knowledge but wants to be onboarded
|
|
||||||
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://dynamic-svgs.vercel.app/image.svg?dark">
|
|
||||||
<img alt="Areas of expertise table" src="https://dynamic-svgs.vercel.app/image.svg">
|
|
||||||
</picture>
|
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
The development branch is `main`. This is the branch that all pull
|
The development branch is `main`. This is the branch that all pull
|
||||||
|
|||||||
@@ -31,13 +31,13 @@
|
|||||||
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
||||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
|
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
|
||||||
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
||||||
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
||||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
|
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
|
||||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||||
<a href="https://github.com/orgs/calcom/projects/1/views/25"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
<a href="https://github.com/orgs/calcom/projects/9"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||||
@@ -324,7 +324,7 @@ We have a list of [help wanted](https://github.com/orgs/calcom/projects/1/views/
|
|||||||
6. In the third page (Test Users), add the Google account(s) you'll using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
|
6. In the third page (Test Users), add the Google account(s) you'll using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
|
||||||
7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option.
|
7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option.
|
||||||
8. Select Web Application as the Application Type.
|
8. Select Web Application as the Application Type.
|
||||||
9. Under Authorized redirect URI's, select Add URI and then add the URI `<Cal.com URL>/api/integrations/googlecalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
9. Under Authorized redirect URI's, select Add URI and then add the URI `<Cal.com URL>/api/integrations/googlecalendar/callback` and `<Cal.com URL>/api/auth/callback/google` replacing Cal.com URL with the URI at which your application runs.
|
||||||
10. The key will be created and you will be redirected back to the Credentials page. Select the newly generated client ID under OAuth 2.0 Client IDs.
|
10. The key will be created and you will be redirected back to the Credentials page. Select the newly generated client ID under OAuth 2.0 Client IDs.
|
||||||
11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the .env file as the value for GOOGLE_API_CREDENTIALS key.
|
11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the .env file as the value for GOOGLE_API_CREDENTIALS key.
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule apps/api updated: a36b6fa923...18e96e2a47.
+1
-1
Submodule apps/console updated: 995771ebbd...8313b3b402.
@@ -15,14 +15,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/dayjs": "*",
|
"@calcom/dayjs": "*",
|
||||||
"@calcom/ui": "*",
|
"@calcom/ui": "*",
|
||||||
"@radix-ui/react-avatar": "^0.1.4",
|
"@radix-ui/react-avatar": "^1.0.0",
|
||||||
"@radix-ui/react-collapsible": "^0.1.0",
|
"@radix-ui/react-collapsible": "^1.0.0",
|
||||||
"@radix-ui/react-dialog": "^0.1.0",
|
"@radix-ui/react-dialog": "^1.0.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^0.1.1",
|
"@radix-ui/react-dropdown-menu": "^1.0.0",
|
||||||
"@radix-ui/react-id": "^0.1.0",
|
"@radix-ui/react-id": "^1.0.0",
|
||||||
"@radix-ui/react-radio-group": "^0.1.1",
|
"@radix-ui/react-popover": "^1.0.0",
|
||||||
"@radix-ui/react-slider": "^0.1.1",
|
"@radix-ui/react-radio-group": "^1.0.0",
|
||||||
"@radix-ui/react-switch": "^0.1.1",
|
"@radix-ui/react-slider": "^1.0.0",
|
||||||
|
"@radix-ui/react-switch": "^1.0.0",
|
||||||
"@radix-ui/react-tooltip": "^1.0.0",
|
"@radix-ui/react-tooltip": "^1.0.0",
|
||||||
"next": "^12.2.5",
|
"next": "^12.2.5",
|
||||||
"next-transpile-modules": "^9.0.0",
|
"next-transpile-modules": "^9.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ComponentMeta } from "@storybook/react";
|
||||||
|
|
||||||
|
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Toggle Group",
|
||||||
|
component: ToggleGroup,
|
||||||
|
} as ComponentMeta<typeof ToggleGroup>;
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<ToggleGroup
|
||||||
|
defaultValue="12"
|
||||||
|
options={[
|
||||||
|
{ value: "12", label: "12h" },
|
||||||
|
{ value: "24", label: "24h" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -426,7 +426,7 @@ paths:
|
|||||||
- installed: true
|
- installed: true
|
||||||
type: caldav_calendar
|
type: caldav_calendar
|
||||||
credential: null
|
credential: null
|
||||||
title: CalDav Server
|
title: CalDav (Beta)
|
||||||
imageSrc: integrations/caldav.svg
|
imageSrc: integrations/caldav.svg
|
||||||
description: For personal and business calendars
|
description: For personal and business calendars
|
||||||
__N_SSP: true
|
__N_SSP: true
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
|||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { App as AppType } from "@calcom/types/App";
|
import { App as AppType } from "@calcom/types/App";
|
||||||
import { Button, SkeletonButton } from "@calcom/ui";
|
import { Button, SkeletonButton } from "@calcom/ui";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import Shell from "@calcom/ui/Shell";
|
import Shell from "@calcom/ui/Shell";
|
||||||
import Badge from "@calcom/ui/v2/core/Badge";
|
import Badge from "@calcom/ui/v2/core/Badge";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
import DisconnectIntegration from "@components/integrations/DisconnectIntegration";
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useRouter } from "next/router";
|
import { NextRouter, useRouter } from "next/router";
|
||||||
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
||||||
import { components, ControlProps } from "react-select";
|
import { components, ControlProps } from "react-select";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
|
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
|
||||||
import { HorizontalTabs, showToast } from "@calcom/ui/v2";
|
import { Button, HorizontalTabs, showToast, Switch } from "@calcom/ui/v2";
|
||||||
import { Button, 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 { EMBED_LIB_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||||
|
|
||||||
@@ -46,6 +45,23 @@ const getDimension = (dimension: string) => {
|
|||||||
return dimension;
|
return dimension;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goto = (router: NextRouter, searchParams: Record<string, string>) => {
|
||||||
|
const newQuery = new URLSearchParams(router.asPath.split("?")[1]);
|
||||||
|
Object.keys(searchParams).forEach((key) => {
|
||||||
|
newQuery.set(key, searchParams[key]);
|
||||||
|
});
|
||||||
|
router.push(`${router.asPath.split("?")[0]}?${newQuery.toString()}`, undefined, {
|
||||||
|
shallow: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeQueryParams = (router: NextRouter, queryParams: string[]) => {
|
||||||
|
queryParams.forEach((param) => {
|
||||||
|
delete router.query[param];
|
||||||
|
});
|
||||||
|
router.push(`${router.asPath.split("?")[0]}?${router.query.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* It allows us to show code with certain reusable blocks indented according to the block variable placement
|
* It allows us to show code with certain reusable blocks indented according to the block variable placement
|
||||||
* So, if you add a variable ${abc} with indentation of 4 spaces, it will automatically indent all newlines in `abc` with the same indent before constructing the final string
|
* So, if you add a variable ${abc} with indentation of 4 spaces, it will automatically indent all newlines in `abc` with the same indent before constructing the final string
|
||||||
@@ -452,7 +468,7 @@ const embeds: {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
name: "HTML",
|
name: "HTML",
|
||||||
embedTabName: "embed-code",
|
href: "embedTabName=embed-code",
|
||||||
icon: Icon.FiCode,
|
icon: Icon.FiCode,
|
||||||
type: "code",
|
type: "code",
|
||||||
Component: forwardRef<
|
Component: forwardRef<
|
||||||
@@ -503,7 +519,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "React",
|
name: "React",
|
||||||
embedTabName: "embed-react",
|
href: "embedTabName=embed-react",
|
||||||
icon: Icon.FiCode,
|
icon: Icon.FiCode,
|
||||||
type: "code",
|
type: "code",
|
||||||
Component: forwardRef<
|
Component: forwardRef<
|
||||||
@@ -543,8 +559,8 @@ ${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, prev
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Preview",
|
name: "Preview",
|
||||||
embedTabName: "embed-preview",
|
href: "embedTabName=embed-preview",
|
||||||
icon: Icon.FiEye,
|
icon: Icon.FiTrello,
|
||||||
type: "iframe",
|
type: "iframe",
|
||||||
Component: forwardRef<
|
Component: forwardRef<
|
||||||
HTMLIFrameElement | HTMLTextAreaElement | null,
|
HTMLIFrameElement | HTMLTextAreaElement | null,
|
||||||
@@ -590,7 +606,7 @@ const ChooseEmbedTypesDialogContent = () => {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<DialogContent size="lg">
|
<DialogContent type="creation" useOwnActionButtons size="lg">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
<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")}
|
||||||
@@ -606,11 +622,8 @@ const ChooseEmbedTypesDialogContent = () => {
|
|||||||
key={index}
|
key={index}
|
||||||
data-testid={embed.type}
|
data-testid={embed.type}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push({
|
goto(router, {
|
||||||
query: {
|
embedType: embed.type,
|
||||||
...router.query,
|
|
||||||
embedType: embed.type,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
||||||
@@ -635,6 +648,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const s = (href: string) => {
|
||||||
|
const searchParams = new URLSearchParams(router.asPath.split("?")[1] || "");
|
||||||
|
const [a, b] = href.split("=");
|
||||||
|
searchParams.set(a, b);
|
||||||
|
return `${router.asPath.split("?")[0]}?${searchParams.toString()}`;
|
||||||
|
};
|
||||||
|
const parsedTabs = tabs.map((t) => ({ ...t, href: s(t.href) }));
|
||||||
const embedCodeRefs: Record<typeof tabs[0]["name"], RefObject<HTMLTextAreaElement>> = {};
|
const embedCodeRefs: Record<typeof tabs[0]["name"], RefObject<HTMLTextAreaElement>> = {};
|
||||||
tabs
|
tabs
|
||||||
.filter((tab) => tab.type === "code")
|
.filter((tab) => tab.type === "code")
|
||||||
@@ -661,28 +681,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
const noPopupQuery = {
|
removeQueryParams(router, ["dialog", ...queryParamsForDialog]);
|
||||||
...router.query,
|
|
||||||
};
|
|
||||||
|
|
||||||
delete noPopupQuery.dialog;
|
|
||||||
|
|
||||||
queryParamsForDialog.forEach((queryParam) => {
|
|
||||||
delete noPopupQuery[queryParam];
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push({
|
|
||||||
query: noPopupQuery,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use embed-code as default tab
|
// Use embed-code as default tab
|
||||||
if (!router.query.embedTabName) {
|
if (!router.query.embedTabName) {
|
||||||
router.query.embedTabName = "embed-code";
|
goto(router, {
|
||||||
router.push({
|
embedTabName: "embed-code",
|
||||||
query: {
|
|
||||||
...router.query,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,20 +784,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent size="lg">
|
<DialogContent size="xl" className="p-0.5" type="creation" useOwnActionButtons>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex w-1/3 flex-col bg-white p-6">
|
<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">
|
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newQuery = { ...router.query };
|
removeQueryParams(router, ["embedType", "embedTabName"]);
|
||||||
delete newQuery.embedType;
|
|
||||||
delete newQuery.embedTabName;
|
|
||||||
router.push({
|
|
||||||
query: {
|
|
||||||
...newQuery,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}>
|
}}>
|
||||||
<Icon.FiArrowLeft className="mr-4 w-4" />
|
<Icon.FiArrowLeft className="mr-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1040,14 +1038,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-2/3 flex-col p-6">
|
<div className="flex w-2/3 flex-col p-8">
|
||||||
<HorizontalTabs tabNameKey="embedTabName" data-testid="embed-tabs" tabs={tabs} />
|
<HorizontalTabs data-testid="embed-tabs" tabs={parsedTabs} linkProps={{ shallow: true }} />
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.embedTabName}
|
key={tab.href}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
router.query.embedTabName === tab.embedTabName ? "flex flex-grow flex-col" : "hidden"
|
router.query.embedTabName === tab.href.split("=")[1] ? "flex flex-grow flex-col" : "hidden"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex h-[55vh] flex-grow flex-col">
|
<div className="flex h-[55vh] flex-grow flex-col">
|
||||||
{tab.type === "code" ? (
|
{tab.type === "code" ? (
|
||||||
@@ -1133,19 +1131,10 @@ export const EmbedButton = <T extends React.ElementType>({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
className = classNames(className, "hidden lg:inline-flex");
|
className = classNames(className, "hidden lg:inline-flex");
|
||||||
const openEmbedModal = () => {
|
const openEmbedModal = () => {
|
||||||
const query = {
|
goto(router, {
|
||||||
...router.query,
|
|
||||||
dialog: "embed",
|
dialog: "embed",
|
||||||
embedUrl,
|
embedUrl,
|
||||||
};
|
});
|
||||||
router.push(
|
|
||||||
{
|
|
||||||
pathname: router.pathname,
|
|
||||||
query,
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ shallow: true }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
const Component = as ?? Button;
|
const Component = as ?? Button;
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ export type GateState = {
|
|||||||
type GateProps = {
|
type GateProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
gates: Gate[];
|
gates: Gate[];
|
||||||
metadata: JSONObject;
|
appData: JSONObject;
|
||||||
dispatch: Dispatch<Partial<GateState>>;
|
dispatch: Dispatch<Partial<GateState>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RainbowGate = dynamic(() => import("@calcom/app-store/rainbow/components/RainbowKit"));
|
const RainbowGate = dynamic(() => import("@calcom/app-store/rainbow/components/RainbowKit"));
|
||||||
|
|
||||||
// To add a new Gate just add the gate logic to the switch statement
|
// To add a new Gate just add the gate logic to the switch statement
|
||||||
const Gates: React.FC<GateProps> = ({ children, gates, metadata, dispatch }) => {
|
const Gates: React.FC<GateProps> = ({ children, gates, appData, dispatch }) => {
|
||||||
const [rainbowToken, setRainbowToken] = useState<string>();
|
const [rainbowToken, setRainbowToken] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,12 +31,12 @@ const Gates: React.FC<GateProps> = ({ children, gates, metadata, dispatch }) =>
|
|||||||
for (const gate of gates) {
|
for (const gate of gates) {
|
||||||
switch (gate) {
|
switch (gate) {
|
||||||
case "rainbow":
|
case "rainbow":
|
||||||
if (metadata.blockchainId && metadata.smartContractAddress && !rainbowToken) {
|
if (appData.blockchainId && appData.smartContractAddress && !rainbowToken) {
|
||||||
gateWrappers = (
|
gateWrappers = (
|
||||||
<RainbowGate
|
<RainbowGate
|
||||||
setToken={setRainbowToken}
|
setToken={setRainbowToken}
|
||||||
chainId={metadata.blockchainId as number}
|
chainId={appData.blockchainId as number}
|
||||||
tokenAddress={metadata.smartContractAddress as string}>
|
tokenAddress={appData.smartContractAddress as string}>
|
||||||
{gateWrappers}
|
{gateWrappers}
|
||||||
</RainbowGate>
|
</RainbowGate>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export function useViewerI18n() {
|
|||||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||||
* We intend to not cache i18n query
|
* We intend to not cache i18n query
|
||||||
**/
|
**/
|
||||||
context: { skipBatch: true },
|
trpc: {
|
||||||
|
context: { skipBatch: true },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-21
@@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Select from "react-select";
|
|
||||||
import { OptionProps } from "react-select";
|
import { OptionProps } from "react-select";
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
@@ -8,6 +7,7 @@ import { trpc } from "@calcom/trpc/react";
|
|||||||
import type { App } from "@calcom/types/App";
|
import type { App } from "@calcom/types/App";
|
||||||
import { Button } from "@calcom/ui";
|
import { Button } from "@calcom/ui";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
|
import { Select } from "@calcom/ui/v2";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
@@ -23,11 +23,11 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
|||||||
type={data.type}
|
type={data.type}
|
||||||
render={(installProps) => {
|
render={(installProps) => {
|
||||||
return (
|
return (
|
||||||
<Button {...installProps} className="w-full" color="minimal">
|
<Button {...installProps} className="flex w-full align-top" color="minimal">
|
||||||
{data.image && (
|
{data.image && (
|
||||||
<img className="float-left mr-3 inline h-5 w-5" src={data.image} alt={data.label} />
|
<img className="float-left mr-3 inline h-5 w-5" src={data.image} alt={data.label} />
|
||||||
)}
|
)}
|
||||||
<p>{data.label}</p>
|
<p className="text-left">{`${t("add")} ${data.label}`}</p>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -35,7 +35,7 @@ const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App
|
|||||||
) : (
|
) : (
|
||||||
<Button className="w-full" color="minimal" href="/apps/categories/calendar">
|
<Button className="w-full" color="minimal" href="/apps/categories/calendar">
|
||||||
<Icon.FiPlus className="text-color mr-3 ml-1 h-4 w-4" />
|
<Icon.FiPlus className="text-color mr-3 ml-1 h-4 w-4" />
|
||||||
<p>{t("add_new_calendar")}...</p>
|
<p>{t("install_new_calendar_app")}</p>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -63,27 +63,45 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
|||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
name="additionalCalendar"
|
name="additionalCalendar"
|
||||||
placeholder={t("install_another")}
|
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>
|
||||||
|
}
|
||||||
|
className="min-w-44"
|
||||||
options={options}
|
options={options}
|
||||||
styles={{
|
|
||||||
placeholder: (defaultStyles) => {
|
|
||||||
return {
|
|
||||||
...defaultStyles,
|
|
||||||
color: "#3E3E3E",
|
|
||||||
marginLeft: "3px",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
control: (defaultStyles) => {
|
|
||||||
return {
|
|
||||||
...defaultStyles,
|
|
||||||
borderRadius: "6px",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="min-w-52 block w-full flex-1 rounded-none rounded-r-sm border-gray-300 text-sm font-medium text-gray-700"
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
components={{ Option: ImageOption }}
|
components={{ Option: ImageOption }}
|
||||||
|
styles={{
|
||||||
|
menu: (defaultStyles) => ({
|
||||||
|
...defaultStyles,
|
||||||
|
"@media only screen and (max-width: 640px)": {
|
||||||
|
left: "0",
|
||||||
|
},
|
||||||
|
width: "max-content",
|
||||||
|
right: "0",
|
||||||
|
}),
|
||||||
|
control: (defaultStyles) => ({
|
||||||
|
...defaultStyles,
|
||||||
|
padding: "0",
|
||||||
|
border: "0",
|
||||||
|
borderRadius: "inherit",
|
||||||
|
}),
|
||||||
|
dropdownIndicator: (defaultStyles) => ({
|
||||||
|
...defaultStyles,
|
||||||
|
display: "none",
|
||||||
|
}),
|
||||||
|
valueContainer: (defaultStyles) => ({
|
||||||
|
...defaultStyles,
|
||||||
|
padding: "0",
|
||||||
|
}),
|
||||||
|
placeholder: (defaultStyles) => ({
|
||||||
|
...defaultStyles,
|
||||||
|
margin: "0",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import type { App } from "@calcom/types/App";
|
|
||||||
|
|
||||||
import AppCard from "./AppCard";
|
|
||||||
|
|
||||||
export default function AllApps({ apps }: { apps: App[] }) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-16">
|
|
||||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("all_apps")}</h2>
|
|
||||||
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
||||||
{apps.map((app) => (
|
|
||||||
<AppCard key={app.name} app={app} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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/v2/LicenseRequired";
|
||||||
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
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 { showToast, SkeletonText } from "@calcom/ui/v2";
|
||||||
|
import { Button, SkeletonButton, Shell } from "@calcom/ui/v2";
|
||||||
|
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
|
||||||
|
|
||||||
|
import HeadSeo from "@components/seo/head-seo";
|
||||||
|
|
||||||
|
const Component = ({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
logo,
|
||||||
|
slug,
|
||||||
|
variant,
|
||||||
|
body,
|
||||||
|
categories,
|
||||||
|
author,
|
||||||
|
price = 0,
|
||||||
|
commission,
|
||||||
|
isGlobal = false,
|
||||||
|
feeType,
|
||||||
|
docs,
|
||||||
|
website,
|
||||||
|
email,
|
||||||
|
tos,
|
||||||
|
privacy,
|
||||||
|
isProOnly,
|
||||||
|
images,
|
||||||
|
}: Parameters<typeof App>[0]) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const hasImages = images && images.length > 0;
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const mutation = useAddAppMutation(null, {
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast(t("app_successfully_installed"), "success");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof Error) showToast(error.message || t("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="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||||
|
{hasImages && (
|
||||||
|
<div className="align-center mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||||
|
{images ? (
|
||||||
|
images.map((img) => (
|
||||||
|
<img
|
||||||
|
key={img}
|
||||||
|
src={img}
|
||||||
|
alt={`Screenshot of app ${name}`}
|
||||||
|
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SkeletonText />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
|
||||||
|
hasImages && "lg:ml-8"
|
||||||
|
)}>
|
||||||
|
<div className="mb-8 flex pt-4">
|
||||||
|
<header>
|
||||||
|
<div className="mb-4 flex items-center">
|
||||||
|
<img className="min-h-16 min-w-16 h-16 w-16" src={logo} alt={name} />
|
||||||
|
<h1 className="font-cal ml-4 text-3xl text-gray-900">{name}</h1>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-medium text-gray-600">
|
||||||
|
<Link href={`categories/${categories[0]}`}>
|
||||||
|
<a className="rounded-md bg-gray-100 p-1 text-xs capitalize text-gray-800">{categories[0]}</a>
|
||||||
|
</Link>{" "}
|
||||||
|
• {t("published_by", { author })}
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
{!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("default")}
|
||||||
|
</Button>
|
||||||
|
{!isGlobal && (
|
||||||
|
<InstallAppButton
|
||||||
|
type={type}
|
||||||
|
isProOnly={isProOnly}
|
||||||
|
render={({ useDefaultComponent, ...props }) => {
|
||||||
|
if (useDefaultComponent) {
|
||||||
|
props = {
|
||||||
|
onClick: () => {
|
||||||
|
mutation.mutate({ type, variant, slug });
|
||||||
|
},
|
||||||
|
loading: mutation.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
StartIcon={Icon.FiPlus}
|
||||||
|
{...props}
|
||||||
|
// @TODO: Overriding color and size prevent us from
|
||||||
|
// having to duplicate InstallAppButton for now.
|
||||||
|
color="primary"
|
||||||
|
size="base"
|
||||||
|
data-testid="install-app-button">
|
||||||
|
{t("install_another")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : existingCredentials.length > 0 ? (
|
||||||
|
<DisconnectIntegration
|
||||||
|
label={t("disconnect")}
|
||||||
|
credentialId={existingCredentials[0]}
|
||||||
|
onSuccess={() => {
|
||||||
|
router.replace("/apps/installed");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InstallAppButton
|
||||||
|
type={type}
|
||||||
|
isProOnly={isProOnly}
|
||||||
|
render={({ useDefaultComponent, ...props }) => {
|
||||||
|
if (useDefaultComponent) {
|
||||||
|
props = {
|
||||||
|
onClick: () => {
|
||||||
|
mutation.mutate({ type, variant, slug });
|
||||||
|
},
|
||||||
|
loading: mutation.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-testid="install-app-button"
|
||||||
|
{...props}
|
||||||
|
// @TODO: Overriding color and size prevent us from
|
||||||
|
// having to duplicate InstallAppButton for now.
|
||||||
|
color="primary"
|
||||||
|
size="base">
|
||||||
|
{t("install_app")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SkeletonButton className="h-10 w-24" />
|
||||||
|
)}
|
||||||
|
{price !== 0 && (
|
||||||
|
<span className="block text-right">
|
||||||
|
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||||
|
{feeType === "monthly" && "/" + t("month")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="prose prose-sm mt-8">{body}</div>
|
||||||
|
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||||
|
<span>
|
||||||
|
{price === 0 ? (
|
||||||
|
"Free"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
useGrouping: false,
|
||||||
|
}).format(price)}
|
||||||
|
{feeType === "monthly" && "/" + t("month")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h4 className="mt-8 mb-2 font-semibold text-gray-900 ">{t("learn_more")}</h4>
|
||||||
|
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||||
|
{docs && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm font-normal text-black no-underline hover:underline"
|
||||||
|
href={docs}>
|
||||||
|
<Icon.FiBookOpen className="mr-1 -mt-1 inline h-4 w-4 text-gray-500" />
|
||||||
|
{t("documentation")}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{website && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-normal text-black no-underline hover:underline"
|
||||||
|
href={website}>
|
||||||
|
<Icon.FiExternalLink className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||||
|
{website.replace("https://", "")}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{email && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-normal text-black no-underline hover:underline"
|
||||||
|
href={"mailto:" + email}>
|
||||||
|
<Icon.FiMail className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||||
|
|
||||||
|
{email}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{tos && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-normal text-black no-underline hover:underline"
|
||||||
|
href={tos}>
|
||||||
|
<Icon.FiFile className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||||
|
{t("terms_of_service")}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{privacy && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-normal text-black no-underline hover:underline"
|
||||||
|
href={privacy}>
|
||||||
|
<Icon.FiShield className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||||
|
{t("privacy_policy")}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<Icon.FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App(props: {
|
||||||
|
name: string;
|
||||||
|
description: AppType["description"];
|
||||||
|
type: AppType["type"];
|
||||||
|
isGlobal?: AppType["isGlobal"];
|
||||||
|
logo: string;
|
||||||
|
slug: string;
|
||||||
|
variant: 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"];
|
||||||
|
images?: string[];
|
||||||
|
}) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell large isPublic heading={t("app_store")} backPath="/apps" withoutSeo>
|
||||||
|
<HeadSeo
|
||||||
|
title={props.name}
|
||||||
|
description={props.description}
|
||||||
|
app={{ slug: props.logo, name: props.name, description: props.description }}
|
||||||
|
nextSeoProps={{
|
||||||
|
nofollow: true,
|
||||||
|
noindex: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{props.licenseRequired ? (
|
||||||
|
<LicenseRequired>
|
||||||
|
<Component {...props} />
|
||||||
|
</LicenseRequired>
|
||||||
|
) : (
|
||||||
|
<Component {...props} />
|
||||||
|
)}
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import { App } from "@calcom/types/App";
|
|
||||||
import Badge from "@calcom/ui/Badge";
|
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
|
|
||||||
interface AppCardProps {
|
|
||||||
app: App;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppCard(props: AppCardProps) {
|
|
||||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
|
||||||
return (
|
|
||||||
<Link href={"/apps/" + props.app.slug}>
|
|
||||||
<a
|
|
||||||
className="block h-full rounded-sm border border-gray-300 p-5 hover:bg-neutral-50"
|
|
||||||
data-testid={`app-store-app-card-${props.app.slug}`}>
|
|
||||||
<div className="flex">
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={props.app.logo} alt={props.app.name + " Logo"} className="mb-4 h-12 w-12 rounded-sm" />
|
|
||||||
}
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
className="ml-auto flex self-start"
|
|
||||||
onClick={() => {
|
|
||||||
console.log("The magic is supposed to happen here");
|
|
||||||
}}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<h3 className="font-medium">{props.app.name}</h3>
|
|
||||||
</div>
|
|
||||||
{/* TODO: add reviews <div className="flex text-sm text-gray-800">
|
|
||||||
<span>{props.rating} stars</span> <StarIcon className="ml-1 mt-0.5 h-4 w-4 text-yellow-600" />
|
|
||||||
<span className="pl-1 text-gray-500">{props.reviews} reviews</span>
|
|
||||||
</div> */}
|
|
||||||
<p className="mt-2 truncate text-sm text-gray-500">{props.app.description}</p>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+45
-33
@@ -1,25 +1,25 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import Link from "next/link";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { useMutation } from "react-query";
|
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||||
import { Alert, EmptyScreen } from "@calcom/ui/v2";
|
import { Alert, Button, EmptyScreen } from "@calcom/ui/v2";
|
||||||
import { List } from "@calcom/ui/v2/core/List";
|
import { List } from "@calcom/ui/v2/core/List";
|
||||||
import { ShellSubHeading } from "@calcom/ui/v2/core/Shell";
|
import { ShellSubHeading } from "@calcom/ui/v2/core/Shell";
|
||||||
import Switch from "@calcom/ui/v2/core/Switch";
|
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 DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
|
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
|
||||||
|
import IntegrationListItem from "@components/apps/IntegrationListItem";
|
||||||
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||||
import AdditionalCalendarSelector from "@components/v2/apps/AdditionalCalendarSelector";
|
|
||||||
import DestinationCalendarSelector from "@components/v2/apps/DestinationCalendarSelector";
|
|
||||||
import IntegrationListItem from "@components/v2/apps/IntegrationListItem";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChanged: () => unknown | Promise<unknown>;
|
onChanged: () => unknown | Promise<unknown>;
|
||||||
@@ -140,6 +140,7 @@ function CalendarList(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo: @hariom extract this into packages/apps-store as "GeneralAppSettings"
|
||||||
function ConnectedCalendarsList(props: Props) {
|
function ConnectedCalendarsList(props: Props) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
||||||
@@ -153,7 +154,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<List className="flex flex-col gap-6">
|
<List className="flex flex-col gap-6" noBorderTreatment>
|
||||||
{data.connectedCalendars.map((item) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
<Fragment key={item.credentialId}>
|
<Fragment key={item.credentialId}>
|
||||||
{item.calendars ? (
|
{item.calendars ? (
|
||||||
@@ -162,6 +163,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
title={item.integration.title}
|
title={item.integration.title}
|
||||||
logo={item.integration.logo}
|
logo={item.integration.logo}
|
||||||
description={item.primary?.externalId || "No external Id"}
|
description={item.primary?.externalId || "No external Id"}
|
||||||
|
separate={true}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex w-32 justify-end">
|
<div className="flex w-32 justify-end">
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
@@ -172,31 +174,41 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
{!fromOnboarding && (
|
<div className="border-t border-gray-200">
|
||||||
<>
|
{!fromOnboarding && (
|
||||||
<p className="px-4 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
|
<>
|
||||||
<ul className="space-y-2 p-4">
|
<p className="px-4 pt-4 text-sm text-neutral-500">
|
||||||
{item.calendars.map((cal) => (
|
{t("toggle_calendars_conflict")}
|
||||||
<CalendarSwitch
|
</p>
|
||||||
key={cal.externalId}
|
<ul className="space-y-2 p-4">
|
||||||
externalId={cal.externalId}
|
{item.calendars.map((cal) => (
|
||||||
title={cal.name || "Nameless calendar"}
|
<CalendarSwitch
|
||||||
type={item.integration.type}
|
key={cal.externalId}
|
||||||
defaultSelected={cal.isSelected}
|
externalId={cal.externalId}
|
||||||
destination={cal.externalId === props.destinationCalendarId}
|
title={cal.name || "Nameless calendar"}
|
||||||
/>
|
type={item.integration.type}
|
||||||
))}
|
defaultSelected={cal.isSelected}
|
||||||
</ul>
|
destination={cal.externalId === props.destinationCalendarId}
|
||||||
</>
|
/>
|
||||||
)}
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</IntegrationListItem>
|
</IntegrationListItem>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
severity="warning"
|
severity="warning"
|
||||||
title={t("calendar_error")}
|
title={t("something_went_wrong")}
|
||||||
message={item.error?.message}
|
message={
|
||||||
|
<span>
|
||||||
|
<Link href={"/apps/" + item.integration.slug}>{item.integration.name}</Link>:{" "}
|
||||||
|
{t("calendar_error")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
|
||||||
actions={
|
actions={
|
||||||
<div className="w-32">
|
<div className="flex w-32 justify-end md:pr-1">
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
credentialId={item.credentialId}
|
credentialId={item.credentialId}
|
||||||
trashIcon
|
trashIcon
|
||||||
@@ -251,11 +263,11 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
|||||||
<ShellSubHeading
|
<ShellSubHeading
|
||||||
title={t("calendar")}
|
title={t("calendar")}
|
||||||
subtitle={t("installed_app_calendar_description")}
|
subtitle={t("installed_app_calendar_description")}
|
||||||
className="flex items-center"
|
className="mb-0 flex flex-wrap items-center gap-4 sm:flex-nowrap md:mb-3 md:gap-0"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||||
{!!data.connectedCalendars.length && (
|
{!!data.connectedCalendars.length && (
|
||||||
<div className=" flex items-center">
|
<div className="flex items-center">
|
||||||
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
|
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -263,7 +275,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between rounded-md border border-gray-200 bg-gray-50 p-4">
|
<div className="flex justify-between rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||||
<div className="flex w-full items-center gap-4">
|
<div className="flex w-full flex-col items-start gap-4 md:flex-row md:items-center">
|
||||||
<div className="relative rounded-md border border-gray-200 bg-white p-1.5">
|
<div className="relative rounded-md border border-gray-200 bg-white p-1.5">
|
||||||
<Icon.FiCalendar className="h-8 w-8" strokeWidth="1" />
|
<Icon.FiCalendar className="h-8 w-8" strokeWidth="1" />
|
||||||
<Icon.FiPlus
|
<Icon.FiPlus
|
||||||
@@ -271,11 +283,11 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
|||||||
strokeWidth="4"
|
strokeWidth="4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-6/12">
|
<div className="md:w-6/12">
|
||||||
<h1 className="text-sm font-semibold">{t("create_events_on")}</h1>
|
<h1 className="text-sm font-semibold">{t("create_events_on")}</h1>
|
||||||
<p className="text-sm font-normal">{t("set_calendar")}</p>
|
<p className="text-sm font-normal">{t("set_calendar")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-6/12 justify-end">
|
<div className="justify-end md:w-6/12">
|
||||||
<DestinationCalendarSelector
|
<DestinationCalendarSelector
|
||||||
onChange={mutation.mutate}
|
onChange={mutation.mutate}
|
||||||
hidePlaceholder
|
hidePlaceholder
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
|
|
||||||
export default function AppStoreCategories({
|
|
||||||
categories,
|
|
||||||
}: {
|
|
||||||
categories: {
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
}[];
|
|
||||||
}) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
return (
|
|
||||||
<div className="mb-16">
|
|
||||||
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("popular_categories")}</h2>
|
|
||||||
<div className="grid-col-1 grid w-full gap-3 overflow-scroll sm:grid-flow-col">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<Link key={category.name} href={"/apps/categories/" + category.name}>
|
|
||||||
<a
|
|
||||||
data-testid={`app-store-category-${category.name}`}
|
|
||||||
className="relative flex rounded-sm bg-gray-100 px-6 py-4 sm:block">
|
|
||||||
<div className="min-w-24 -ml-5 text-center sm:ml-0">
|
|
||||||
<Image
|
|
||||||
alt={category.name}
|
|
||||||
width="352"
|
|
||||||
height="252"
|
|
||||||
layout="responsive"
|
|
||||||
src={"/app-store/" + category.name + ".svg"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="self-center">
|
|
||||||
<h3 className="font-medium capitalize">{category.name}</h3>
|
|
||||||
<p className="text-sm text-gray-500">{category.count} apps</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+2
-5
@@ -51,7 +51,7 @@ const DestinationCalendarSelector = ({
|
|||||||
if (selected) {
|
if (selected) {
|
||||||
setSelectedOption({
|
setSelectedOption({
|
||||||
value: `${selected.integration}:${selected.externalId}`,
|
value: `${selected.integration}:${selected.externalId}`,
|
||||||
label: `<span class="font-semibold">${selected.name}</span> (${selected.externalId})` || "",
|
label: `${selected.name} (${selected.externalId})` || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [query.data?.connectedCalendars, value]);
|
}, [query.data?.connectedCalendars, value]);
|
||||||
@@ -66,7 +66,7 @@ const DestinationCalendarSelector = ({
|
|||||||
options: (selectedCalendar.calendars ?? [])
|
options: (selectedCalendar.calendars ?? [])
|
||||||
.filter((cal) => cal.readOnly === false)
|
.filter((cal) => cal.readOnly === false)
|
||||||
.map((cal) => ({
|
.map((cal) => ({
|
||||||
label: `<span class="font-semibold">${cal.name}</span> (${cal.externalId})` || "",
|
label: `${cal.name} (${cal.externalId})` || "",
|
||||||
value: `${cal.integration}:${cal.externalId}`,
|
value: `${cal.integration}:${cal.externalId}`,
|
||||||
})),
|
})),
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
@@ -83,9 +83,6 @@ const DestinationCalendarSelector = ({
|
|||||||
title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||||
<Select
|
<Select
|
||||||
name="primarySelectedCalendar"
|
name="primarySelectedCalendar"
|
||||||
formatOptionLabel={function (data) {
|
|
||||||
return <span dangerouslySetInnerHTML={{ __html: data.label }} />;
|
|
||||||
}}
|
|
||||||
placeholder={
|
placeholder={
|
||||||
!hidePlaceholder ? (
|
!hidePlaceholder ? (
|
||||||
`${t("select_destination_calendar")}`
|
`${t("select_destination_calendar")}`
|
||||||
+17
-2
@@ -2,6 +2,8 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
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 { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/v2/core/List";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
@@ -16,7 +18,10 @@ function IntegrationListItem(props: {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
logo: string;
|
logo: string;
|
||||||
destination?: boolean;
|
destination?: boolean;
|
||||||
|
separate?: boolean;
|
||||||
|
invalidCredential?: boolean;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { hl } = router.query;
|
const { hl } = router.query;
|
||||||
const [highlight, setHighlight] = useState(hl === props.slug);
|
const [highlight, setHighlight] = useState(hl === props.slug);
|
||||||
@@ -32,7 +37,8 @@ function IntegrationListItem(props: {
|
|||||||
<ListItem
|
<ListItem
|
||||||
expanded={!!props.children}
|
expanded={!!props.children}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"my-0 flex-col rounded-none border-0 transition-colors duration-500",
|
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
||||||
|
"my-0 flex-col border transition-colors duration-500 ",
|
||||||
highlight ? "bg-yellow-100" : ""
|
highlight ? "bg-yellow-100" : ""
|
||||||
)}>
|
)}>
|
||||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
||||||
@@ -42,10 +48,19 @@ function IntegrationListItem(props: {
|
|||||||
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
|
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
|
||||||
</ListItemTitle>
|
</ListItemTitle>
|
||||||
<ListItemText component="p">{props.description}</ListItemText>
|
<ListItemText component="p">{props.description}</ListItemText>
|
||||||
|
{/* Alert error that key stopped working. */}
|
||||||
|
{props.invalidCredential && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Icon.FiAlertCircle className="w-8 text-red-500 sm:w-4" />
|
||||||
|
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||||
|
{t("invalid_credential")}
|
||||||
|
</ListItemText>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>{props.actions}</div>
|
<div>{props.actions}</div>
|
||||||
</div>
|
</div>
|
||||||
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
{props.children && <div className="w-full">{props.children}</div>}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||||
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
import useApp from "@calcom/lib/hooks/useApp";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
|
import { showToast } from "@calcom/ui/v2";
|
||||||
|
import Button from "@calcom/ui/v2/core/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use this component to allow installing an app from anywhere on the app.
|
||||||
|
* Use of this component requires you to remove custom InstallAppButtonComponent so that it can manage the redirection itself
|
||||||
|
*/
|
||||||
|
export default function OmniInstallAppButton({ appId, className }: { appId: string; className: string }) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { data: app } = useApp(appId);
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
|
const mutation = useAddAppMutation(null, {
|
||||||
|
onSuccess: () => {
|
||||||
|
//TODO: viewer.appById might be replaced with viewer.apps so that a single query needs to be invalidated.
|
||||||
|
utils.invalidateQueries(["viewer.appById", { appId }]);
|
||||||
|
utils.invalidateQueries(["viewer.apps", { extendsFeature: "EventType" }]);
|
||||||
|
showToast(t("app_successfully_installed"), "success");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InstallAppButton
|
||||||
|
type={app.type}
|
||||||
|
isProOnly={app.isProOnly}
|
||||||
|
wrapperClassName={classNames("[@media(max-width:260px)]:w-full", className)}
|
||||||
|
render={({ useDefaultComponent, ...props }) => {
|
||||||
|
if (useDefaultComponent) {
|
||||||
|
props = {
|
||||||
|
onClick: () => {
|
||||||
|
mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug, isOmniInstall: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
loading={mutation.isLoading}
|
||||||
|
color="secondary"
|
||||||
|
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
|
||||||
|
StartIcon={Icon.FiPlus}
|
||||||
|
{...props}>
|
||||||
|
{t("install")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import Glide, { Options } from "@glidejs/glide";
|
|
||||||
import "@glidejs/glide/dist/css/glide.core.min.css";
|
|
||||||
import "@glidejs/glide/dist/css/glide.theme.min.css";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
|
|
||||||
const Slider = <T extends string | unknown>({
|
|
||||||
title = "",
|
|
||||||
className = "",
|
|
||||||
items,
|
|
||||||
itemKey = (item) => `${item}`,
|
|
||||||
renderItem,
|
|
||||||
options = {},
|
|
||||||
}: {
|
|
||||||
title?: string;
|
|
||||||
className?: string;
|
|
||||||
items: T[];
|
|
||||||
itemKey?: (item: T) => string;
|
|
||||||
renderItem?: (item: T) => JSX.Element;
|
|
||||||
options?: Options;
|
|
||||||
}) => {
|
|
||||||
const glide = useRef(null);
|
|
||||||
const slider = useRef<Glide.Properties | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (glide.current) {
|
|
||||||
slider.current = new Glide(glide.current, {
|
|
||||||
type: "carousel",
|
|
||||||
...options,
|
|
||||||
}).mount();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => slider.current?.destroy();
|
|
||||||
}, [options]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`mb-2 ${className}`}>
|
|
||||||
<style jsx global>
|
|
||||||
{`
|
|
||||||
.glide__slide {
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
<div className="glide" ref={glide}>
|
|
||||||
<div className="flex cursor-default">
|
|
||||||
{title && (
|
|
||||||
<div>
|
|
||||||
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">{title}</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="glide__arrows ml-auto" data-glide-el="controls">
|
|
||||||
<button data-glide-dir="<" className="mr-4">
|
|
||||||
<Icon.FiArrowLeft className="h-5 w-5 text-gray-600 hover:text-black" />
|
|
||||||
</button>
|
|
||||||
<button data-glide-dir=">">
|
|
||||||
<Icon.FiArrowRight className="h-5 w-5 text-gray-600 hover:text-black" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="glide__track" data-glide-el="track">
|
|
||||||
<ul className="glide__slides">
|
|
||||||
{items.map((item) => {
|
|
||||||
if (typeof renderItem !== "function") return null;
|
|
||||||
return (
|
|
||||||
<li key={itemKey(item)} className="glide__slide h-auto pl-0">
|
|
||||||
{renderItem(item)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Slider;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import type { App } from "@calcom/types/App";
|
|
||||||
|
|
||||||
import AppCard from "./AppCard";
|
|
||||||
import Slider from "./Slider";
|
|
||||||
|
|
||||||
const TrendingAppsSlider = <T extends App>({ items }: { items: T[] }) => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Slider<T>
|
|
||||||
className="mb-16"
|
|
||||||
title={t("trending_apps")}
|
|
||||||
items={items.filter((app) => !!app.trending)}
|
|
||||||
itemKey={(app) => app.name}
|
|
||||||
options={{
|
|
||||||
perView: 3,
|
|
||||||
breakpoints: {
|
|
||||||
768 /* and below */: {
|
|
||||||
perView: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
renderItem={(app) => <AppCard app={app} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @deprecated use v2 instead, located at packages/ui/v2/core/apps */
|
|
||||||
export default TrendingAppsSlider;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { signIn } from "next-auth/react";
|
|
||||||
import { Dispatch, SetStateAction } from "react";
|
|
||||||
import { useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
email: string;
|
|
||||||
samlTenantID: string;
|
|
||||||
samlProductID: string;
|
|
||||||
hostedCal: boolean;
|
|
||||||
setErrorMessage: Dispatch<SetStateAction<string | null>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SAMLLogin(props: Props) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const methods = useFormContext();
|
|
||||||
const telemetry = useTelemetry();
|
|
||||||
|
|
||||||
const mutation = trpc.useMutation("viewer.public.samlTenantProduct", {
|
|
||||||
onSuccess: async (data) => {
|
|
||||||
await signIn("saml", {}, { tenant: data.tenant, product: data.product });
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
props.setErrorMessage(err.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-5">
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
data-testid="saml"
|
|
||||||
className="flex w-full justify-center"
|
|
||||||
onClick={async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// track Google logins. Without personal data/payload
|
|
||||||
telemetry.event(telemetryEventTypes.googleLogin, collectPageParameters());
|
|
||||||
|
|
||||||
if (!props.hostedCal) {
|
|
||||||
await signIn("saml", {}, { tenant: props.samlTenantID, product: props.samlProductID });
|
|
||||||
} else {
|
|
||||||
if (props.email.length === 0) {
|
|
||||||
props.setErrorMessage(t("saml_email_required"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// hosted solution, fetch tenant and product from the backend
|
|
||||||
mutation.mutate({
|
|
||||||
email: methods.getValues("email"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{t("signin_with_saml")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,12 @@ import { useRouter } from "next/router";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button } from "@calcom/ui";
|
import { Button } from "@calcom/ui";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { Form } from "@calcom/ui/form/fields";
|
import { Form } from "@calcom/ui/form/fields";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
|
import Tooltip from "@calcom/ui/v2/core/Tooltip";
|
||||||
|
|
||||||
import { Props } from "./pages/AvailabilityPage";
|
import { Props } from "./pages/AvailabilityPage";
|
||||||
|
|
||||||
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
||||||
return locations.length ? (
|
return locations.length ? (
|
||||||
<div>
|
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-2 break-words text-sm text-gray-600">
|
||||||
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-2 break-words text-sm text-gray-600">
|
{locations.map((location) => {
|
||||||
{locations.map((location) => {
|
const eventLocationType = getEventLocationType(location.type);
|
||||||
const eventLocationType = getEventLocationType(location.type);
|
if (!eventLocationType) {
|
||||||
if (!eventLocationType) {
|
// It's possible that the location app got uninstalled
|
||||||
// It's possible that the location app got uninstalled
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
return (
|
||||||
return (
|
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
||||||
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
<img
|
||||||
<img
|
src={eventLocationType.iconUrl}
|
||||||
src={eventLocationType.iconUrl}
|
className={classNames(
|
||||||
className={classNames(
|
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
|
||||||
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
|
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
||||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
)}
|
||||||
)}
|
alt={`${eventLocationType.label} icon`}
|
||||||
alt={`${eventLocationType.label} icon`}
|
/>
|
||||||
/>
|
<Tooltip content={locationKeyToString(location)}>
|
||||||
<span key={location.type}>{locationKeyToString(location)} </span>
|
<a
|
||||||
</div>
|
target="_blank"
|
||||||
);
|
href={locationKeyToString(location) ?? "/"}
|
||||||
})}
|
className="truncate"
|
||||||
</div>
|
key={location.type}
|
||||||
|
rel="noreferrer">
|
||||||
|
{locationKeyToString(location)}
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
|
|||||||
@@ -4,19 +4,22 @@ import { FC, useEffect, useState } from "react";
|
|||||||
|
|
||||||
import dayjs, { Dayjs } from "@calcom/dayjs";
|
import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||||
import { nameOfDay } from "@calcom/lib/weekday";
|
import { nameOfDay } from "@calcom/lib/weekday";
|
||||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots";
|
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots";
|
||||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||||
|
import { ToggleGroup } from "@calcom/ui/v2/core/form/ToggleGroup";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
|
|
||||||
type AvailableTimesProps = {
|
type AvailableTimesProps = {
|
||||||
timeFormat: string;
|
timeFormat: TimeFormat;
|
||||||
|
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
recurringCount: number | undefined;
|
recurringCount: number | undefined;
|
||||||
eventTypeSlug: string;
|
eventTypeSlug: string;
|
||||||
date: Dayjs;
|
date?: Dayjs;
|
||||||
seatsPerTimeSlot?: number | null;
|
seatsPerTimeSlot?: number | null;
|
||||||
slots?: Slot[];
|
slots?: Slot[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -31,6 +34,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
eventTypeSlug,
|
eventTypeSlug,
|
||||||
recurringCount,
|
recurringCount,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
|
onTimeFormatChange,
|
||||||
seatsPerTimeSlot,
|
seatsPerTimeSlot,
|
||||||
ethSignature,
|
ethSignature,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -44,18 +48,31 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
setBrand(getComputedStyle(document.documentElement).getPropertyValue("--brand-color").trim());
|
setBrand(getComputedStyle(document.documentElement).getPropertyValue("--brand-color").trim());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark:bg-darkgray-100 mt-8 flex flex-col px-4 text-center sm:mt-0 sm:w-1/3 sm:p-5 md:-mb-5">
|
<div className="dark:bg-darkgray-100 mt-8 flex h-full w-full flex-col px-4 text-center sm:mt-0 sm:p-5 md:-mb-5 md:min-w-[200px] lg:min-w-[300px]">
|
||||||
<div className="mb-4 text-left text-base">
|
<div className="mb-6 flex items-center text-left text-base">
|
||||||
<span className="text-bookingdarker dark:text-darkgray-800 mb-8 w-1/2 break-words font-semibold text-gray-900">
|
<div className="mr-4">
|
||||||
{nameOfDay(i18n.language, Number(date.format("d")))}
|
<span className="text-bookingdarker dark:text-darkgray-800 font-semibold text-gray-900">
|
||||||
</span>
|
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
||||||
<span className="text-bookinglight font-medium">
|
</span>
|
||||||
{date.format(", D ")}
|
<span className="text-bookinglight font-medium">
|
||||||
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
|
, {date.toDate().toLocaleString(i18n.language, { month: "long" })} {date.format(" D ")}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ToggleGroup
|
||||||
|
onValueChange={(timeFormat) => onTimeFormatChange(timeFormat === "24")}
|
||||||
|
defaultValue={timeFormat === TimeFormat.TWELVE_HOUR ? "12" : "24"}
|
||||||
|
options={[
|
||||||
|
{ value: "12", label: t("12_hour_short") },
|
||||||
|
{ value: "24", label: t("24_hour_short") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
<div className="-mb-5 grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
||||||
{slots.length > 0 &&
|
{slots.length > 0 &&
|
||||||
slots.map((slot) => {
|
slots.map((slot) => {
|
||||||
type BookingURL = {
|
type BookingURL = {
|
||||||
@@ -63,7 +80,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
query: Record<string, string | number | string[] | undefined>;
|
query: Record<string, string | number | string[] | undefined>;
|
||||||
};
|
};
|
||||||
const bookingUrl: BookingURL = {
|
const bookingUrl: BookingURL = {
|
||||||
pathname: "book",
|
pathname: router.pathname.endsWith("/embed") ? "../book" : "book",
|
||||||
query: {
|
query: {
|
||||||
...router.query,
|
...router.query,
|
||||||
date: dayjs(slot.time).format(),
|
date: dayjs(slot.time).format(),
|
||||||
@@ -150,4 +167,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
AvailableTimes.displayName = "AvailableTimes";
|
||||||
|
|
||||||
export default AvailableTimes;
|
export default AvailableTimes;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { SchedulingType } from "@prisma/client";
|
||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
|
|
||||||
|
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||||
|
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||||
|
|
||||||
|
import type { AvailabilityPageProps } from "../../pages/[user]/[type]";
|
||||||
|
import type { BookPageProps } from "../../pages/[user]/book";
|
||||||
|
import type { DynamicAvailabilityPageProps } from "../../pages/d/[link]/[slug]";
|
||||||
|
import type { HashLinkPageProps } from "../../pages/d/[link]/book";
|
||||||
|
import type { AvailabilityTeamPageProps } from "../../pages/team/[slug]/[type]";
|
||||||
|
import type { TeamBookingPageProps } from "../../pages/team/[slug]/book";
|
||||||
|
import { AvailableEventLocations } from "./AvailableEventLocations";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile:
|
||||||
|
| AvailabilityPageProps["profile"]
|
||||||
|
| HashLinkPageProps["profile"]
|
||||||
|
| TeamBookingPageProps["profile"]
|
||||||
|
| BookPageProps["profile"]
|
||||||
|
| AvailabilityTeamPageProps["profile"]
|
||||||
|
| DynamicAvailabilityPageProps["profile"];
|
||||||
|
eventType:
|
||||||
|
| AvailabilityPageProps["eventType"]
|
||||||
|
| HashLinkPageProps["eventType"]
|
||||||
|
| TeamBookingPageProps["eventType"]
|
||||||
|
| BookPageProps["eventType"]
|
||||||
|
| AvailabilityTeamPageProps["eventType"]
|
||||||
|
| DynamicAvailabilityPageProps["eventType"];
|
||||||
|
isBookingPage?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
isMobile?: boolean;
|
||||||
|
rescheduleUid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookingDescription: FC<Props> = (props) => {
|
||||||
|
const { profile, eventType, isBookingPage = false, children } = props;
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UserAvatars
|
||||||
|
profile={profile}
|
||||||
|
users={eventType.users}
|
||||||
|
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||||
|
size={10}
|
||||||
|
truncateAfter={3}
|
||||||
|
/>
|
||||||
|
<h2 className="mt-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
{profile.name}
|
||||||
|
</h2>
|
||||||
|
<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">
|
||||||
|
{eventType?.description && (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"flex",
|
||||||
|
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||||
|
)}>
|
||||||
|
<div>
|
||||||
|
<Icon.FiInfo
|
||||||
|
className={classNames(
|
||||||
|
"mr-[10px] ml-[2px] inline-block h-4 w-4",
|
||||||
|
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[calc(100%_-_2rem)] flex-shrink break-words">
|
||||||
|
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eventType?.requiresConfirmation && (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"flex items-center",
|
||||||
|
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 " />
|
||||||
|
</div>
|
||||||
|
{t("requires_confirmation")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AvailableEventLocations
|
||||||
|
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={classNames(
|
||||||
|
"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>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BookingDescription;
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import { BookingStatus } from "@prisma/client";
|
import { BookingStatus } from "@prisma/client";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
import { formatTime } from "@calcom/lib/date-fns";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
import { inferQueryInput, inferQueryOutput, trpc } from "@calcom/trpc/react";
|
import { inferQueryInput, inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
|
||||||
import { TextArea } from "@calcom/ui/form/fields";
|
import { TextArea } from "@calcom/ui/form/fields";
|
||||||
import Badge from "@calcom/ui/v2/core/Badge";
|
import Badge from "@calcom/ui/v2/core/Badge";
|
||||||
import Button from "@calcom/ui/v2/core/Button";
|
import Button from "@calcom/ui/v2/core/Button";
|
||||||
|
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 useMeQuery from "@lib/hooks/useMeQuery";
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
import { extractRecurringDates } from "@lib/parseDate";
|
import { extractRecurringDates } from "@lib/parseDate";
|
||||||
@@ -29,7 +31,7 @@ type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
|||||||
|
|
||||||
type BookingItemProps = BookingItem & {
|
type BookingItemProps = BookingItem & {
|
||||||
listingStatus: BookingListingStatus;
|
listingStatus: BookingListingStatus;
|
||||||
recurringBookings?: BookingItem[];
|
recurringBookings: inferQueryOutput<"viewer.bookings">["recurringInfo"];
|
||||||
};
|
};
|
||||||
|
|
||||||
function BookingListItem(booking: BookingItemProps) {
|
function BookingListItem(booking: BookingItemProps) {
|
||||||
@@ -44,10 +46,25 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
const mutation = trpc.useMutation(["viewer.bookings.confirm"], {
|
const mutation = trpc.useMutation(["viewer.bookings.confirm"], {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setRejectionDialogIsOpen(false);
|
setRejectionDialogIsOpen(false);
|
||||||
|
showToast(t("booking_confirmation_success"), "success");
|
||||||
|
utils.invalidateQueries("viewer.bookings");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast(t("booking_confirmation_failed"), "error");
|
||||||
utils.invalidateQueries("viewer.bookings");
|
utils.invalidateQueries("viewer.bookings");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||||
|
const isPast = new Date(booking.endTime) < new Date();
|
||||||
|
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||||
|
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||||
|
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||||
|
const isPending = booking.status === BookingStatus.PENDING;
|
||||||
|
const isRecurring = booking.recurringEventId !== null;
|
||||||
|
const isTabRecurring = booking.listingStatus === "recurring";
|
||||||
|
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||||
|
|
||||||
const bookingConfirm = async (confirm: boolean) => {
|
const bookingConfirm = async (confirm: boolean) => {
|
||||||
let body = {
|
let body = {
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
@@ -56,28 +73,18 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||||
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
|
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
|
||||||
*/
|
*/
|
||||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||||
}
|
}
|
||||||
mutation.mutate(body);
|
mutation.mutate(body);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
|
||||||
const isPast = new Date(booking.endTime) < new Date();
|
|
||||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
|
||||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
|
||||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
|
||||||
const isPending = booking.status === BookingStatus.PENDING;
|
|
||||||
|
|
||||||
const pendingActions: ActionType[] = [
|
const pendingActions: ActionType[] = [
|
||||||
{
|
{
|
||||||
id: "reject",
|
id: "reject",
|
||||||
label:
|
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
|
||||||
? t("reject_all")
|
|
||||||
: t("reject"),
|
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setRejectionDialogIsOpen(true);
|
setRejectionDialogIsOpen(true);
|
||||||
},
|
},
|
||||||
@@ -86,10 +93,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "confirm",
|
id: "confirm",
|
||||||
label:
|
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
|
||||||
? t("confirm_all")
|
|
||||||
: t("confirm"),
|
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
bookingConfirm(true);
|
bookingConfirm(true);
|
||||||
},
|
},
|
||||||
@@ -102,23 +106,15 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
let bookedActions: ActionType[] = [
|
let bookedActions: ActionType[] = [
|
||||||
{
|
{
|
||||||
id: "cancel",
|
id: "cancel",
|
||||||
label:
|
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
|
||||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
|
||||||
? t("cancel_all_remaining")
|
|
||||||
: t("cancel"),
|
|
||||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
/* 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. */
|
cancel all remaining bookings or just that booking instance. */
|
||||||
href: `/cancel/${booking.uid}${
|
href: `/cancel/${booking.uid}${isTabRecurring && isRecurring ? "?allRemainingBookings=true" : ""}`,
|
||||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
|
||||||
? "?allRemainingBookings=true"
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
icon: Icon.FiX,
|
icon: Icon.FiX,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "edit_booking",
|
id: "edit_booking",
|
||||||
label: t("edit_booking"),
|
label: t("edit"),
|
||||||
icon: Icon.FiEdit,
|
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: "reschedule",
|
id: "reschedule",
|
||||||
@@ -147,7 +143,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
if (isTabRecurring && isRecurring) {
|
||||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,21 +176,20 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
setLocationMutation.mutate({ bookingId: booking.id, newLocation });
|
setLocationMutation.mutate({ bookingId: booking.id, newLocation });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract recurring dates is intensive to run, so use useMemo.
|
||||||
// Calculate the booking date(s) and setup recurring event data to show
|
// Calculate the booking date(s) and setup recurring event data to show
|
||||||
let recurringStrings: string[] = [];
|
// @FIXME: This is importing the RRULE library which is already heavy. Find out a more optimal way do this.
|
||||||
let recurringDates: Date[] = [];
|
const [recurringStrings, recurringDates] = useMemo(() => {
|
||||||
|
if (booking.recurringBookings !== undefined && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||||
if (booking.recurringBookings && booking.eventType.recurringEvent?.freq !== undefined) {
|
return extractRecurringDates(booking, user?.timeZone, i18n);
|
||||||
[recurringStrings, recurringDates] = extractRecurringDates(
|
}
|
||||||
booking.recurringBookings,
|
return [[], []];
|
||||||
user?.timeZone,
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
i18n
|
}, [user?.timeZone, i18n.language, booking.recurringBookings]);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const location = booking.location || "";
|
const location = booking.location || "";
|
||||||
|
|
||||||
const onClick = () => {
|
const onClickTableData = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/success",
|
pathname: "/success",
|
||||||
query: {
|
query: {
|
||||||
@@ -202,7 +197,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
|
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
|
||||||
type: booking.eventType.id || 0,
|
type: booking.eventType.id || 0,
|
||||||
eventSlug: booking.eventType.slug,
|
eventSlug: booking.eventType.slug,
|
||||||
user: user?.username || "",
|
username: user?.username || "",
|
||||||
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
|
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
|
||||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||||
location: location,
|
location: location,
|
||||||
@@ -215,6 +210,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RescheduleDialog
|
<RescheduleDialog
|
||||||
@@ -262,77 +258,101 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<tr className="flex hover:bg-neutral-50">
|
<tr className="flex flex-col hover:bg-neutral-50 sm:flex-row">
|
||||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-48" onClick={onClick}>
|
<td
|
||||||
|
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||||
|
onClick={onClickTableData}>
|
||||||
<div className="cursor-pointer py-4">
|
<div className="cursor-pointer py-4">
|
||||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||||
|
<MeetingTimeInTimezones
|
||||||
|
timeFormat={user?.timeFormat}
|
||||||
|
userTimezone={user?.timeZone}
|
||||||
|
startTime={booking.startTime}
|
||||||
|
endTime={booking.endTime}
|
||||||
|
attendees={booking.attendees}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
{booking.recurringBookings &&
|
{isPending && (
|
||||||
booking.eventType?.recurringEvent?.freq &&
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||||
(booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
|
{t("unconfirmed")}
|
||||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
</Badge>
|
||||||
<div className="flex">
|
)}
|
||||||
<Tooltip
|
|
||||||
content={recurringStrings.map((aDate, key) => (
|
{booking.eventType?.team && (
|
||||||
<p key={key}>{aDate}</p>
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||||
))}>
|
{booking.eventType.team.name}
|
||||||
<div className="text-gray-600 dark:text-white">
|
</Badge>
|
||||||
<Icon.FiRefreshCcw
|
)}
|
||||||
strokeWidth="3"
|
|
||||||
className="float-left mr-1 mt-1.5 inline-block h-3 w-3 text-gray-400"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 pl-5 text-xs">
|
|
||||||
{booking.status === BookingStatus.ACCEPTED
|
|
||||||
? `${t("event_remaining", {
|
|
||||||
count: recurringDates.length,
|
|
||||||
})}`
|
|
||||||
: getEveryFreqFor({
|
|
||||||
t,
|
|
||||||
recurringEvent: booking.eventType.recurringEvent,
|
|
||||||
recurringCount: booking.recurringBookings.length,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className={"flex-1 px-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
|
|
||||||
<div className="cursor-pointer py-4">
|
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
<Badge variant="orange" className="mb-2 ltr:mr-2 rtl:ml-2">
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||||
{t("pending_payment")}
|
{t("pending_payment")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 text-sm text-gray-400">
|
||||||
|
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClickTableData}>
|
||||||
|
{/* Time and Badges for mobile */}
|
||||||
|
<div className="w-full pt-4 pb-2 sm:hidden">
|
||||||
|
<div className="flex w-full items-center justify-between sm:hidden">
|
||||||
|
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||||
|
<div className="pr-2 text-sm text-gray-500">
|
||||||
|
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "}
|
||||||
|
{formatTime(booking.endTime, user?.timeFormat, user?.timeZone)}
|
||||||
|
<MeetingTimeInTimezones
|
||||||
|
timeFormat={user?.timeFormat}
|
||||||
|
userTimezone={user?.timeZone}
|
||||||
|
startTime={booking.startTime}
|
||||||
|
endTime={booking.endTime}
|
||||||
|
attendees={booking.attendees}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||||
|
{t("unconfirmed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{booking.eventType?.team && (
|
||||||
|
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||||
|
{booking.eventType.team.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
|
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||||
|
{t("pending_payment")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-gray-400 sm:hidden">
|
||||||
|
<RecurringBookingsTooltip booking={booking} recurringDates={recurringDates} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cursor-pointer py-4">
|
||||||
<div
|
<div
|
||||||
title={booking.title}
|
title={booking.title}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
|
"max-w-10/12 sm:max-w-56 text-sm font-medium leading-6 text-neutral-900 md:max-w-full",
|
||||||
isCancelled ? "line-through" : ""
|
isCancelled ? "line-through" : ""
|
||||||
)}>
|
)}>
|
||||||
{booking.title}
|
{booking.title}
|
||||||
<span> </span>
|
<span> </span>
|
||||||
{booking.eventType?.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
|
||||||
|
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
||||||
)}
|
)}
|
||||||
{isPending && (
|
|
||||||
<Badge variant="orange" className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">
|
|
||||||
{t("unconfirmed")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{booking.description && (
|
{booking.description && (
|
||||||
<div
|
<div
|
||||||
className="max-w-52 md:max-w-96 truncate text-sm text-gray-600"
|
className="max-w-10/12 sm:max-w-40 md:max-w-56 xl:max-w-80 lg:max-w-64 truncate text-sm text-gray-600"
|
||||||
title={booking.description}>
|
title={booking.description}>
|
||||||
"{booking.description}"
|
"{booking.description}"
|
||||||
</div>
|
</div>
|
||||||
@@ -351,8 +371,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||||
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
|
|
||||||
{isUpcoming && !isCancelled ? (
|
{isUpcoming && !isCancelled ? (
|
||||||
<>
|
<>
|
||||||
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||||
@@ -372,6 +391,62 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecurringBookingsTooltipProps {
|
||||||
|
booking: BookingItemProps;
|
||||||
|
recurringDates: Date[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookingsTooltipProps) => {
|
||||||
|
// Get user so we can determine 12/24 hour format preferences
|
||||||
|
const query = useMeQuery();
|
||||||
|
const user = query.data;
|
||||||
|
const { t } = useLocale();
|
||||||
|
const now = new Date();
|
||||||
|
const recurringCount = recurringDates.filter((date) => {
|
||||||
|
return date >= now;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
(booking.recurringBookings &&
|
||||||
|
booking.eventType?.recurringEvent?.freq &&
|
||||||
|
(booking.listingStatus === "recurring" ||
|
||||||
|
booking.listingStatus === "unconfirmed" ||
|
||||||
|
booking.listingStatus === "cancelled") && (
|
||||||
|
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||||
|
<div className="flex">
|
||||||
|
<Tooltip
|
||||||
|
content={recurringDates.map((aDate, key) => (
|
||||||
|
<p key={key} className={classNames(recurringDates[key] < now && "line-through")}>
|
||||||
|
{formatTime(booking.startTime, user?.timeFormat, user?.timeZone)}
|
||||||
|
{" - "}
|
||||||
|
{dayjs(aDate).format("D MMMM YYYY")}
|
||||||
|
</p>
|
||||||
|
))}>
|
||||||
|
<div className="text-gray-600 dark:text-white">
|
||||||
|
<Icon.FiRefreshCcw
|
||||||
|
strokeWidth="3"
|
||||||
|
className="float-left mr-1 mt-1.5 inline-block h-3 w-3 text-gray-400"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 pl-5 text-xs">
|
||||||
|
{booking.status === BookingStatus.ACCEPTED
|
||||||
|
? `${t("event_remaining", {
|
||||||
|
count: recurringCount,
|
||||||
|
})}`
|
||||||
|
: getEveryFreqFor({
|
||||||
|
t,
|
||||||
|
recurringEvent: booking.eventType.recurringEvent,
|
||||||
|
recurringCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface UserProps {
|
interface UserProps {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Props = {
|
|||||||
booking: {
|
booking: {
|
||||||
title?: string;
|
title?: string;
|
||||||
uid?: string;
|
uid?: string;
|
||||||
|
id?: number;
|
||||||
};
|
};
|
||||||
profile: {
|
profile: {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -57,13 +58,13 @@ export default function CancelBooking(props: Props) {
|
|||||||
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-3 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:mb-3 "
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<div className=" flex flex-col-reverse rtl:space-x-reverse sm:flex-row">
|
<div className="flex flex-col-reverse rtl:space-x-reverse sm:flex-row">
|
||||||
{!props.recurringEvent && (
|
{!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">
|
<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
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
className="border-0 sm:border"
|
className="border-0 sm:border"
|
||||||
onClick={() => router.push("/reschedule/" + booking?.uid)}>
|
onClick={() => router.push(`/reschedule/${booking?.uid}`)}>
|
||||||
{t("reschedule_this")}
|
{t("reschedule_this")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,8 +80,8 @@ export default function CancelBooking(props: Props) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
uid: booking?.uid,
|
id: booking?.id,
|
||||||
reason: cancellationReason,
|
cancellationReason: cancellationReason,
|
||||||
};
|
};
|
||||||
|
|
||||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/form/TimezoneSelect";
|
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/v2/core/TimezoneSelect";
|
||||||
import { Switch } from "@calcom/ui/v2";
|
|
||||||
|
|
||||||
import { is24h, timeZone } from "../../lib/clock";
|
import { timeZone } from "../../lib/clock";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSelectTimeZone: (selectedTimeZone: string) => void;
|
onSelectTimeZone: (selectedTimeZone: string) => void;
|
||||||
onToggle24hClock: (is24hClock: boolean) => void;
|
|
||||||
timeFormat: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat }) => {
|
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||||
const [is24hClock, setIs24hClock] = useState(timeFormat === "HH:mm" && true);
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIs24hClock(is24h());
|
|
||||||
setSelectedTimeZone(timeZone());
|
setSelectedTimeZone(timeZone());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -27,26 +22,11 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat
|
|||||||
onSelectTimeZone(timeZone(selectedTimeZone));
|
onSelectTimeZone(timeZone(selectedTimeZone));
|
||||||
}
|
}
|
||||||
}, [selectedTimeZone, onSelectTimeZone]);
|
}, [selectedTimeZone, onSelectTimeZone]);
|
||||||
const handle24hClockToggle = (is24hClock: boolean) => {
|
|
||||||
setIs24hClock(is24hClock);
|
|
||||||
onToggle24hClock(is24h(is24hClock));
|
|
||||||
};
|
|
||||||
|
|
||||||
return selectedTimeZone !== "" ? (
|
return selectedTimeZone !== "" ? (
|
||||||
<div className="max-w-80 dark:border-darkgray-300 dark:bg-darkgray-200 absolute z-10 w-full rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
|
<div className="dark:border-darkgray-300 dark:bg-darkgray-200 rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
|
||||||
<div className="mb-4 flex">
|
<div className="mb-4 flex">
|
||||||
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
||||||
<div className="ml-auto flex items-center">
|
|
||||||
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
|
|
||||||
{t("am_pm")}
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
name="24hClock"
|
|
||||||
label={t("24_h")}
|
|
||||||
defaultChecked={is24hClock}
|
|
||||||
onCheckedChange={handle24hClockToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<TimezoneSelect
|
<TimezoneSelect
|
||||||
id="timeZone"
|
id="timeZone"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Get router variables
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { EventType } from "@prisma/client";
|
import { EventType } from "@prisma/client";
|
||||||
import { SchedulingType } from "@prisma/client";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
||||||
import { TFunction } from "next-i18next";
|
import { TFunction } from "next-i18next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
import { useReducer, useEffect, useMemo, useState } from "react";
|
||||||
@@ -9,6 +8,8 @@ import { Toaster } from "react-hot-toast";
|
|||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||||
|
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||||
import dayjs, { Dayjs } from "@calcom/dayjs";
|
import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||||
import {
|
import {
|
||||||
useEmbedNonStylesConfig,
|
useEmbedNonStylesConfig,
|
||||||
@@ -19,13 +20,13 @@ import {
|
|||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
|
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import notEmpty from "@calcom/lib/notEmpty";
|
import notEmpty from "@calcom/lib/notEmpty";
|
||||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
|
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||||
import { localStorage } from "@calcom/lib/webstorage";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
|
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
|
||||||
@@ -37,17 +38,16 @@ import { isBrandingHidden } from "@lib/isBrandingHidden";
|
|||||||
|
|
||||||
import Gates, { Gate, GateState } from "@components/Gates";
|
import Gates, { Gate, GateState } from "@components/Gates";
|
||||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||||
|
import BookingDescription from "@components/booking/BookingDescription";
|
||||||
import TimeOptions from "@components/booking/TimeOptions";
|
import TimeOptions from "@components/booking/TimeOptions";
|
||||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
|
||||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||||
|
|
||||||
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||||
import { AvailableEventLocations } from "../AvailableEventLocations";
|
|
||||||
|
|
||||||
|
// Get router variables
|
||||||
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
|
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const path = router.asPath.split("/");
|
const path = router.asPath.split("/");
|
||||||
@@ -79,7 +79,7 @@ const useSlots = ({
|
|||||||
usernameList: string[];
|
usernameList: string[];
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isIdle } = trpc.useQuery(
|
const { data, isLoading, isPaused } = trpc.useQuery(
|
||||||
[
|
[
|
||||||
"viewer.public.slots.getSchedule",
|
"viewer.public.slots.getSchedule",
|
||||||
{
|
{
|
||||||
@@ -101,13 +101,14 @@ const useSlots = ({
|
|||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// The very first time isIdle is set if auto-fetch is disabled, so isIdle should also be considered a loading state.
|
// The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state.
|
||||||
return { slots: cachedSlots, isLoading: isLoading || isIdle };
|
return { slots: cachedSlots, isLoading: isLoading || isPaused };
|
||||||
};
|
};
|
||||||
|
|
||||||
const SlotPicker = ({
|
const SlotPicker = ({
|
||||||
eventType,
|
eventType,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
|
onTimeFormatChange,
|
||||||
timeZone,
|
timeZone,
|
||||||
recurringEventCount,
|
recurringEventCount,
|
||||||
users,
|
users,
|
||||||
@@ -116,7 +117,8 @@ const SlotPicker = ({
|
|||||||
ethSignature,
|
ethSignature,
|
||||||
}: {
|
}: {
|
||||||
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
|
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
|
||||||
timeFormat: string;
|
timeFormat: TimeFormat;
|
||||||
|
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
seatsPerTimeSlot?: number;
|
seatsPerTimeSlot?: number;
|
||||||
recurringEventCount?: number;
|
recurringEventCount?: number;
|
||||||
@@ -130,6 +132,8 @@ const SlotPicker = ({
|
|||||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!router.isReady) return;
|
if (!router.isReady) return;
|
||||||
|
|
||||||
@@ -176,10 +180,8 @@ const SlotPicker = ({
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mt-8 w-full px-4 sm:mt-0 sm:min-w-[455px] md:px-5",
|
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
||||||
selectedDate
|
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||||
? "sm:dark:border-darkgray-200 border-gray-200 sm:w-1/2 sm:border-r sm:p-4 sm:pr-6 md:w-1/3"
|
|
||||||
: "sm:p-4"
|
|
||||||
)}
|
)}
|
||||||
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
||||||
locale={isLocaleReady ? i18n.language : "en"}
|
locale={isLocaleReady ? i18n.language : "en"}
|
||||||
@@ -194,55 +196,42 @@ const SlotPicker = ({
|
|||||||
weekStart={weekStart}
|
weekStart={weekStart}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedDate && (
|
<div ref={slotPickerRef}>
|
||||||
<AvailableTimes
|
<AvailableTimes
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
slots={slots[selectedDate.format("YYYY-MM-DD")]}
|
slots={selectedDate && slots[selectedDate.format("YYYY-MM-DD")]}
|
||||||
date={selectedDate}
|
date={selectedDate}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
|
onTimeFormatChange={onTimeFormatChange}
|
||||||
eventTypeId={eventType.id}
|
eventTypeId={eventType.id}
|
||||||
eventTypeSlug={eventType.slug}
|
eventTypeSlug={eventType.slug}
|
||||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||||
recurringCount={recurringEventCount}
|
recurringCount={recurringEventCount}
|
||||||
ethSignature={ethSignature}
|
ethSignature={ethSignature}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function TimezoneDropdown({
|
function TimezoneDropdown({
|
||||||
onChangeTimeFormat,
|
|
||||||
onChangeTimeZone,
|
onChangeTimeZone,
|
||||||
timeZone,
|
timeZone,
|
||||||
timeFormat,
|
|
||||||
}: {
|
}: {
|
||||||
onChangeTimeFormat: (newTimeFormat: string) => void;
|
|
||||||
onChangeTimeZone: (newTimeZone: string) => void;
|
onChangeTimeZone: (newTimeZone: string) => void;
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
timeFormat: string;
|
|
||||||
}) {
|
}) {
|
||||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectTimeZone = (newTimeZone: string) => {
|
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||||
onChangeTimeZone(newTimeZone);
|
onChangeTimeZone(newTimeZone);
|
||||||
localStorageTimeZone(newTimeZone);
|
localStorageTimeZone(newTimeZone);
|
||||||
setIsTimeOptionsOpen(false);
|
setIsTimeOptionsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
|
||||||
onChangeTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen} className="flex">
|
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||||
<Collapsible.Trigger className="min-w-32 dark:text-darkgray-600 mb-2 -ml-2 px-2 text-left text-gray-600">
|
<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">
|
<p className="text-sm font-medium">
|
||||||
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
|
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
|
||||||
{timeZone}
|
{timeZone}
|
||||||
@@ -252,15 +241,16 @@ function TimezoneDropdown({
|
|||||||
<Icon.FiChevronDown className="ml-1 inline-block h-4 w-4" />
|
<Icon.FiChevronDown className="ml-1 inline-block h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</Collapsible.Trigger>
|
</Popover.Trigger>
|
||||||
<Collapsible.Content>
|
<Popover.Portal>
|
||||||
<TimeOptions
|
<Popover.Content
|
||||||
onSelectTimeZone={handleSelectTimeZone}
|
hideWhenDetached
|
||||||
onToggle24hClock={handleToggle24hClock}
|
align="start"
|
||||||
timeFormat={timeFormat}
|
className="animate-fade-in-up absolute left-0 top-2 w-80 max-w-[calc(100vw_-_1.5rem)]">
|
||||||
/>
|
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||||
</Collapsible.Content>
|
</Popover.Content>
|
||||||
</Collapsible.Root>
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,9 +279,9 @@ const useRouterQuery = <T extends string>(name: T) => {
|
|||||||
|
|
||||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||||
|
|
||||||
const AvailabilityPage = ({ profile, eventType }: Props) => {
|
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isEmbed = useIsEmbed();
|
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||||
const query = dateQuerySchema.parse(router.query);
|
const query = dateQuerySchema.parse(router.query);
|
||||||
const { rescheduleUid } = query;
|
const { rescheduleUid } = query;
|
||||||
useTheme(profile.theme);
|
useTheme(profile.theme);
|
||||||
@@ -302,8 +292,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
|||||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||||
|
|
||||||
const [timeZone, setTimeZone] = useState<string>();
|
const [timeZone, setTimeZone] = useState<string>();
|
||||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
const [timeFormat, setTimeFormat] = useState<TimeFormat>(detectBrowserTimeFormat);
|
||||||
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
|
|
||||||
|
const onTimeFormatChange = (is24Hours: boolean) => {
|
||||||
|
setTimeFormat(is24Hours ? TimeFormat.TWENTY_FOUR_HOUR : TimeFormat.TWELVE_HOUR);
|
||||||
|
setIs24hClockInLocalStorage(is24Hours);
|
||||||
|
};
|
||||||
|
|
||||||
const [gateState, gateDispatcher] = useReducer(
|
const [gateState, gateDispatcher] = useReducer(
|
||||||
(state: GateState, newState: Partial<GateState>) => ({
|
(state: GateState, newState: Partial<GateState>) => ({
|
||||||
...state,
|
...state,
|
||||||
@@ -316,10 +311,6 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
|||||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsAvailableTimesVisible(!!query.date);
|
|
||||||
}, [query.date]);
|
|
||||||
|
|
||||||
// TODO: Improve this;
|
// TODO: Improve this;
|
||||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||||
|
|
||||||
@@ -338,248 +329,118 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
|||||||
|
|
||||||
// get dynamic user list here
|
// get dynamic user list here
|
||||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||||
// Recurring event sidebar requires more space
|
|
||||||
const maxWidth = isAvailableTimesVisible
|
|
||||||
? recurringEventCount
|
|
||||||
? "max-w-6xl"
|
|
||||||
: "max-w-5xl"
|
|
||||||
: recurringEventCount
|
|
||||||
? "max-w-4xl"
|
|
||||||
: "max-w-3xl";
|
|
||||||
const timezoneDropdown = useMemo(
|
const timezoneDropdown = useMemo(
|
||||||
() => (
|
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
||||||
<TimezoneDropdown
|
[timeZone]
|
||||||
timeFormat={timeFormat}
|
|
||||||
onChangeTimeFormat={setTimeFormat}
|
|
||||||
timeZone={timeZone}
|
|
||||||
onChangeTimeZone={setTimeZone}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[timeZone, timeFormat]
|
|
||||||
);
|
);
|
||||||
|
const stripeAppData = getStripeAppData(eventType);
|
||||||
|
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
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.
|
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||||
const slug = rawSlug.join("/");
|
|
||||||
|
|
||||||
// Define conditional gates here
|
// Define conditional gates here
|
||||||
const gates = [
|
const gates = [
|
||||||
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
||||||
eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress
|
rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress
|
||||||
? ("rainbow" as Gate)
|
? ("rainbow" as Gate)
|
||||||
: undefined,
|
: undefined,
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
|
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
||||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||||
name={profile.name || undefined}
|
meeting={{
|
||||||
username={slug || undefined}
|
title: eventType.title,
|
||||||
|
profile: { name: `${profile.name}`, image: profile.image },
|
||||||
|
users: [
|
||||||
|
...(eventType.users || []).map((user) => ({
|
||||||
|
name: `${user.name}`,
|
||||||
|
username: `${user.username}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}}
|
||||||
nextSeoProps={{
|
nextSeoProps={{
|
||||||
nofollow: eventType.hidden,
|
nofollow: eventType.hidden,
|
||||||
noindex: eventType.hidden,
|
noindex: eventType.hidden,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<BookingPageTagManager eventType={eventType} />
|
||||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||||
<div>
|
<div>
|
||||||
<main
|
<main
|
||||||
className={classNames(
|
className={classNames(
|
||||||
shouldAlignCentrally ? "mx-auto" : "",
|
"flex-col md:mx-4 lg:flex",
|
||||||
isEmbed
|
shouldAlignCentrally ? "items-center" : "items-start",
|
||||||
? classNames(maxWidth)
|
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
||||||
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
|
||||||
)}>
|
)}>
|
||||||
<div
|
<div>
|
||||||
style={availabilityDatePickerEmbedStyles}
|
<div
|
||||||
className={classNames(
|
style={availabilityDatePickerEmbedStyles}
|
||||||
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white",
|
className={classNames(
|
||||||
"border-bookinglightest overflow-hidden rounded-md md:border",
|
isBackgroundTransparent
|
||||||
isEmbed ? "mx-auto" : maxWidth
|
? ""
|
||||||
)}>
|
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
||||||
{/* mobile: details */}
|
"border-bookinglightest overflow-hidden md:rounded-md md:border",
|
||||||
<div className="block px-4 pt-4 sm:p-8 md:hidden">
|
isEmbed && "mx-auto"
|
||||||
<div>
|
)}>
|
||||||
<UserAvatars
|
<div className="overflow-hidden md:flex">
|
||||||
profile={profile}
|
<div
|
||||||
users={eventType.users}
|
className={classNames(
|
||||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||||
size={9}
|
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||||
truncateAfter={5}
|
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||||
/>
|
)}>
|
||||||
<div className="mt-4">
|
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||||
<div className="dark:text-darkgray-900 mt-2 gap-2">
|
{!rescheduleUid && eventType.recurringEvent && (
|
||||||
<p className="break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
<div className="flex items-start text-sm font-medium">
|
||||||
{profile.name}
|
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||||
</p>
|
<div>
|
||||||
<h1 className="text-bookingdark dark:text-darkgray-900 mb-4 break-words text-xl font-semibold">
|
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||||
{eventType.title}
|
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||||
</h1>
|
</p>
|
||||||
<div className="flex flex-col space-y-3">
|
<input
|
||||||
{eventType?.description && (
|
type="number"
|
||||||
<div className="dark:text-darkgray-600 flex py-1 text-sm font-medium text-gray-600">
|
min="1"
|
||||||
<div>
|
max={eventType.recurringEvent.count}
|
||||||
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
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"
|
||||||
</div>
|
defaultValue={eventType.recurringEvent.count}
|
||||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
onChange={(event) => {
|
||||||
|
setRecurringEventCount(parseInt(event?.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="inline">
|
||||||
|
{t("occurrence", {
|
||||||
|
count: recurringEventCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{eventType?.requiresConfirmation && (
|
)}
|
||||||
<p className="dark:text-darkgray-600 text-gray-600">
|
{stripeAppData.price > 0 && (
|
||||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
<p className="-ml-2 px-2 text-sm font-medium">
|
||||||
{t("requires_confirmation")}
|
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||||
</p>
|
<IntlProvider locale="en">
|
||||||
)}
|
<FormattedNumber
|
||||||
<AvailableEventLocations locations={eventType.locations} />
|
value={stripeAppData.price / 100.0}
|
||||||
<p className="dark:text-darkgray-600 text-gray-600">
|
style="currency"
|
||||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
currency={stripeAppData.currency.toUpperCase()}
|
||||||
{eventType.length} {t("minutes")}
|
/>
|
||||||
|
</IntlProvider>
|
||||||
</p>
|
</p>
|
||||||
{eventType.price > 0 && (
|
)}
|
||||||
<div className="dark:text-darkgray-600 text-gray-600">
|
{timezoneDropdown}
|
||||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
</BookingDescription>
|
||||||
<IntlProvider locale="en">
|
|
||||||
<FormattedNumber
|
|
||||||
value={eventType.price / 100.0}
|
|
||||||
style="currency"
|
|
||||||
currency={eventType.currency.toUpperCase()}
|
|
||||||
/>
|
|
||||||
</IntlProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!rescheduleUid && eventType.recurringEvent && (
|
|
||||||
<div className="dark:text-darkgray-600 flex items-center text-gray-600">
|
|
||||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 shrink-0 text-gray-500" />
|
|
||||||
<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:text-darkgray-600 h-7 rounded-sm border-gray-300 bg-white text-sm text-gray-600 [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600"
|
|
||||||
defaultValue={eventType.recurringEvent.count}
|
|
||||||
onChange={(event) => {
|
|
||||||
setRecurringEventCount(parseInt(event?.target.value));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="dark:text-darkgray-600 inline text-gray-600">
|
|
||||||
{t("occurrence", {
|
|
||||||
count: recurringEventCount,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{timezoneDropdown}
|
|
||||||
|
|
||||||
<div className="md:hidden">
|
{!isEmbed && (
|
||||||
{/* Temp disabled booking?.startTime && rescheduleUid && (
|
<div className="mt-auto hidden md:block">
|
||||||
<div>
|
<GoBackToPreviousPage t={t} />
|
||||||
<p
|
|
||||||
className="mt-8 text-gray-600 dark:text-darkgray-600"
|
|
||||||
data-testid="former_time_p_mobile">
|
|
||||||
{t("former_time")}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-500 line-through dark:text-darkgray-600">
|
|
||||||
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
|
||||||
{typeof booking.startTime === "string" &&
|
|
||||||
parseDate(dayjs(booking.startTime), i18n)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)*/}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden sm:flex">
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"sm:dark:border-darkgray-200 hidden overflow-hidden border-gray-200 p-5 sm:border-r md:flex md:flex-col " +
|
|
||||||
(isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
|
||||||
}>
|
|
||||||
<UserAvatars
|
|
||||||
profile={profile}
|
|
||||||
users={eventType.users}
|
|
||||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
|
||||||
size={10}
|
|
||||||
truncateAfter={3}
|
|
||||||
/>
|
|
||||||
<h2 className="break-words text-sm font-medium text-gray-600 dark:text-gray-300 lg:mt-2">
|
|
||||||
{profile.name}
|
|
||||||
</h2>
|
|
||||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl 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">
|
|
||||||
{eventType?.description && (
|
|
||||||
<div className="flex">
|
|
||||||
<div>
|
|
||||||
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{eventType?.requiresConfirmation && (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div>
|
|
||||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
|
||||||
</div>
|
|
||||||
{t("requires_confirmation")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<AvailableEventLocations locations={eventType.locations} />
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
|
||||||
{eventType.length} {t("minutes")}
|
|
||||||
</p>
|
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
{eventType.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={eventType.price / 100.0}
|
|
||||||
style="currency"
|
|
||||||
currency={eventType.currency.toUpperCase()}
|
|
||||||
/>
|
|
||||||
</IntlProvider>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{timezoneDropdown}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isEmbed && <GoBackToPreviousPage t={t} />}
|
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||||
|
|
||||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
||||||
@@ -592,26 +453,34 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)*/}
|
)*/}
|
||||||
|
</div>
|
||||||
|
<SlotPicker
|
||||||
|
weekStart={
|
||||||
|
typeof profile.weekStart === "string"
|
||||||
|
? ([
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
].indexOf(profile.weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||||
|
: profile.weekStart /* Allows providing weekStart as number */
|
||||||
|
}
|
||||||
|
eventType={eventType}
|
||||||
|
timeFormat={timeFormat}
|
||||||
|
onTimeFormatChange={onTimeFormatChange}
|
||||||
|
timeZone={timeZone}
|
||||||
|
users={userList}
|
||||||
|
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||||
|
recurringEventCount={recurringEventCount}
|
||||||
|
ethSignature={gateState.rainbowToken}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SlotPicker
|
|
||||||
weekStart={
|
|
||||||
typeof profile.weekStart === "string"
|
|
||||||
? (["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
|
||||||
profile.weekStart
|
|
||||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
|
||||||
: profile.weekStart /* Allows providing weekStart as number */
|
|
||||||
}
|
|
||||||
eventType={eventType}
|
|
||||||
timeFormat={timeFormat}
|
|
||||||
timeZone={timeZone}
|
|
||||||
users={userList}
|
|
||||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
|
||||||
recurringEventCount={recurringEventCount}
|
|
||||||
ethSignature={gateState.rainbowToken}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||||
</div>
|
</div>
|
||||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Toaster position="bottom-right" />
|
<Toaster position="bottom-right" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { EventTypeCustomInputType, WorkflowActions } from "@prisma/client";
|
import { EventTypeCustomInputType, WorkflowActions } from "@prisma/client";
|
||||||
import { SchedulingType } from "@prisma/client";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
@@ -9,17 +9,19 @@ import { useEffect, useMemo, useState, useReducer } from "react";
|
|||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { ReactMultiEmail } from "react-multi-email";
|
import { ReactMultiEmail } from "react-multi-email";
|
||||||
import { useMutation } from "react-query";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||||
import {
|
import {
|
||||||
locationKeyToString,
|
locationKeyToString,
|
||||||
getEventLocationValue,
|
getEventLocationValue,
|
||||||
getEventLocationType,
|
getEventLocationType,
|
||||||
EventLocationType,
|
EventLocationType,
|
||||||
|
getHumanReadableLocationValue,
|
||||||
} from "@calcom/app-store/locations";
|
} from "@calcom/app-store/locations";
|
||||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||||
|
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||||
import { LocationObject, LocationType } from "@calcom/core/location";
|
import { LocationObject, LocationType } from "@calcom/core/location";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +31,8 @@ import {
|
|||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
import { formatTime } from "@calcom/lib/date-fns";
|
||||||
|
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -43,14 +47,14 @@ import { Button } from "@calcom/ui/v2";
|
|||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { ensureArray } from "@lib/ensureArray";
|
import { ensureArray } from "@lib/ensureArray";
|
||||||
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||||
import slugify from "@lib/slugify";
|
import slugify from "@lib/slugify";
|
||||||
|
|
||||||
import Gates, { Gate, GateState } from "@components/Gates";
|
import Gates, { Gate, GateState } from "@components/Gates";
|
||||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
import BookingDescription from "@components/booking/BookingDescription";
|
||||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
|
||||||
|
|
||||||
import { BookPageProps } from "../../../pages/[user]/book";
|
import { BookPageProps } from "../../../pages/[user]/book";
|
||||||
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||||
@@ -81,9 +85,13 @@ const BookingPage = ({
|
|||||||
recurringEventCount,
|
recurringEventCount,
|
||||||
hasHashedBookingLink,
|
hasHashedBookingLink,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
|
...restProps
|
||||||
}: BookingPageProps) => {
|
}: BookingPageProps) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const isEmbed = useIsEmbed();
|
// Get user so we can determine 12/24 hour format preferences
|
||||||
|
const query = useMeQuery();
|
||||||
|
const user = query.data;
|
||||||
|
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -97,6 +105,7 @@ const BookingPage = ({
|
|||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
const stripeAppData = getStripeAppData(eventType);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (top !== window) {
|
if (top !== window) {
|
||||||
@@ -111,14 +120,14 @@ const BookingPage = ({
|
|||||||
|
|
||||||
const mutation = useMutation(createBooking, {
|
const mutation = useMutation(createBooking, {
|
||||||
onSuccess: async (responseData) => {
|
onSuccess: async (responseData) => {
|
||||||
const { id, attendees, paymentUid } = responseData;
|
const { id, paymentUid } = responseData;
|
||||||
if (paymentUid) {
|
if (paymentUid) {
|
||||||
return await router.push(
|
return await router.push(
|
||||||
createPaymentLink({
|
createPaymentLink({
|
||||||
paymentUid,
|
paymentUid,
|
||||||
date,
|
date,
|
||||||
name: attendees[0].name,
|
name: bookingForm.getValues("name"),
|
||||||
email: attendees[0].email,
|
email: bookingForm.getValues("email"),
|
||||||
absolute: false,
|
absolute: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -130,10 +139,10 @@ const BookingPage = ({
|
|||||||
date,
|
date,
|
||||||
type: eventType.id,
|
type: eventType.id,
|
||||||
eventSlug: eventType.slug,
|
eventSlug: eventType.slug,
|
||||||
user: profile.slug,
|
username: profile.slug,
|
||||||
reschedule: !!rescheduleUid,
|
reschedule: !!rescheduleUid,
|
||||||
name: attendees[0].name,
|
name: bookingForm.getValues("name"),
|
||||||
email: attendees[0].email,
|
email: bookingForm.getValues("email"),
|
||||||
location: responseData.location,
|
location: responseData.location,
|
||||||
eventName: profile.eventName || "",
|
eventName: profile.eventName || "",
|
||||||
bookingId: id,
|
bookingId: id,
|
||||||
@@ -163,7 +172,7 @@ const BookingPage = ({
|
|||||||
type: eventType.id,
|
type: eventType.id,
|
||||||
eventSlug: eventType.slug,
|
eventSlug: eventType.slug,
|
||||||
recur: recurringEventId,
|
recur: recurringEventId,
|
||||||
user: profile.slug,
|
username: profile.slug,
|
||||||
reschedule: !!rescheduleUid,
|
reschedule: !!rescheduleUid,
|
||||||
name: attendees[0].name,
|
name: attendees[0].name,
|
||||||
email: attendees[0].email,
|
email: attendees[0].email,
|
||||||
@@ -258,11 +267,13 @@ const BookingPage = ({
|
|||||||
phone: z
|
phone: z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
.optional(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
smsReminderNumber: z
|
smsReminderNumber: z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
.optional(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
@@ -354,7 +365,9 @@ const BookingPage = ({
|
|||||||
hasHashedBookingLink,
|
hasHashedBookingLink,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
smsReminderNumber:
|
smsReminderNumber:
|
||||||
selectedLocationType === LocationType.Phone ? booking.phone : booking.smsReminderNumber,
|
selectedLocationType === LocationType.Phone
|
||||||
|
? booking.phone
|
||||||
|
: booking.smsReminderNumber || undefined,
|
||||||
ethSignature: gateState.rainbowToken,
|
ethSignature: gateState.rainbowToken,
|
||||||
}));
|
}));
|
||||||
recurringMutation.mutate(recurringBookings);
|
recurringMutation.mutate(recurringBookings);
|
||||||
@@ -382,7 +395,9 @@ const BookingPage = ({
|
|||||||
hasHashedBookingLink,
|
hasHashedBookingLink,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
smsReminderNumber:
|
smsReminderNumber:
|
||||||
selectedLocationType === LocationType.Phone ? booking.phone : booking.smsReminderNumber,
|
selectedLocationType === LocationType.Phone
|
||||||
|
? booking.phone
|
||||||
|
: booking.smsReminderNumber || undefined,
|
||||||
ethSignature: gateState.rainbowToken,
|
ethSignature: gateState.rainbowToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -396,6 +411,7 @@ const BookingPage = ({
|
|||||||
"dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500";
|
"dark:placeholder:text-darkgray-600 focus:border-brand dark:border-darkgray-300 dark:text-darkgray-900 block w-full rounded-md border-gray-300 text-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:bg-transparent dark:selection:bg-green-500 disabled:dark:text-gray-500";
|
||||||
|
|
||||||
let isSmsReminderNumberNeeded = false;
|
let isSmsReminderNumberNeeded = false;
|
||||||
|
let isSmsReminderNumberRequired = false;
|
||||||
|
|
||||||
if (eventType.workflows.length > 0) {
|
if (eventType.workflows.length > 0) {
|
||||||
eventType.workflows.forEach((workflowReference) => {
|
eventType.workflows.forEach((workflowReference) => {
|
||||||
@@ -403,23 +419,25 @@ const BookingPage = ({
|
|||||||
workflowReference.workflow.steps.forEach((step) => {
|
workflowReference.workflow.steps.forEach((step) => {
|
||||||
if (step.action === WorkflowActions.SMS_ATTENDEE) {
|
if (step.action === WorkflowActions.SMS_ATTENDEE) {
|
||||||
isSmsReminderNumberNeeded = true;
|
isSmsReminderNumberNeeded = true;
|
||||||
|
isSmsReminderNumberRequired = step.numberRequired || false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||||
|
|
||||||
// Define conditional gates here
|
// Define conditional gates here
|
||||||
const gates = [
|
const gates = [
|
||||||
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
||||||
eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress
|
rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress
|
||||||
? ("rainbow" as Gate)
|
? ("rainbow" as Gate)
|
||||||
: undefined,
|
: undefined,
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
|
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
|
||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
{rescheduleUid
|
{rescheduleUid
|
||||||
@@ -435,6 +453,7 @@ const BookingPage = ({
|
|||||||
</title>
|
</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
|
<BookingPageTagManager eventType={eventType} />
|
||||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||||
<main
|
<main
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -445,54 +464,20 @@ const BookingPage = ({
|
|||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"main overflow-hidden",
|
"main overflow-hidden",
|
||||||
isEmbed ? "" : "border border-gray-200",
|
|
||||||
isBackgroundTransparent ? "" : "dark:border-1 dark:bg-darkgray-100 bg-white",
|
isBackgroundTransparent ? "" : "dark:border-1 dark:bg-darkgray-100 bg-white",
|
||||||
"dark:border-darkgray-300 rounded-md sm:border"
|
"dark:border-darkgray-300 rounded-md sm:border"
|
||||||
)}>
|
)}>
|
||||||
<div className="sm:flex">
|
<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">
|
<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">
|
||||||
<UserAvatars
|
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||||
profile={profile}
|
{stripeAppData.price > 0 && (
|
||||||
users={eventType.users}
|
|
||||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
|
||||||
size={10}
|
|
||||||
truncateAfter={3}
|
|
||||||
/>
|
|
||||||
<h2 className="mt-2 break-words text-sm font-medium text-gray-500 dark:text-gray-300">
|
|
||||||
{profile.name}
|
|
||||||
</h2>
|
|
||||||
<h1 className="font-cal dark:text-darkgray-900 break-words text-2xl text-gray-900 ">
|
|
||||||
{eventType.title}
|
|
||||||
</h1>
|
|
||||||
<div className="mt-4 flex flex-col space-y-3 lg:mt-9">
|
|
||||||
{eventType?.description && (
|
|
||||||
<div className="dark:text-darkgray-600 flex text-sm font-medium text-gray-600">
|
|
||||||
<div>
|
|
||||||
<Icon.FiInfo className="dark:text-darkgray-600 mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{eventType?.requiresConfirmation && (
|
|
||||||
<div className="dark:text-darkgray-600 flex items-center text-sm font-medium text-gray-600">
|
|
||||||
<div>
|
|
||||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
|
||||||
</div>
|
|
||||||
{t("requires_confirmation")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="dark:text-darkgray-600 text-sm font-medium text-gray-600">
|
|
||||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
|
||||||
{eventType.length} {t("minutes")}
|
|
||||||
</p>
|
|
||||||
{eventType.price > 0 && (
|
|
||||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
<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" />
|
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<FormattedNumber
|
<FormattedNumber
|
||||||
value={eventType.price / 100.0}
|
value={stripeAppData.price / 100.0}
|
||||||
style="currency"
|
style="currency"
|
||||||
currency={eventType.currency.toUpperCase()}
|
currency={stripeAppData.currency.toUpperCase()}
|
||||||
/>
|
/>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</p>
|
</p>
|
||||||
@@ -513,15 +498,25 @@ const BookingPage = ({
|
|||||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
{(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
||||||
parseDate(dayjs(date).tz(timeZone()), i18n)}
|
`${formatTime(dayjs(date).toDate(), user?.timeFormat, user?.timeZone)}, ${dayjs(
|
||||||
|
date
|
||||||
|
).format("dddd, D MMMM YYYY")}`}
|
||||||
{!rescheduleUid &&
|
{!rescheduleUid &&
|
||||||
eventType.recurringEvent?.freq &&
|
eventType.recurringEvent?.freq &&
|
||||||
recurringStrings.slice(0, 5).map((aDate, key) => <p key={key}>{aDate}</p>)}
|
recurringDates.slice(0, 5).map((aDate, key) => {
|
||||||
|
return (
|
||||||
|
<p key={key}>{`${formatTime(aDate, user?.timeFormat, user?.timeZone)}, ${dayjs(
|
||||||
|
aDate
|
||||||
|
).format("dddd, D MMMM YYYY")}`}</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={recurringStrings.slice(5).map((aDate, key) => (
|
content={recurringDates.slice(5).map((aDate, key) => (
|
||||||
<p key={key}>{aDate}</p>
|
<p key={key}>{`${formatTime(aDate, user?.timeFormat, user?.timeZone)}, ${dayjs(
|
||||||
|
aDate
|
||||||
|
).format("dddd, D MMMM YYYY")}`}</p>
|
||||||
))}>
|
))}>
|
||||||
<p className="dark:text-darkgray-600 text-sm">
|
<p className="dark:text-darkgray-600 text-sm">
|
||||||
{t("plus_more", { count: recurringStrings.length - 5 })}
|
{t("plus_more", { count: recurringStrings.length - 5 })}
|
||||||
@@ -544,7 +539,15 @@ const BookingPage = ({
|
|||||||
)}
|
)}
|
||||||
{!!eventType.seatsPerTimeSlot && (
|
{!!eventType.seatsPerTimeSlot && (
|
||||||
<div className="text-bookinghighlight flex items-start text-sm">
|
<div className="text-bookinghighlight flex items-start text-sm">
|
||||||
<Icon.FiUser className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
<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
|
<p
|
||||||
className={`${
|
className={`${
|
||||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||||
@@ -560,7 +563,7 @@ const BookingPage = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</BookingDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 sm:w-1/2">
|
<div className="p-6 sm:w-1/2">
|
||||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||||
@@ -605,37 +608,51 @@ const BookingPage = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{locations.length > 1 && (
|
<>
|
||||||
<div className="mb-4">
|
{rescheduleUid ? (
|
||||||
<span className="block text-sm font-medium text-gray-700 dark:text-white">
|
<div className="mb-4">
|
||||||
{t("location")}
|
<span className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
</span>
|
{t("location")}
|
||||||
{locations.map((location, i) => {
|
</span>
|
||||||
const locationString = locationKeyToString(location);
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
// TODO: Right now selectedLocationType isn't send by getSSP. Once that's available defaultChecked should work and show the location in the original booking
|
{getHumanReadableLocationValue(booking?.location, t)}
|
||||||
const defaultChecked = rescheduleUid ? selectedLocationType === location.type : i === 0;
|
</p>
|
||||||
if (typeof locationString !== "string") {
|
</div>
|
||||||
// It's possible that location app got uninstalled
|
) : (
|
||||||
return null;
|
locations.length > 1 && (
|
||||||
}
|
<div className="mb-4">
|
||||||
return (
|
<span className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
<label key={i} className="block">
|
{t("location")}
|
||||||
<input
|
</span>
|
||||||
type="radio"
|
{locations.map((location, i) => {
|
||||||
disabled={!!disableLocations}
|
const locationString = locationKeyToString(location);
|
||||||
className="location dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
if (!selectedLocationType) {
|
||||||
{...bookingForm.register("locationType", { required: true })}
|
bookingForm.setValue("locationType", locations[0].type);
|
||||||
value={location.type}
|
}
|
||||||
defaultChecked={defaultChecked}
|
if (typeof locationString !== "string") {
|
||||||
/>
|
// It's possible that location app got uninstalled
|
||||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
|
return null;
|
||||||
{locationKeyToString(location)}
|
}
|
||||||
</span>
|
return (
|
||||||
</label>
|
<label key={i} className="block">
|
||||||
);
|
<input
|
||||||
})}
|
type="radio"
|
||||||
</div>
|
disabled={!!disableLocations}
|
||||||
)}
|
className="location dark:bg-darkgray-300 dark:border-darkgray-300 h-4 w-4 border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
||||||
|
{...bookingForm.register("locationType", { required: true })}
|
||||||
|
value={location.type}
|
||||||
|
defaultChecked={i === 0}
|
||||||
|
/>
|
||||||
|
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
|
||||||
|
{locationKeyToString(location)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
{/* TODO: Change name and id ="phone" to something generic */}
|
{/* TODO: Change name and id ="phone" to something generic */}
|
||||||
{AttendeeInput && (
|
{AttendeeInput && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -713,83 +730,81 @@ const BookingPage = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{input.type === EventTypeCustomInputType.BOOL && (
|
{input.type === EventTypeCustomInputType.BOOL && (
|
||||||
<div className="flex h-5 items-center">
|
<div className="my-6">
|
||||||
<input
|
<div className="flex">
|
||||||
type="checkbox"
|
<input
|
||||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
type="checkbox"
|
||||||
required: input.required,
|
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||||
})}
|
required: input.required,
|
||||||
required={input.required}
|
})}
|
||||||
id={"custom_" + input.id}
|
required={input.required}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
id={"custom_" + input.id}
|
||||||
placeholder=""
|
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||||
disabled={disabledExceptForOwner}
|
placeholder=""
|
||||||
/>
|
disabled={disabledExceptForOwner}
|
||||||
<label
|
/>
|
||||||
htmlFor={"custom_" + input.id}
|
<label
|
||||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
htmlFor={"custom_" + input.id}
|
||||||
{input.label}
|
className="-mt-px block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
</label>
|
{input.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!eventType.disableGuests && (
|
{!eventType.disableGuests && guestToggle && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
{guestToggle && (
|
<div>
|
||||||
<div>
|
<label
|
||||||
<label
|
htmlFor="guests"
|
||||||
htmlFor="guests"
|
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
{t("guests")}
|
||||||
{t("guests")}
|
</label>
|
||||||
</label>
|
{!disableInput && (
|
||||||
{!disableInput && (
|
<Controller
|
||||||
<Controller
|
control={bookingForm.control}
|
||||||
control={bookingForm.control}
|
name="guests"
|
||||||
name="guests"
|
render={({ field: { onChange, value } }) => (
|
||||||
render={({ field: { onChange, value } }) => (
|
<ReactMultiEmail
|
||||||
<ReactMultiEmail
|
className="relative"
|
||||||
className="relative"
|
placeholder={<span className="dark:text-darkgray-600">guest@example.com</span>}
|
||||||
placeholder={
|
emails={value}
|
||||||
<span className="dark:text-darkgray-600">guest@example.com</span>
|
onChange={onChange}
|
||||||
}
|
getLabel={(
|
||||||
emails={value}
|
email: string,
|
||||||
onChange={onChange}
|
index: number,
|
||||||
getLabel={(
|
removeEmail: (index: number) => void
|
||||||
email: string,
|
) => {
|
||||||
index: number,
|
return (
|
||||||
removeEmail: (index: number) => void
|
<div data-tag key={index} className="cursor-pointer">
|
||||||
) => {
|
{email}
|
||||||
return (
|
{!disableInput && (
|
||||||
<div data-tag key={index} className="cursor-pointer">
|
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||||
{email}
|
×
|
||||||
{!disableInput && (
|
</span>
|
||||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
)}
|
||||||
×
|
</div>
|
||||||
</span>
|
);
|
||||||
)}
|
}}
|
||||||
</div>
|
/>
|
||||||
);
|
)}
|
||||||
}}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
{/* Custom code when guest emails should not be editable */}
|
||||||
/>
|
{disableInput && guestListEmails && guestListEmails.length > 0 && (
|
||||||
)}
|
<div data-tag className="react-multi-email">
|
||||||
{/* Custom code when guest emails should not be editable */}
|
{/* // @TODO: user owners are appearing as guest here when should be only user input */}
|
||||||
{disableInput && guestListEmails && guestListEmails.length > 0 && (
|
{guestListEmails.map((email, index) => {
|
||||||
<div data-tag className="react-multi-email">
|
return (
|
||||||
{/* // @TODO: user owners are appearing as guest here when should be only user input */}
|
<div key={index} className="cursor-pointer">
|
||||||
{guestListEmails.map((email, index) => {
|
<span data-tag>{email}</span>
|
||||||
return (
|
</div>
|
||||||
<div key={index} className="cursor-pointer">
|
);
|
||||||
<span data-tag>{email}</span>
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSmsReminderNumberNeeded && selectedLocationType !== LocationType.Phone && (
|
{isSmsReminderNumberNeeded && selectedLocationType !== LocationType.Phone && (
|
||||||
@@ -797,7 +812,7 @@ const BookingPage = ({
|
|||||||
<label
|
<label
|
||||||
htmlFor="smsReminderNumber"
|
htmlFor="smsReminderNumber"
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-white">
|
className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
{t("number_for_sms_reminders")}
|
{t("number_sms_notifications")}
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<PhoneInput<BookingFormValues>
|
<PhoneInput<BookingFormValues>
|
||||||
@@ -805,7 +820,7 @@ const BookingPage = ({
|
|||||||
name="smsReminderNumber"
|
name="smsReminderNumber"
|
||||||
placeholder={t("enter_phone_number")}
|
placeholder={t("enter_phone_number")}
|
||||||
id="smsReminderNumber"
|
id="smsReminderNumber"
|
||||||
required
|
required={isSmsReminderNumberRequired}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{bookingForm.formState.errors.smsReminderNumber && (
|
{bookingForm.formState.errors.smsReminderNumber && (
|
||||||
@@ -834,6 +849,7 @@ const BookingPage = ({
|
|||||||
) : (
|
) : (
|
||||||
<textarea
|
<textarea
|
||||||
{...bookingForm.register("notes")}
|
{...bookingForm.register("notes")}
|
||||||
|
required={!!eventType.metadata.additionalNotesRequired}
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -845,15 +861,16 @@ const BookingPage = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 rtl:space-x-reverse">
|
<div className="flex justify-end space-x-2 rtl:space-x-reverse">
|
||||||
{!guestToggle && (
|
{!eventType.disableGuests && !guestToggle && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
color="secondary"
|
color="minimalSecondary"
|
||||||
|
size="icon"
|
||||||
|
tooltip={t("additional_guests")}
|
||||||
|
StartIcon={Icon.FiUserPlus}
|
||||||
onClick={() => setGuestToggle(!guestToggle)}
|
onClick={() => setGuestToggle(!guestToggle)}
|
||||||
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast mr-auto dark:border-transparent">
|
className="mr-auto"
|
||||||
<Icon.FiUserPlus className="mr-2" />
|
/>
|
||||||
{t("additional_guests")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
color="minimal"
|
color="minimal"
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { RescheduleResponse } from "pages/api/book/request-reschedule";
|
|
||||||
import React, { useState, Dispatch, SetStateAction } from "react";
|
import React, { useState, Dispatch, SetStateAction } from "react";
|
||||||
import { useMutation } from "react-query";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { TextArea } from "@calcom/ui/form/fields";
|
import { TextArea } from "@calcom/ui/form/fields";
|
||||||
import Button from "@calcom/ui/v2/core/Button";
|
import Button from "@calcom/ui/v2/core/Button";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
import * as fetchWrapper from "@lib/core/http/fetch-wrapper";
|
|
||||||
|
|
||||||
interface IRescheduleDialog {
|
interface IRescheduleDialog {
|
||||||
isOpenDialog: boolean;
|
isOpenDialog: boolean;
|
||||||
@@ -23,35 +19,18 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
|
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
|
||||||
const [rescheduleReason, setRescheduleReason] = useState("");
|
const [rescheduleReason, setRescheduleReason] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const rescheduleApi = useMutation(
|
|
||||||
async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await fetchWrapper.post<
|
|
||||||
{ bookingId: string; rescheduleReason: string },
|
|
||||||
RescheduleResponse
|
|
||||||
>("/api/book/request-reschedule", {
|
|
||||||
bookingId,
|
|
||||||
rescheduleReason,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result) {
|
const { mutate: rescheduleApi, isLoading } = trpc.useMutation("viewer.bookings.requestReschedule", {
|
||||||
showToast(t("reschedule_request_sent"), "success");
|
async onSuccess() {
|
||||||
setIsOpenDialog(false);
|
showToast(t("reschedule_request_sent"), "success");
|
||||||
}
|
setIsOpenDialog(false);
|
||||||
} catch (error) {
|
await utils.invalidateQueries(["viewer.bookings"]);
|
||||||
showToast(t("unexpected_error_try_again"), "error");
|
|
||||||
// @TODO: notify sentry
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
},
|
},
|
||||||
{
|
onError() {
|
||||||
async onSettled() {
|
showToast(t("unexpected_error_try_again"), "error");
|
||||||
await utils.invalidateQueries(["viewer.bookings"]);
|
// @TODO: notify sentry
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
@@ -84,7 +63,10 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
data-testid="send_request"
|
data-testid="send_request"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
rescheduleApi.mutate();
|
rescheduleApi({
|
||||||
|
bookingId,
|
||||||
|
rescheduleReason,
|
||||||
|
});
|
||||||
}}>
|
}}>
|
||||||
{t("send_reschedule_request")}
|
{t("send_reschedule_request")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { z } from "zod";
|
|||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
@@ -24,6 +23,7 @@ import Dropdown, {
|
|||||||
} from "@calcom/ui/Dropdown";
|
} from "@calcom/ui/Dropdown";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { Form, InputLeading, TextAreaField, TextField } from "@calcom/ui/form/fields";
|
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 { HttpError } from "@lib/core/http/error";
|
||||||
import { slugify } from "@lib/slugify";
|
import { slugify } from "@lib/slugify";
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { Prisma, SchedulingType } from "@prisma/client";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
|
||||||
|
|
||||||
import { parseRecurringEvent } from "@calcom/lib";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
|
||||||
|
|
||||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
|
||||||
select: baseEventTypeSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
|
||||||
|
|
||||||
export type EventTypeDescriptionProps = {
|
|
||||||
eventType: EventType;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
|
|
||||||
const recurringEvent = useMemo(
|
|
||||||
() => parseRecurringEvent(eventType.recurringEvent),
|
|
||||||
[eventType.recurringEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={classNames("text-gray-600 dark:text-white", className)}>
|
|
||||||
{eventType.description && (
|
|
||||||
<h2 className="max-w-[280px] overflow-hidden text-ellipsis sm:max-w-[500px]">
|
|
||||||
{eventType.description.substring(0, 100)}
|
|
||||||
{eventType.description.length > 100 && "..."}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<ul className="mt-2 flex flex-wrap space-x-1 sm:flex-nowrap ">
|
|
||||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
|
||||||
<Icon.FiClock className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
|
||||||
{eventType.length} {t("minutes")}
|
|
||||||
</li>
|
|
||||||
{eventType.schedulingType ? (
|
|
||||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
|
||||||
<Icon.FiUsers className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
|
||||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
|
|
||||||
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
|
||||||
<Icon.FiUser className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
|
||||||
{t("1_on_1")}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{recurringEvent?.count && recurringEvent.count > 0 && (
|
|
||||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
|
||||||
<Icon.FiRefreshCw className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
|
||||||
{t("repeats_up_to", {
|
|
||||||
count: recurringEvent.count,
|
|
||||||
})}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{eventType.price > 0 && (
|
|
||||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
|
||||||
<Icon.FiCreditCard className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<FormattedNumber
|
|
||||||
value={eventType.price / 100.0}
|
|
||||||
style="currency"
|
|
||||||
currency={eventType.currency.toUpperCase()}
|
|
||||||
/>
|
|
||||||
</IntlProvider>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{eventType.requiresConfirmation && (
|
|
||||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
|
||||||
<Icon.FiCheckSquare className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
|
||||||
{t("requires_confirmation")}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EventTypeDescription;
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import type { FormValues } from "pages/event-types/[type]";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { UseFormReturn } 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 from "@components/ui/form/Select";
|
|
||||||
|
|
||||||
type RecurringEventControllerProps = {
|
|
||||||
recurringEvent: RecurringEvent | null;
|
|
||||||
formMethods: UseFormReturn<FormValues>;
|
|
||||||
paymentEnabled: boolean;
|
|
||||||
onRecurringEventDefined: (value: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
* use component from `/apps/web/components/v2/eventtype/RecurringEventController` instead
|
|
||||||
**/
|
|
||||||
export default function RecurringEventController({
|
|
||||||
recurringEvent,
|
|
||||||
formMethods,
|
|
||||||
paymentEnabled,
|
|
||||||
onRecurringEventDefined,
|
|
||||||
}: RecurringEventControllerProps) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
|
||||||
|
|
||||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
|
||||||
const recurringEventFreqOptions = Object.entries(Frequency)
|
|
||||||
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
|
|
||||||
.map(([key, value]) => ({
|
|
||||||
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventState?.interval }),
|
|
||||||
value: value.toString(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="block items-start sm:flex">
|
|
||||||
<div className="min-w-48 mb-4 sm:mb-0">
|
|
||||||
<span className="flex text-sm font-medium text-neutral-700">{t("recurring_event")}</span>
|
|
||||||
</div>
|
|
||||||
<div className={!paymentEnabled ? "w-full" : ""}>
|
|
||||||
{paymentEnabled ? (
|
|
||||||
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex h-5 items-start">
|
|
||||||
<input
|
|
||||||
onChange={(event) => {
|
|
||||||
onRecurringEventDefined(event?.target.checked);
|
|
||||||
if (!event?.target.checked) {
|
|
||||||
formMethods.setValue("recurringEvent", null);
|
|
||||||
setRecurringEventState(null);
|
|
||||||
} else {
|
|
||||||
const newVal = recurringEvent || {
|
|
||||||
interval: 1,
|
|
||||||
count: 12,
|
|
||||||
freq: Frequency.WEEKLY,
|
|
||||||
};
|
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
|
||||||
setRecurringEventState(newVal);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="checkbox"
|
|
||||||
className="text-primary-600 h-4 w-4 rounded border-gray-300"
|
|
||||||
defaultChecked={recurringEventState !== null}
|
|
||||||
data-testid="recurring-event-check"
|
|
||||||
id="recurringEvent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
|
||||||
<label htmlFor="recurringEvent" className="text-neutral-900">
|
|
||||||
{t("recurring_event_description")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{recurringEventState && (
|
|
||||||
<div data-testid="recurring-event-collapsible" className="mt-4 text-sm">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
className="block w-16 rounded-sm border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
|
||||||
defaultValue={recurringEventState.interval}
|
|
||||||
onChange={(event) => {
|
|
||||||
const newVal = {
|
|
||||||
...recurringEventState,
|
|
||||||
interval: parseInt(event?.target.value),
|
|
||||||
};
|
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
|
||||||
setRecurringEventState(newVal);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
options={recurringEventFreqOptions}
|
|
||||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
|
||||||
isSearchable={false}
|
|
||||||
className="w-18 block min-w-0 rounded-sm text-sm"
|
|
||||||
onChange={(event) => {
|
|
||||||
const newVal = {
|
|
||||||
...recurringEventState,
|
|
||||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
|
||||||
};
|
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
|
||||||
setRecurringEventState(newVal);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-center">
|
|
||||||
<p className="mr-2 text-neutral-900">{t("max")}</p>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
className="block w-16 rounded-sm border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
|
||||||
defaultValue={recurringEventState.count}
|
|
||||||
onChange={(event) => {
|
|
||||||
const newVal = {
|
|
||||||
...recurringEventState,
|
|
||||||
count: parseInt(event?.target.value),
|
|
||||||
};
|
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
|
||||||
setRecurringEventState(newVal);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="mr-2 text-neutral-900">
|
|
||||||
{t("occurrence", {
|
|
||||||
count: recurringEventState.count,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,10 +14,11 @@ const CalendarItem = (props: ICalendarItem) => {
|
|||||||
const { title, imageSrc, type } = props;
|
const { title, imageSrc, type } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-center p-5">
|
<div className="flex flex-row items-center justify-between p-5">
|
||||||
<img src={imageSrc} alt={title} className="h-8 w-8" />
|
<div className="flex items-center space-x-3">
|
||||||
<p className="mx-3 text-sm font-bold">{title}</p>
|
<img src={imageSrc} alt={title} className="h-8 w-8" />
|
||||||
|
<p className="text-sm font-bold">{title}</p>
|
||||||
|
</div>
|
||||||
<InstallAppButtonWithoutPlanCheck
|
<InstallAppButtonWithoutPlanCheck
|
||||||
type={type}
|
type={type}
|
||||||
render={(buttonProps) => (
|
render={(buttonProps) => (
|
||||||
@@ -29,8 +30,7 @@ const CalendarItem = (props: ICalendarItem) => {
|
|||||||
// Save cookie key to return url step
|
// Save cookie key to return url step
|
||||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||||
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
|
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
|
||||||
}}
|
}}>
|
||||||
className="ml-auto rounded-md border border-gray-200 py-[10px] px-4 text-sm font-bold">
|
|
||||||
{t("connect")}
|
{t("connect")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMutation } from "react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Switch } from "@calcom/ui/v2";
|
import { Switch } from "@calcom/ui/v2";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ const Steps = (props: ISteps) => {
|
|||||||
key={`step-${index}`}
|
key={`step-${index}`}
|
||||||
onClick={() => navigateToStep(index)}
|
onClick={() => navigateToStep(index)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"h-1 w-1/4 rounded-[1px] bg-black dark:bg-white",
|
"h-1 w-full rounded-[1px] bg-black dark:bg-white",
|
||||||
index < currentStep ? "cursor-pointer" : ""
|
index < currentStep ? "cursor-pointer" : ""
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div key={`step-${index}`} className="h-1 w-1/4 rounded-[1px] bg-black bg-opacity-25" />
|
<div key={`step-${index}`} className="h-1 w-full rounded-[1px] bg-black bg-opacity-25" />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Already connected calendars */}
|
{/* Already connected calendars */}
|
||||||
{firstCalendar &&
|
{!queryConnectedCalendars.isLoading &&
|
||||||
|
firstCalendar &&
|
||||||
firstCalendar.integration &&
|
firstCalendar.integration &&
|
||||||
firstCalendar.integration.title &&
|
firstCalendar.integration.title &&
|
||||||
firstCalendar.integration.imageSrc && (
|
firstCalendar.integration.imageSrc && (
|
||||||
@@ -75,7 +76,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
|||||||
</List>
|
</List>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{queryConnectedCalendars.isLoading && (
|
{queryIntegrations.isLoading && (
|
||||||
<ul className="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white p-0 dark:bg-black">
|
<ul className="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white p-0 dark:bg-black">
|
||||||
{[0, 0, 0, 0].map((_item, index) => {
|
{[0, 0, 0, 0].map((_item, index) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const UserProfile = (props: IUserProfile) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{errors.bio && (
|
{errors.bio && (
|
||||||
<p data-testid="required" className="text-xs italic text-red-500">
|
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||||
{t("required")}
|
{t("required")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
className="w-full rounded-md border border-gray-300 text-sm"
|
className="w-full rounded-md border border-gray-300 text-sm"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p data-testid="required" className="text-xs italic text-red-500">
|
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||||
{t("required")}
|
{t("required")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { useMutation } from "react-query";
|
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
@@ -11,6 +10,7 @@ import { List } from "@calcom/ui/List";
|
|||||||
import { ShellSubHeading } from "@calcom/ui/Shell";
|
import { ShellSubHeading } from "@calcom/ui/Shell";
|
||||||
import Switch from "@calcom/ui/Switch";
|
import Switch from "@calcom/ui/Switch";
|
||||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
@@ -185,8 +185,8 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
severity="warning"
|
severity="warning"
|
||||||
title={t("calendar_error")}
|
title={t("something_went_wrong")}
|
||||||
message={item.error?.message}
|
message={t("calendar_error")}
|
||||||
actions={
|
actions={
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
id={item.credentialId}
|
id={item.credentialId}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||||
import { Dialog } from "@calcom/ui/Dialog";
|
import { Dialog } from "@calcom/ui/Dialog";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
export default function DisconnectIntegration(props: {
|
export default function DisconnectIntegration(props: {
|
||||||
/** Integration credential id */
|
/** Integration credential id */
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation } from "react-query";
|
|
||||||
|
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||||
import { Dialog } from "@calcom/ui/Dialog";
|
import { Dialog } from "@calcom/ui/Dialog";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent";
|
import DeleteStripeDialogContent from "@components/dialog/DeleteStripeDialogContent";
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import React, { SyntheticEvent, useState } from "react";
|
|||||||
|
|
||||||
import { ErrorCode } from "@calcom/lib/auth";
|
import { ErrorCode } from "@calcom/lib/auth";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
const ChangePasswordSection = () => {
|
const ChangePasswordSection = () => {
|
||||||
const [oldPassword, setOldPassword] = useState("");
|
const [oldPassword, setOldPassword] = useState("");
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import Badge from "@calcom/ui/Badge";
|
import Badge from "@calcom/ui/Badge";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonation: boolean }) => {
|
const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonation: boolean }) => {
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import merge from "lodash/merge";
|
import merge from "lodash/merge";
|
||||||
import { NextSeo, NextSeoProps } from "next-seo";
|
import { NextSeo, NextSeoProps } from "next-seo";
|
||||||
import React from "react";
|
|
||||||
|
import {
|
||||||
|
AppImageProps,
|
||||||
|
constructAppImage,
|
||||||
|
constructGenericImage,
|
||||||
|
constructMeetingImage,
|
||||||
|
MeetingImageProps,
|
||||||
|
} from "@calcom/lib/OgImages";
|
||||||
|
import { truncate, truncateOnWord } from "@calcom/lib/text";
|
||||||
|
|
||||||
import { getSeoImage, seoConfig } from "@lib/config/next-seo.config";
|
import { getSeoImage, seoConfig } from "@lib/config/next-seo.config";
|
||||||
import { getBrowserInfo } from "@lib/core/browser/browser.utils";
|
import { getBrowserInfo } from "@lib/core/browser/browser.utils";
|
||||||
@@ -9,11 +17,11 @@ export type HeadSeoProps = {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
siteName?: string;
|
siteName?: string;
|
||||||
name?: string;
|
|
||||||
url?: string;
|
url?: string;
|
||||||
username?: string;
|
|
||||||
canonical?: string;
|
canonical?: string;
|
||||||
nextSeoProps?: NextSeoProps;
|
nextSeoProps?: NextSeoProps;
|
||||||
|
app?: AppImageProps;
|
||||||
|
meeting?: MeetingImageProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,32 +71,14 @@ const buildSeoMeta = (pageProps: {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const constructImage = (name: string, description: string, username: string): string => {
|
|
||||||
return (
|
|
||||||
encodeURIComponent("Meet **" + name + "** <br>" + description).replace(/'/g, "%27") +
|
|
||||||
".png?md=1&images=https%3A%2F%2Fcal.com%2Flogo-white.svg&images=" +
|
|
||||||
(process.env.NEXT_PUBLIC_WEBSITE_URL || process.env.NEXT_PUBLIC_WEBAPP_URL) +
|
|
||||||
"/" +
|
|
||||||
username +
|
|
||||||
"/avatar.png"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
|
export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
|
||||||
const defaultUrl = getBrowserInfo()?.url;
|
const defaultUrl = getBrowserInfo()?.url;
|
||||||
const image = getSeoImage("default");
|
|
||||||
|
|
||||||
const {
|
const { title, description, siteName, canonical = defaultUrl, nextSeoProps = {}, app, meeting } = props;
|
||||||
title,
|
|
||||||
description,
|
const image = getSeoImage("ogImage") + constructGenericImage({ title, description });
|
||||||
name = null,
|
const truncatedDescription = truncateOnWord(description, 158);
|
||||||
username = null,
|
|
||||||
siteName,
|
|
||||||
canonical = defaultUrl,
|
|
||||||
nextSeoProps = {},
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const truncatedDescription = description.length > 24 ? description.substring(0, 23) + "..." : description;
|
|
||||||
const pageTitle = title + " | Cal.com";
|
const pageTitle = title + " | Cal.com";
|
||||||
let seoObject = buildSeoMeta({
|
let seoObject = buildSeoMeta({
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
@@ -98,8 +88,20 @@ export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
|
|||||||
siteName,
|
siteName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (name && username) {
|
if (meeting) {
|
||||||
const pageImage = getSeoImage("ogImage") + constructImage(name, truncatedDescription, username);
|
const pageImage = getSeoImage("ogImage") + constructMeetingImage(meeting);
|
||||||
|
seoObject = buildSeoMeta({
|
||||||
|
title: pageTitle,
|
||||||
|
description: truncatedDescription,
|
||||||
|
image: pageImage,
|
||||||
|
canonical,
|
||||||
|
siteName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app) {
|
||||||
|
const pageImage =
|
||||||
|
getSeoImage("ogImage") + constructAppImage({ ...app, description: truncatedDescription });
|
||||||
seoObject = buildSeoMeta({
|
seoObject = buildSeoMeta({
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
description: truncatedDescription,
|
description: truncatedDescription,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import Badge from "@calcom/ui/Badge";
|
import Badge from "@calcom/ui/Badge";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
/** @deprecated Use `packages/features/ee/teams/components/DisableTeamImpersonation.tsx` */
|
/** @deprecated Use `packages/features/ee/teams/components/DisableTeamImpersonation.tsx` */
|
||||||
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
|
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useState } from "react";
|
|||||||
import TeamAvailabilityModal from "@calcom/features/ee/teams/components/TeamAvailabilityModal";
|
import TeamAvailabilityModal from "@calcom/features/ee/teams/components/TeamAvailabilityModal";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||||
@@ -19,6 +18,7 @@ import Dropdown, {
|
|||||||
} from "@calcom/ui/Dropdown";
|
} from "@calcom/ui/Dropdown";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import Button from "@calcom/ui/v2/core/Button";
|
import Button from "@calcom/ui/v2/core/Button";
|
||||||
|
import ButtonGroup from "@calcom/ui/v2/core/ButtonGroup";
|
||||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||||
import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
|
import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
|
||||||
import Dropdown, {
|
import Dropdown, {
|
||||||
@@ -14,6 +15,7 @@ import Dropdown, {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
DropdownItem,
|
||||||
} from "@calcom/ui/v2/core/Dropdown";
|
} from "@calcom/ui/v2/core/Dropdown";
|
||||||
import { Tooltip } from "@calcom/ui/v2/core/Tooltip";
|
import { Tooltip } from "@calcom/ui/v2/core/Tooltip";
|
||||||
import showToast from "@calcom/ui/v2/core/notifications";
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
@@ -135,108 +137,103 @@ export default function TeamListItem(props: Props) {
|
|||||||
{!isInvitee && (
|
{!isInvitee && (
|
||||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||||
<TeamRole role={team.role} />
|
<TeamRole role={team.role} />
|
||||||
|
<ButtonGroup combined>
|
||||||
<Tooltip side="top" content={t("copy_link_team")}>
|
<Tooltip content={t("copy_link_team")}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
color="secondary"
|
||||||
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug);
|
onClick={() => {
|
||||||
showToast(t("link_copied"), "success");
|
navigator.clipboard.writeText(
|
||||||
}}
|
process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug
|
||||||
className="h-10 w-10 transition-none"
|
);
|
||||||
size="icon"
|
showToast(t("link_copied"), "success");
|
||||||
color="minimal"
|
}}
|
||||||
type="button">
|
size="icon"
|
||||||
<Icon.FiLink className="h-5 w-5 group-hover:text-gray-600" />
|
StartIcon={Icon.FiLink}
|
||||||
</Button>
|
combined
|
||||||
</Tooltip>
|
/>
|
||||||
<Dropdown>
|
</Tooltip>
|
||||||
<DropdownMenuTrigger asChild>
|
<Dropdown>
|
||||||
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
<DropdownMenuTrigger asChild className="radix-state-open:rounded-r-md">
|
||||||
</DropdownMenuTrigger>
|
<Button type="button" color="secondary" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
||||||
<DropdownMenuContent hidden={hideDropdown}>
|
</DropdownMenuTrigger>
|
||||||
{isAdmin && (
|
<DropdownMenuContent hidden={hideDropdown}>
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<DropdownItem
|
||||||
|
type="button"
|
||||||
|
href={"/settings/teams/" + team.id + "/profile"}
|
||||||
|
StartIcon={Icon.FiEdit2}>
|
||||||
|
{t("edit_team") as string}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Link href={"/settings/teams/" + team.id + "/profile"}>
|
<DropdownItem
|
||||||
<a>
|
type="button"
|
||||||
<Button
|
target="_blank"
|
||||||
color="minimal"
|
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`}
|
||||||
className="w-full rounded-none font-medium"
|
StartIcon={Icon.FiExternalLink}>
|
||||||
StartIcon={Icon.FiEdit2}>
|
{t("preview_team") as string}
|
||||||
{t("edit_team")}
|
</DropdownItem>
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||||
<DropdownMenuItem>
|
{isOwner && (
|
||||||
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`} passHref={true}>
|
<DropdownMenuItem>
|
||||||
<a target="_blank">
|
<Dialog open={hideDropdown} onOpenChange={setHideDropdown}>
|
||||||
<Button
|
<DialogTrigger asChild>
|
||||||
color="minimal"
|
<Button
|
||||||
className="w-full rounded-none font-medium"
|
onClick={(e) => {
|
||||||
StartIcon={Icon.FiExternalLink}>
|
e.stopPropagation();
|
||||||
{t("preview_team")}
|
}}
|
||||||
</Button>
|
color="destructive"
|
||||||
</a>
|
className="rounded-none px-3 font-normal"
|
||||||
</Link>
|
StartIcon={Icon.FiTrash}>
|
||||||
</DropdownMenuItem>
|
{t("disband_team")}
|
||||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
</Button>
|
||||||
{isOwner && (
|
</DialogTrigger>
|
||||||
<DropdownMenuItem>
|
<ConfirmationDialogContent
|
||||||
<Dialog open={hideDropdown} onOpenChange={setHideDropdown}>
|
variety="danger"
|
||||||
<DialogTrigger asChild>
|
title={t("disband_team")}
|
||||||
<Button
|
confirmBtnText={t("confirm_disband_team")}
|
||||||
onClick={(e) => {
|
isLoading={props.isLoading}
|
||||||
e.stopPropagation();
|
onConfirm={() => {
|
||||||
}}
|
props.onActionSelect("disband");
|
||||||
color="destructive"
|
|
||||||
className="w-full rounded-none font-medium"
|
|
||||||
StartIcon={Icon.FiTrash}>
|
|
||||||
{t("disband_team")}
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<ConfirmationDialogContent
|
|
||||||
variety="danger"
|
|
||||||
title={t("disband_team")}
|
|
||||||
confirmBtnText={t("confirm_disband_team")}
|
|
||||||
isLoading={props.isLoading}
|
|
||||||
onConfirm={() => {
|
|
||||||
props.onActionSelect("disband");
|
|
||||||
}}>
|
|
||||||
{t("disband_team_confirmation_message")}
|
|
||||||
</ConfirmationDialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isOwner && (
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
color="destructive"
|
|
||||||
size="lg"
|
|
||||||
StartIcon={Icon.FiLogOut}
|
|
||||||
className="w-full rounded-none"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}>
|
}}>
|
||||||
{t("leave_team")}
|
{t("disband_team_confirmation_message")}
|
||||||
</Button>
|
</ConfirmationDialogContent>
|
||||||
</DialogTrigger>
|
</Dialog>
|
||||||
<ConfirmationDialogContent
|
</DropdownMenuItem>
|
||||||
variety="danger"
|
)}
|
||||||
title={t("leave_team")}
|
|
||||||
confirmBtnText={t("confirm_leave_team")}
|
{!isOwner && (
|
||||||
onConfirm={declineInvite}>
|
<DropdownMenuItem>
|
||||||
{t("leave_team_confirmation_message")}
|
<Dialog>
|
||||||
</ConfirmationDialogContent>
|
<DialogTrigger asChild>
|
||||||
</Dialog>
|
<Button
|
||||||
</DropdownMenuItem>
|
type="button"
|
||||||
)}
|
color="destructive"
|
||||||
</DropdownMenuContent>
|
size="lg"
|
||||||
</Dropdown>
|
StartIcon={Icon.FiLogOut}
|
||||||
|
className="w-full rounded-none"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}>
|
||||||
|
{t("leave_team")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<ConfirmationDialogContent
|
||||||
|
variety="danger"
|
||||||
|
title={t("leave_team")}
|
||||||
|
confirmBtnText={t("confirm_leave_team")}
|
||||||
|
onConfirm={declineInvite}>
|
||||||
|
{t("leave_team_confirmation_message")}
|
||||||
|
</ConfirmationDialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</Dropdown>
|
||||||
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { objectKeys } from "@calcom/lib/objectKeys";
|
import { objectKeys } from "@calcom/lib/objectKeys";
|
||||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
@@ -9,6 +8,7 @@ import { Alert } from "@calcom/ui/Alert";
|
|||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { TextField } from "@calcom/ui/form/fields";
|
import { TextField } from "@calcom/ui/form/fields";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import ImageUploader from "@components/ImageUploader";
|
import ImageUploader from "@components/ImageUploader";
|
||||||
import SettingInputContainer from "@components/ui/SettingInputContainer";
|
import SettingInputContainer from "@components/ui/SettingInputContainer";
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
} from "@calcom/ui/Dialog";
|
} from "@calcom/ui/Dialog";
|
||||||
|
import showToast from "@calcom/ui/v2/core/notifications";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TeamPageProps } from "pages/team/[slug]";
|
import { TeamPageProps } from "pages/team/[slug]";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
import { Avatar } from "@calcom/ui/v2";
|
import { Avatar } from "@calcom/ui/v2";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
@@ -13,46 +10,46 @@ type TeamType = TeamPageProps["team"];
|
|||||||
type MembersType = TeamType["members"];
|
type MembersType = TeamType["members"];
|
||||||
type MemberType = MembersType[number];
|
type MemberType = MembersType[number];
|
||||||
|
|
||||||
const Team = ({ team }: TeamPageProps) => {
|
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const Member = ({ member }: { member: MemberType }) => {
|
return (
|
||||||
return (
|
<Link key={member.id} href={`/${member.username}`}>
|
||||||
<Link key={member.id} href={`/${member.username}`}>
|
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full w-[90%] flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
|
||||||
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex h-full flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
|
<Avatar
|
||||||
<Avatar
|
size="md"
|
||||||
size="md"
|
alt={member.name || ""}
|
||||||
alt={member.name || ""}
|
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
/>
|
||||||
/>
|
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
||||||
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
<p className="font-medium text-neutral-900 dark:text-white">{member.name}</p>
|
||||||
<p className="font-medium text-neutral-900 dark:text-white">{member.name}</p>
|
<p className="line-clamp-3 overflow-ellipsis text-sm font-normal text-neutral-500 dark:text-white">
|
||||||
<p className="line-clamp-3 overflow-ellipsis text-sm font-normal text-neutral-500 dark:text-white">
|
{member.bio || t("user_from_team", { user: member.name, team: teamName })}
|
||||||
{member.bio || t("user_from_team", { user: member.name, team: team.name })}
|
</p>
|
||||||
</p>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
);
|
||||||
);
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const Members = ({ members }: { members: MembersType }) => {
|
const Members = ({ members, teamName }: { members: MembersType; teamName: string | null }) => {
|
||||||
if (!members || members.length === 0) {
|
if (!members || members.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
|
||||||
{members.map((member) => {
|
|
||||||
return member.username !== null && <Member key={member.id} member={member} />;
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
||||||
|
{members.map((member) => {
|
||||||
|
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Team = ({ team }: TeamPageProps) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Members members={team.members} />
|
<Members members={team.members} teamName={team.name} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,50 +1,48 @@
|
|||||||
import { useState } from "react";
|
import classNames from "classnames";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { ControllerRenderProps } from "react-hook-form";
|
||||||
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
|
|
||||||
const EditableHeading = ({
|
const EditableHeading = function EditableHeading({
|
||||||
title,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = "",
|
isReady,
|
||||||
readOnly = false,
|
...passThroughProps
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
isReady?: boolean;
|
||||||
onChange?: (value: string) => void;
|
} & Omit<JSX.IntrinsicElements["input"], "name" | "onChange"> &
|
||||||
placeholder?: string;
|
ControllerRenderProps) {
|
||||||
readOnly?: boolean;
|
|
||||||
}) => {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const enableEditing = () => !readOnly && setIsEditing(true);
|
const enableEditing = () => setIsEditing(true);
|
||||||
return (
|
return (
|
||||||
<div className="group relative cursor-pointer" onClick={enableEditing}>
|
<div className="group relative cursor-pointer" onClick={enableEditing}>
|
||||||
{!isEditing ? (
|
<div className="flex items-center">
|
||||||
<>
|
<label className="min-w-8 relative inline-block">
|
||||||
<h1
|
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value} </span>
|
||||||
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
|
{!isEditing && isReady && (
|
||||||
className="inline pl-0 normal-case text-gray-900 focus:text-black group-hover:text-gray-500">
|
<Icon.FiEdit2 className="ml-1 inline h-4 w-4 align-top text-gray-700 group-hover:text-gray-500" />
|
||||||
{title}
|
)}
|
||||||
</h1>
|
|
||||||
{!readOnly ? (
|
|
||||||
<Icon.FiEdit2 className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{ marginBottom: -11 }}>
|
|
||||||
<input
|
<input
|
||||||
|
{...passThroughProps}
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
value={value}
|
||||||
style={{ top: -6, fontSize: 22 }}
|
|
||||||
required
|
required
|
||||||
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
|
className={classNames(
|
||||||
placeholder={placeholder}
|
"absolute top-0 left-0 w-full cursor-pointer border-none bg-transparent p-0 align-top text-xl text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
|
||||||
defaultValue={title}
|
)}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setIsEditing(true);
|
||||||
|
passThroughProps.onFocus && passThroughProps.onFocus(e);
|
||||||
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
onChange && onChange(e.target.value);
|
passThroughProps.onBlur && passThroughProps.onBlur(e);
|
||||||
}}
|
}}
|
||||||
|
onChange={(e) => onChange && onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ interface ILinkTextProps {
|
|||||||
href: string;
|
href: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
classNameChildren?: string;
|
classNameChildren?: string;
|
||||||
|
target?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This component had to be made in order to make i18n work with next/link
|
* This component had to be made in order to make i18n work with next/link
|
||||||
* @see https://github.com/i18next/react-i18next/issues/1090#issuecomment-615426145
|
* @see https://github.com/i18next/react-i18next/issues/1090#issuecomment-615426145
|
||||||
**/
|
**/
|
||||||
export const LinkText = (props: ILinkTextProps) => {
|
export const LinkText = (props: ILinkTextProps) => {
|
||||||
const { href, children, classNameChildren, ...moreProps } = props;
|
const { target, href, children, classNameChildren, ...moreProps } = props;
|
||||||
return (
|
return (
|
||||||
<Link href={href || ""} {...moreProps}>
|
<Link href={href || ""} {...moreProps}>
|
||||||
<a className={classNameChildren}>{children}</a>
|
<a target={target || ""} className={classNameChildren}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import React, { FC } from "react";
|
import React, { FC } from "react";
|
||||||
|
|
||||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
import Dropdown, {
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
} from "@calcom/ui/Dropdown";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import Button from "@calcom/ui/v2/core/Button";
|
import Button from "@calcom/ui/v2/core/Button";
|
||||||
|
|
||||||
@@ -43,39 +48,36 @@ const DropdownActions = ({
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenuTrigger asChild>{actionTrigger}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{actionTrigger}</DropdownMenuTrigger>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuContent portalled>
|
<DropdownMenuPortal>
|
||||||
{actions.map((action) => (
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
|
{actions.map((action) => (
|
||||||
<Button
|
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
|
||||||
type="button"
|
<Button
|
||||||
color="minimal"
|
type="button"
|
||||||
className="w-full rounded-none font-normal"
|
color="minimal"
|
||||||
href={action.href}
|
className="w-full rounded-none font-normal"
|
||||||
StartIcon={action.icon}
|
href={action.href}
|
||||||
onClick={action.onClick || defaultAction}
|
StartIcon={action.icon}
|
||||||
data-testid={action.id}>
|
onClick={action.onClick || defaultAction}
|
||||||
{action.label}
|
data-testid={action.id}>
|
||||||
</Button>
|
{action.label}
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
))}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TableActions: FC<Props> = ({ actions }) => {
|
const TableActions: FC<Props> = ({ actions }) => {
|
||||||
const mobileActions = actions.flatMap((action) => {
|
|
||||||
if (action.actions) {
|
|
||||||
return action.actions;
|
|
||||||
}
|
|
||||||
return action;
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hidden space-x-2 rtl:space-x-reverse lg:flex">
|
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const button = (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
|
className="whitespace-nowrap"
|
||||||
key={action.id}
|
key={action.id}
|
||||||
data-testid={action.id}
|
data-testid={action.id}
|
||||||
href={action.href}
|
href={action.href}
|
||||||
@@ -93,9 +95,6 @@ const TableActions: FC<Props> = ({ actions }) => {
|
|||||||
return <DropdownActions key={action.id} actions={action.actions} actionTrigger={button} />;
|
return <DropdownActions key={action.id} actions={action.actions} actionTrigger={button} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-block text-left lg:hidden">
|
|
||||||
<DropdownActions actions={mobileActions} />
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import { MutableRefObject, useCallback, useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { getPremiumPlanMode, getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
import { getPremiumPlanMode, getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||||
|
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { User } from "@calcom/prisma/client";
|
import { User } from "@calcom/prisma/client";
|
||||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
||||||
import { Icon, StarIconSolid } from "@calcom/ui/Icon";
|
import { Icon, StarIconSolid } from "@calcom/ui/Icon";
|
||||||
|
import { Button } from "@calcom/ui/v2";
|
||||||
import { Input, Label } from "@calcom/ui/v2";
|
import { Input, Label } from "@calcom/ui/v2";
|
||||||
|
|
||||||
export enum UsernameChangeStatusEnum {
|
export enum UsernameChangeStatusEnum {
|
||||||
@@ -25,7 +26,7 @@ interface ICustomUsernameProps {
|
|||||||
currentUsername: string | undefined;
|
currentUsername: string | undefined;
|
||||||
setCurrentUsername: (value: string | undefined) => void;
|
setCurrentUsername: (value: string | undefined) => void;
|
||||||
inputUsernameValue: string | undefined;
|
inputUsernameValue: string | undefined;
|
||||||
usernameRef: MutableRefObject<HTMLInputElement>;
|
usernameRef: MutableRefObject<HTMLInputElement | null>;
|
||||||
setInputUsernameValue: (value: string) => void;
|
setInputUsernameValue: (value: string) => void;
|
||||||
onSuccessMutation?: () => void;
|
onSuccessMutation?: () => void;
|
||||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||||
@@ -43,9 +44,8 @@ interface ICustomUsernameProps {
|
|||||||
| "plan"
|
| "plan"
|
||||||
| "brandColor"
|
| "brandColor"
|
||||||
| "darkBrandColor"
|
| "darkBrandColor"
|
||||||
| "metadata"
|
|
||||||
| "timeFormat"
|
| "timeFormat"
|
||||||
| "allowDynamicBooking"
|
| "metadata"
|
||||||
>;
|
>;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}
|
}
|
||||||
@@ -62,24 +62,13 @@ const obtainNewUsernameChangeCondition = ({
|
|||||||
if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) {
|
if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) {
|
||||||
return UsernameChangeStatusEnum.UPGRADE;
|
return UsernameChangeStatusEnum.UPGRADE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") {
|
if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") {
|
||||||
return UsernameChangeStatusEnum.DOWNGRADE;
|
return UsernameChangeStatusEnum.DOWNGRADE;
|
||||||
}
|
}
|
||||||
return UsernameChangeStatusEnum.NORMAL;
|
return UsernameChangeStatusEnum.NORMAL;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useIsUsernamePremium = (username: string) => {
|
|
||||||
const [isCurrentUsernamePremium, setIsCurrentUsernamePremium] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (!username) return;
|
|
||||||
const { data } = await fetchUsername(username);
|
|
||||||
setIsCurrentUsernamePremium(data.premium);
|
|
||||||
})();
|
|
||||||
}, [username]);
|
|
||||||
return isCurrentUsernamePremium;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const {
|
const {
|
||||||
@@ -91,6 +80,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
onSuccessMutation,
|
onSuccessMutation,
|
||||||
onErrorMutation,
|
onErrorMutation,
|
||||||
readonly: disabled,
|
readonly: disabled,
|
||||||
|
user,
|
||||||
} = props;
|
} = props;
|
||||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||||
const [markAsError, setMarkAsError] = useState(false);
|
const [markAsError, setMarkAsError] = useState(false);
|
||||||
@@ -98,13 +88,13 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
const { paymentStatus: recentAttemptPaymentStatus } = router.query;
|
const { paymentStatus: recentAttemptPaymentStatus } = router.query;
|
||||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||||
const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
|
const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
|
||||||
const isCurrentUsernamePremium = useIsUsernamePremium(currentUsername || "");
|
const isCurrentUsernamePremium =
|
||||||
|
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||||
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
|
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
|
||||||
|
|
||||||
const debouncedApiCall = useCallback(
|
const debouncedApiCall = useCallback(
|
||||||
debounce(async (username) => {
|
debounce(async (username: string) => {
|
||||||
const { data } = await fetchUsername(username);
|
const { data } = await fetchUsername(username);
|
||||||
setMarkAsError(!data.available && username !== currentUsername);
|
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
|
||||||
setIsInputUsernamePremium(data.premium);
|
setIsInputUsernamePremium(data.premium);
|
||||||
setUsernameIsAvailable(data.available);
|
setUsernameIsAvailable(data.available);
|
||||||
}, 150),
|
}, 150),
|
||||||
@@ -183,7 +173,9 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentUsername) {
|
if (currentUsername) {
|
||||||
setInputUsernameValue(currentUsername);
|
setInputUsernameValue(currentUsername);
|
||||||
usernameRef.current.value = currentUsername;
|
if (usernameRef.current) {
|
||||||
|
usernameRef.current.value = currentUsername;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
@@ -202,6 +194,20 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let paymentMsg = !currentUsername ? (
|
||||||
|
<span className="text-xs text-orange-400">
|
||||||
|
You need to reserve your premium username for {getPremiumPlanPriceValue()}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid") {
|
||||||
|
paymentMsg = (
|
||||||
|
<span className="text-sm text-red-500">
|
||||||
|
Your payment could not be completed. Your username is still not reserved
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-items-center">
|
<div className="flex justify-items-center">
|
||||||
@@ -211,7 +217,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isInputUsernamePremium ? "border-1 border-orange-400 " : "",
|
isInputUsernamePremium ? "border-1 border-orange-400 " : "",
|
||||||
"hidden items-center rounded-l-md border border-r-0 border-gray-300 border-r-gray-300 bg-gray-50 px-3 text-sm text-gray-500 md:inline-flex"
|
"hidden h-9 items-center rounded-l-md border border-r-0 border-gray-300 border-r-gray-300 bg-gray-50 px-3 text-sm text-gray-500 md:inline-flex"
|
||||||
)}>
|
)}>
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
||||||
</span>
|
</span>
|
||||||
@@ -237,6 +243,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
value={inputUsernameValue}
|
value={inputUsernameValue}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
// Reset payment status
|
||||||
|
delete router.query.paymentStatus;
|
||||||
setInputUsernameValue(event.target.value);
|
setInputUsernameValue(event.target.value);
|
||||||
}}
|
}}
|
||||||
data-testid="username-input"
|
data-testid="username-input"
|
||||||
@@ -248,7 +256,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
isInputUsernamePremium ? "text-orange-400" : "",
|
isInputUsernamePremium ? "text-orange-400" : "",
|
||||||
usernameIsAvailable ? "" : ""
|
usernameIsAvailable ? "" : ""
|
||||||
)}>
|
)}>
|
||||||
{isInputUsernamePremium ? <StarIconSolid className="mt-[4px] w-6" /> : <></>}
|
{isInputUsernamePremium ? <StarIconSolid className="mt-[2px] w-6" /> : <></>}
|
||||||
{!isInputUsernamePremium && usernameIsAvailable ? <Icon.FiCheck className="mt-2 w-6" /> : <></>}
|
{!isInputUsernamePremium && usernameIsAvailable ? <Icon.FiCheck className="mt-2 w-6" /> : <></>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,17 +268,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{paymentRequired ? (
|
{paymentMsg}
|
||||||
recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid" ? (
|
|
||||||
<span className="text-sm text-red-500">
|
|
||||||
Your payment could not be completed. Your username is still not reserved
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-orange-400">
|
|
||||||
You need to reserve your premium username for {getPremiumPlanPriceValue()}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||||
|
|
||||||
{usernameIsAvailable && (
|
{usernameIsAvailable && (
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { Input, Label } from "@calcom/ui/v2";
|
import { Input, Label, Button } from "@calcom/ui/v2";
|
||||||
|
|
||||||
interface ICustomUsernameProps {
|
interface ICustomUsernameProps {
|
||||||
currentUsername: string | undefined;
|
currentUsername: string | undefined;
|
||||||
setCurrentUsername: (value: string | undefined) => void;
|
setCurrentUsername: (value: string | undefined) => void;
|
||||||
inputUsernameValue: string | undefined;
|
inputUsernameValue: string | undefined;
|
||||||
usernameRef: MutableRefObject<HTMLInputElement>;
|
usernameRef: MutableRefObject<HTMLInputElement | null>;
|
||||||
setInputUsernameValue: (value: string) => void;
|
setInputUsernameValue: (value: string) => void;
|
||||||
onSuccessMutation?: () => void;
|
onSuccessMutation?: () => void;
|
||||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||||
@@ -73,15 +72,14 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ActionButtons = (props: { index: string }) => {
|
const ActionButtons = () => {
|
||||||
const { index } = props;
|
|
||||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="mx-2"
|
className="mx-2"
|
||||||
onClick={() => setOpenDialogSaveUsername(true)}
|
onClick={() => setOpenDialogSaveUsername(true)}
|
||||||
data-testid={`update-username-btn-${index}`}>
|
data-testid="update-username-btn">
|
||||||
{t("update")}
|
{t("update")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -91,7 +89,9 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentUsername) {
|
if (currentUsername) {
|
||||||
setInputUsernameValue(currentUsername);
|
setInputUsernameValue(currentUsername);
|
||||||
usernameRef.current.value = currentUsername;
|
if (usernameRef.current) {
|
||||||
|
usernameRef.current.value = currentUsername;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
@@ -122,7 +122,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="none"
|
autoCorrect="none"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mb-0 mt-0 rounded-md rounded-l-none",
|
"mb-0 mt-0 h-6 rounded-md rounded-l-none",
|
||||||
markAsError
|
markAsError
|
||||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||||
: ""
|
: ""
|
||||||
@@ -137,20 +137,20 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||||||
{currentUsername !== inputUsernameValue && (
|
{currentUsername !== inputUsernameValue && (
|
||||||
<div className="absolute right-[2px] top-0 flex flex-row">
|
<div className="absolute right-[2px] top-0 flex flex-row">
|
||||||
<span className={classNames("mx-2 py-2")}>
|
<span className={classNames("mx-2 py-2")}>
|
||||||
{usernameIsAvailable ? <Icon.FiCheck className="mt-[4px] w-6" /> : <></>}
|
{usernameIsAvailable ? <Icon.FiCheck className="mt-[2px] w-6" /> : <></>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:inline">
|
<div className="hidden md:inline">
|
||||||
<ActionButtons index="desktop" />
|
<ActionButtons />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||||
|
|
||||||
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
|
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
|
||||||
<div className="mt-2 flex justify-end sm:hidden">
|
<div className="mt-2 flex justify-end md:hidden">
|
||||||
<ActionButtons index="mobile" />
|
<ActionButtons />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Dialog open={openDialogSaveUsername}>
|
<Dialog open={openDialogSaveUsername}>
|
||||||
|
|||||||
@@ -1,326 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import React, { useEffect, 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/v2/LicenseRequired";
|
|
||||||
import classNames from "@calcom/lib/classNames";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import { App as AppType } from "@calcom/types/App";
|
|
||||||
import Badge from "@calcom/ui/Badge";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
import { showToast } from "@calcom/ui/v2";
|
|
||||||
import { Button, SkeletonButton, Shell } from "@calcom/ui/v2";
|
|
||||||
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
|
|
||||||
|
|
||||||
const Component = ({
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
logo,
|
|
||||||
slug,
|
|
||||||
variant,
|
|
||||||
body,
|
|
||||||
categories,
|
|
||||||
author,
|
|
||||||
price = 0,
|
|
||||||
commission,
|
|
||||||
isGlobal = false,
|
|
||||||
feeType,
|
|
||||||
docs,
|
|
||||||
website,
|
|
||||||
email,
|
|
||||||
tos,
|
|
||||||
privacy,
|
|
||||||
isProOnly,
|
|
||||||
images,
|
|
||||||
}: Parameters<typeof App>[0]) => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
|
||||||
const hasImages = images && images.length > 0;
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const mutation = useAddAppMutation(null, {
|
|
||||||
onSuccess: () => {
|
|
||||||
showToast(t("app_successfully_installed"), "success");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (error instanceof Error) showToast(error.message || t("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="px-4 pb-3 pt-3 md:px-8 lg:px-0 lg:pt-0">
|
|
||||||
<Link href="/apps">
|
|
||||||
<a className="text-md hover:text-brand-400 inline-flex rounded-sm py-2 font-semibold text-gray-900">
|
|
||||||
<Icon.FiArrowLeft className="mr-2 h-6 w-6" /> {t("app_store")}
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
|
||||||
{hasImages && (
|
|
||||||
<div className="flex-2 mb-4 -ml-4 -mr-4 flex w-auto snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:rounded-md">
|
|
||||||
{images &&
|
|
||||||
images.map((img) => (
|
|
||||||
<img
|
|
||||||
key={img}
|
|
||||||
src={img}
|
|
||||||
alt={`Screenshot of app ${name}`}
|
|
||||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"sticky top-0 -mt-4 max-w-xl flex-1 pb-12 text-sm lg:pb-0",
|
|
||||||
hasImages && "lg:ml-8"
|
|
||||||
)}>
|
|
||||||
<div className="mb-8 flex pt-4">
|
|
||||||
<header>
|
|
||||||
<div className="mb-4 flex items-center">
|
|
||||||
<img className="min-h-16 min-w-16 h-16 w-16" src={logo} alt={name} />
|
|
||||||
<h1 className="font-cal ml-4 text-3xl text-gray-900">{name}</h1>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-600">
|
|
||||||
<Link href={`categories/${categories[0]}`}>
|
|
||||||
<a className="rounded-md bg-gray-100 p-1 text-xs capitalize text-gray-800">
|
|
||||||
{categories[0]}
|
|
||||||
</a>
|
|
||||||
</Link>{" "}
|
|
||||||
• {t("published_by", { author })}
|
|
||||||
</h2>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
{!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("default")}
|
|
||||||
</Button>
|
|
||||||
{!isGlobal && (
|
|
||||||
<InstallAppButton
|
|
||||||
type={type}
|
|
||||||
isProOnly={isProOnly}
|
|
||||||
render={({ useDefaultComponent, ...props }) => {
|
|
||||||
if (useDefaultComponent) {
|
|
||||||
props = {
|
|
||||||
onClick: () => {
|
|
||||||
mutation.mutate({ type, variant, slug });
|
|
||||||
},
|
|
||||||
loading: mutation.isLoading,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
StartIcon={Icon.FiPlus}
|
|
||||||
{...props}
|
|
||||||
// @TODO: Overriding color and size prevent us from
|
|
||||||
// having to duplicate InstallAppButton for now.
|
|
||||||
color="primary"
|
|
||||||
size="base"
|
|
||||||
data-testid="install-app-button">
|
|
||||||
{t("install_another")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : existingCredentials.length > 0 ? (
|
|
||||||
<DisconnectIntegration
|
|
||||||
label={t("disconnect")}
|
|
||||||
credentialId={existingCredentials[0]}
|
|
||||||
onSuccess={() => {
|
|
||||||
router.replace("/apps/installed");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<InstallAppButton
|
|
||||||
type={type}
|
|
||||||
isProOnly={isProOnly}
|
|
||||||
render={({ useDefaultComponent, ...props }) => {
|
|
||||||
if (useDefaultComponent) {
|
|
||||||
props = {
|
|
||||||
onClick: () => {
|
|
||||||
mutation.mutate({ type, variant, slug });
|
|
||||||
},
|
|
||||||
loading: mutation.isLoading,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
data-testid="install-app-button"
|
|
||||||
{...props}
|
|
||||||
// @TODO: Overriding color and size prevent us from
|
|
||||||
// having to duplicate InstallAppButton for now.
|
|
||||||
color="primary"
|
|
||||||
size="base">
|
|
||||||
{t("install_app")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<SkeletonButton className="h-10 w-24" />
|
|
||||||
)}
|
|
||||||
{price !== 0 && (
|
|
||||||
<span className="block text-right">
|
|
||||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
|
||||||
{feeType === "monthly" && "/" + t("month")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="prose prose-sm mt-8 space-x-2">{body}</div>
|
|
||||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
|
||||||
<span>
|
|
||||||
{price === 0 ? (
|
|
||||||
"Free"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
useGrouping: false,
|
|
||||||
}).format(price)}
|
|
||||||
{feeType === "monthly" && "/" + t("month")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<h4 className="mt-8 mb-2 font-semibold text-gray-900 ">{t("learn_more")}</h4>
|
|
||||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
|
||||||
{docs && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-sm font-normal text-black no-underline hover:underline"
|
|
||||||
href={docs}>
|
|
||||||
<Icon.FiBookOpen className="mr-1 -mt-1 inline h-4 w-4 text-gray-500" />
|
|
||||||
{t("documentation")}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{website && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="font-normal text-black no-underline hover:underline"
|
|
||||||
href={website}>
|
|
||||||
<Icon.FiExternalLink className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
|
||||||
{website.replace("https://", "")}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{email && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="font-normal text-black no-underline hover:underline"
|
|
||||||
href={"mailto:" + email}>
|
|
||||||
<Icon.FiMail className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
|
||||||
|
|
||||||
{email}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{tos && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="font-normal text-black no-underline hover:underline"
|
|
||||||
href={tos}>
|
|
||||||
<Icon.FiFile className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
|
||||||
{t("terms_of_service")}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{privacy && (
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="font-normal text-black no-underline hover:underline"
|
|
||||||
href={privacy}>
|
|
||||||
<Icon.FiShield className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
|
||||||
{t("privacy_policy")}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
<hr className="my-8" />
|
|
||||||
<span className="leading-1 block text-xs 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.
|
|
||||||
</span>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App(props: {
|
|
||||||
name: string;
|
|
||||||
type: AppType["type"];
|
|
||||||
isGlobal?: AppType["isGlobal"];
|
|
||||||
logo: string;
|
|
||||||
slug: string;
|
|
||||||
variant: 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"];
|
|
||||||
images?: string[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Shell large isPublic>
|
|
||||||
{props.licenseRequired ? (
|
|
||||||
<LicenseRequired>
|
|
||||||
<Component {...props} />
|
|
||||||
</LicenseRequired>
|
|
||||||
) : (
|
|
||||||
<Component {...props} />
|
|
||||||
)}
|
|
||||||
</Shell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { SkeletonText } from "@calcom/ui/v2";
|
import { SkeletonText } from "@calcom/ui/v2";
|
||||||
@@ -32,9 +33,13 @@ function SkeletonItem() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AvailabilitySelectSkeletonLoader = () => {
|
export const SelectSkeletonLoader = ({ className }: { className?: string }) => {
|
||||||
return (
|
return (
|
||||||
<li className="group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3">
|
<li
|
||||||
|
className={classNames(
|
||||||
|
"group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
<div className="flex-grow truncate text-sm">
|
<div className="flex-grow truncate text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<SkeletonText className="h-4 w-32" />
|
<SkeletonText className="h-4 w-32" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FormValues } from "pages/v2/event-types/[type]";
|
import { FormValues } from "pages/event-types/[type]";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
import { SingleValueProps, OptionProps, components } from "react-select";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
@@ -7,15 +8,45 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { weekdayNames } from "@calcom/lib/weekday";
|
import { weekdayNames } from "@calcom/lib/weekday";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui";
|
import { Icon } from "@calcom/ui";
|
||||||
|
import { Badge } from "@calcom/ui/v2";
|
||||||
import Button from "@calcom/ui/v2/core/Button";
|
import Button from "@calcom/ui/v2/core/Button";
|
||||||
import Select from "@calcom/ui/v2/core/form/Select";
|
import Select from "@calcom/ui/v2/core/form/select";
|
||||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||||
|
|
||||||
import { AvailabilitySelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
|
import { SelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
|
||||||
|
|
||||||
type AvailabilityOption = {
|
type AvailabilityOption = {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||||
|
const { label, isDefault } = props.data;
|
||||||
|
return (
|
||||||
|
<components.Option {...props}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{isDefault && (
|
||||||
|
<Badge variant="blue" className="ml-2">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</components.Option>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||||
|
const { label, isDefault } = props.data;
|
||||||
|
return (
|
||||||
|
<components.SingleValue {...props}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{isDefault && (
|
||||||
|
<Badge variant="blue" className="ml-2">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</components.SingleValue>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AvailabilitySelect = ({
|
const AvailabilitySelect = ({
|
||||||
@@ -30,7 +61,7 @@ const AvailabilitySelect = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading } = trpc.useQuery(["viewer.availability.list"]);
|
const { data, isLoading } = trpc.useQuery(["viewer.availability.list"]);
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <AvailabilitySelectSkeletonLoader />;
|
return <SelectSkeletonLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedules = data?.schedules || [];
|
const schedules = data?.schedules || [];
|
||||||
@@ -38,6 +69,7 @@ const AvailabilitySelect = ({
|
|||||||
const options = schedules.map((schedule) => ({
|
const options = schedules.map((schedule) => ({
|
||||||
value: schedule.id,
|
value: schedule.id,
|
||||||
label: schedule.name,
|
label: schedule.name,
|
||||||
|
isDefault: schedule.isDefault,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const value = options.find((option) =>
|
const value = options.find((option) =>
|
||||||
@@ -53,6 +85,8 @@ const AvailabilitySelect = ({
|
|||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
className={classNames("block w-full min-w-0 flex-1 rounded-sm text-sm", className)}
|
className={classNames("block w-full min-w-0 flex-1 rounded-sm text-sm", className)}
|
||||||
value={value}
|
value={value}
|
||||||
|
components={{ Option, SingleValue }}
|
||||||
|
isMulti={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -95,45 +129,50 @@ export const AvailabilityTab = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 rounded border p-8 py-6 pt-2">
|
<div className="space-y-4 rounded border p-4 py-6 pt-2 md:p-8">
|
||||||
<ol className="table border-collapse text-sm">
|
<ol className="table border-collapse text-sm">
|
||||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||||
const isAvailable = !!filterDays(index).length;
|
const isAvailable = !!filterDays(index).length;
|
||||||
return (
|
return (
|
||||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||||
<span className={classNames("w-32 font-medium", !isAvailable && "text-gray-500 opacity-50")}>
|
<span
|
||||||
|
className={classNames(
|
||||||
|
"w-20 font-medium sm:w-32",
|
||||||
|
!isAvailable && "text-gray-500 opacity-50"
|
||||||
|
)}>
|
||||||
{day}
|
{day}
|
||||||
</span>
|
</span>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<SkeletonText className="block h-5 w-60" />
|
<SkeletonText className="block h-5 w-60" />
|
||||||
) : isAvailable ? (
|
) : isAvailable ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 text-right">
|
||||||
{filterDays(index).map((dayRange, i) => (
|
{filterDays(index).map((dayRange, i) => (
|
||||||
<div key={i} className="flex items-center leading-4">
|
<div key={i} className="flex items-center leading-4">
|
||||||
<span className="w-28">{format(dayRange.startTime)}</span>
|
<span className="w-16 sm:w-28 sm:text-left">{format(dayRange.startTime)}</span>
|
||||||
<span className="">-</span>
|
<span className="ml-4">-</span>
|
||||||
<div className="ml-6">{format(dayRange.endTime)}</div>
|
<div className="ml-6">{format(dayRange.endTime)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500 opacity-50 ">{t("unavailable")}</span>
|
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
<hr />
|
<hr />
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||||
<span className="flex items-center text-sm text-gray-600">
|
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||||
<Icon.FiGlobe className="mr-2" />
|
<Icon.FiGlobe className="mr-2" />
|
||||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
href={`/availability/${scheduleId}`}
|
href={`/availability/${schedule?.schedule.id}`}
|
||||||
color="minimal"
|
color="minimal"
|
||||||
EndIcon={Icon.FiExternalLink}
|
EndIcon={Icon.FiExternalLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
className="justify-center border sm:border-0"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
{t("edit_availability")}
|
{t("edit_availability")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ErrorMessage } from "@hookform/error-message";
|
import { ErrorMessage } from "@hookform/error-message";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -24,6 +25,7 @@ import { Select } from "@calcom/ui/v2/";
|
|||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
|
import { LinkText } from "@components/ui/LinkText";
|
||||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
|
|
||||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||||
@@ -308,6 +310,17 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{selectedLocation && LocationOptions}
|
{selectedLocation && LocationOptions}
|
||||||
|
<div className="mt-5">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||||
|
Can't find the right video app? Visit our
|
||||||
|
<LinkText href="/apps/categories/video" classNameChildren="text-blue-400" target="_blank">
|
||||||
|
App Store
|
||||||
|
</LinkText>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="mt-4 flex justify-end space-x-2">
|
<div className="mt-4 flex justify-end space-x-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import autoAnimate from "@formkit/auto-animate";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { EventTypeCustomInput } from "@prisma/client/";
|
import { EventTypeCustomInput } from "@prisma/client/";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
|
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
@@ -14,15 +15,17 @@ import { Icon } from "@calcom/ui";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
CustomInputItem,
|
CustomInputItem,
|
||||||
DestinationCalendarSelector,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
Label,
|
Label,
|
||||||
|
SettingsToggle,
|
||||||
showToast,
|
showToast,
|
||||||
|
Skeleton,
|
||||||
Switch,
|
Switch,
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@calcom/ui/v2";
|
} from "@calcom/ui/v2";
|
||||||
|
import CheckboxField from "@calcom/ui/v2/core/form/Checkbox";
|
||||||
|
|
||||||
import CustomInputTypeForm from "@components/v2/eventtype/CustomInputTypeForm";
|
import CustomInputTypeForm from "@components/v2/eventtype/CustomInputTypeForm";
|
||||||
|
|
||||||
@@ -37,12 +40,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
const connectedCalendarsQuery = trpc.useQuery(["viewer.connectedCalendars"]);
|
const connectedCalendarsQuery = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
|
||||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||||
|
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||||
const [seatsInputVisible, setSeatsInputVisible] = useState(!!eventType.seatsPerTimeSlot);
|
|
||||||
const [seatsPerTimeSlot, setSeatsPerTimeSlot] = useState(eventType.seatsPerTimeSlot);
|
|
||||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||||
);
|
);
|
||||||
@@ -50,12 +51,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||||
|
|
||||||
const animationRef = useRef(null);
|
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||||
const seatsEnabled = !!eventType.seatsPerTimeSlot;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
animationRef.current && autoAnimate(animationRef.current);
|
|
||||||
}, [animationRef]);
|
|
||||||
|
|
||||||
const removeCustom = (index: number) => {
|
const removeCustom = (index: number) => {
|
||||||
formMethods.getValues("customInputs").splice(index, 1);
|
formMethods.getValues("customInputs").splice(index, 1);
|
||||||
@@ -122,76 +118,61 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<div className="">
|
<div className="">
|
||||||
<div className="flex space-x-3 ">
|
<SettingsToggle
|
||||||
<Switch
|
title={t("additional_inputs")}
|
||||||
checked={customInputs.length > 0}
|
description={t("additional_input_description")}
|
||||||
fitToHeight={true}
|
checked={customInputs.length > 0}
|
||||||
onCheckedChange={(e) => {
|
onCheckedChange={(e) => {
|
||||||
if (e && customInputs.length === 0) {
|
if (e && customInputs.length === 0) {
|
||||||
// Push a placeholders
|
// Push a placeholders
|
||||||
setSelectedCustomInput(undefined);
|
|
||||||
setSelectedCustomInputModalOpen(true);
|
|
||||||
} else if (!e) {
|
|
||||||
setCustomInputs([]);
|
|
||||||
formMethods.setValue("customInputs", []);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-semibold leading-none text-black">{t("additional_inputs")}</Label>
|
|
||||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("additional_input_description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className="my-4" ref={animationRef}>
|
|
||||||
{customInputs.map((customInput: EventTypeCustomInput, idx: number) => (
|
|
||||||
<CustomInputItem
|
|
||||||
key={idx}
|
|
||||||
question={customInput.label}
|
|
||||||
type={customInput.type}
|
|
||||||
required={customInput.required}
|
|
||||||
editOnClick={() => {
|
|
||||||
setSelectedCustomInput(customInput);
|
|
||||||
setSelectedCustomInputModalOpen(true);
|
|
||||||
}}
|
|
||||||
deleteOnClick={() => removeCustom(idx)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{customInputs.length > 0 && (
|
|
||||||
<Button
|
|
||||||
StartIcon={Icon.FiPlus}
|
|
||||||
color="minimal"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCustomInput(undefined);
|
setSelectedCustomInput(undefined);
|
||||||
setSelectedCustomInputModalOpen(true);
|
setSelectedCustomInputModalOpen(true);
|
||||||
}}>
|
} else if (!e) {
|
||||||
Add an input
|
setCustomInputs([]);
|
||||||
</Button>
|
formMethods.setValue("customInputs", []);
|
||||||
)}
|
}
|
||||||
|
}}>
|
||||||
|
<ul className="my-4">
|
||||||
|
{customInputs.map((customInput: EventTypeCustomInput, idx: number) => (
|
||||||
|
<CustomInputItem
|
||||||
|
key={idx}
|
||||||
|
question={customInput.label}
|
||||||
|
type={customInput.type}
|
||||||
|
required={customInput.required}
|
||||||
|
editOnClick={() => {
|
||||||
|
setSelectedCustomInput(customInput);
|
||||||
|
setSelectedCustomInputModalOpen(true);
|
||||||
|
}}
|
||||||
|
deleteOnClick={() => removeCustom(idx)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{customInputs.length > 0 && (
|
||||||
|
<Button
|
||||||
|
StartIcon={Icon.FiPlus}
|
||||||
|
color="minimal"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCustomInput(undefined);
|
||||||
|
setSelectedCustomInputModalOpen(true);
|
||||||
|
}}>
|
||||||
|
Add an input
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SettingsToggle>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<Controller
|
<Controller
|
||||||
name="requiresConfirmation"
|
name="requiresConfirmation"
|
||||||
defaultValue={eventType.requiresConfirmation}
|
defaultValue={eventType.requiresConfirmation}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<div className="flex space-x-3">
|
<SettingsToggle
|
||||||
<Switch
|
title={t("requires_confirmation")}
|
||||||
name="requireConfirmation"
|
description={t("requires_confirmation_description")}
|
||||||
checked={value}
|
checked={value}
|
||||||
onCheckedChange={(e) => onChange(e)}
|
onCheckedChange={(e) => onChange(e)}
|
||||||
disabled={seatsEnabled}
|
disabled={seatsEnabled}
|
||||||
fitToHeight={true}
|
/>
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-semibold leading-none text-black">
|
|
||||||
{t("requires_confirmation")}
|
|
||||||
</Label>
|
|
||||||
<p className="-mt-2 text-sm leading-normal text-gray-600">
|
|
||||||
{t("requires_confirmation_description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
@@ -200,19 +181,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.disableGuests}
|
defaultValue={eventType.disableGuests}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<div className="flex space-x-3 ">
|
<SettingsToggle
|
||||||
<Switch
|
title={t("disable_guests")}
|
||||||
name="disableGuests"
|
description={t("disable_guests_description")}
|
||||||
fitToHeight={true}
|
checked={value}
|
||||||
checked={value}
|
onCheckedChange={(e) => onChange(e)}
|
||||||
onCheckedChange={(e) => onChange(e)}
|
disabled={seatsEnabled}
|
||||||
disabled={seatsEnabled}
|
/>
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-semibold leading-none text-black">{t("disable_guests")}</Label>
|
|
||||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("disable_guests_description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -221,120 +196,158 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
name="hideCalendarNotes"
|
name="hideCalendarNotes"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={eventType.hideCalendarNotes}
|
defaultValue={eventType.hideCalendarNotes}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<SettingsToggle
|
||||||
|
title={t("disable_notes")}
|
||||||
|
description={t("disable_notes_description")}
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={(e) => onChange(e)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<hr />
|
||||||
|
<Controller
|
||||||
|
name="metadata.additionalNotesRequired"
|
||||||
|
control={formMethods.control}
|
||||||
|
defaultValue={!!eventType.metadata.additionalNotesRequired}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<div className="flex space-x-3 ">
|
<div className="flex space-x-3 ">
|
||||||
<Switch
|
<SettingsToggle
|
||||||
name="hideCalendarNotes"
|
title={t("require_additional_notes")}
|
||||||
fitToHeight={true}
|
description={t("require_additional_notes_description")}
|
||||||
checked={value}
|
checked={!!value}
|
||||||
onCheckedChange={(e) => onChange(e)}
|
onCheckedChange={(e) => onChange(e)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-semibold leading-none text-black">{t("disable_notes")}</Label>
|
|
||||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("disable_notes_description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<Controller
|
<Controller
|
||||||
name="hashedLink"
|
name="successRedirectUrl"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
defaultValue={hashedUrl}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
<div className="flex space-x-3 ">
|
<SettingsToggle
|
||||||
<Switch
|
title={t("redirect_success_booking")}
|
||||||
data-testid="hashedLinkCheck"
|
description={t("redirect_url_description")}
|
||||||
name="hashedLinkCheck"
|
checked={redirectUrlVisible}
|
||||||
fitToHeight={true}
|
onCheckedChange={(e) => {
|
||||||
defaultChecked={!!value}
|
setRedirectUrlVisible(e);
|
||||||
onCheckedChange={(e) => {
|
onChange(e ? value : "");
|
||||||
setHashedLinkVisible(e);
|
}}>
|
||||||
onChange(e ? hashedUrl : undefined);
|
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
||||||
}}
|
<div className="lg:-ml-2">
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-semibold leading-none text-black">{t("private_link")}</Label>
|
|
||||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("private_link_description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hashedLinkVisible && (
|
|
||||||
<div className="">
|
|
||||||
<TextField
|
<TextField
|
||||||
disabled
|
label={t("redirect_success_booking")}
|
||||||
name="hashedLink"
|
labelSrOnly
|
||||||
label={t("private_link_label")}
|
placeholder={t("external_redirect_url")}
|
||||||
data-testid="generated-hash-url"
|
required={redirectUrlVisible}
|
||||||
type="text"
|
type="text"
|
||||||
hint={t("private_link_hint")}
|
defaultValue={eventType.successRedirectUrl || ""}
|
||||||
defaultValue={placeholderHashedLink}
|
{...formMethods.register("successRedirectUrl")}
|
||||||
addOnSuffix={
|
|
||||||
<Tooltip
|
|
||||||
content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
|
|
||||||
<Button
|
|
||||||
color="minimal"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(placeholderHashedLink);
|
|
||||||
if (eventType.hashedLink) {
|
|
||||||
showToast(t("private_link_copied"), "success");
|
|
||||||
} else {
|
|
||||||
showToast(t("enabled_after_update_description"), "warning");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hover:stroke-3 hover:bg-transparent hover:text-black"
|
|
||||||
type="button">
|
|
||||||
<Icon.FiCopy />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</SettingsToggle>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<Controller
|
|
||||||
name="seatsPerTimeSlot"
|
<SettingsToggle
|
||||||
control={formMethods.control}
|
data-testid="hashedLinkCheck"
|
||||||
defaultValue={seatsPerTimeSlot}
|
title={t("private_link")}
|
||||||
render={({ field: { value, onChange } }) => (
|
description={t("private_link_description")}
|
||||||
<>
|
checked={hashedLinkVisible}
|
||||||
<div className="flex space-x-3">
|
onCheckedChange={(e) => {
|
||||||
<Switch
|
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||||
name="seatsPerTimeSlot"
|
setHashedLinkVisible(e);
|
||||||
checked={seatsInputVisible}
|
}}>
|
||||||
onCheckedChange={(e) => {
|
{/* Textfield has some margin by default we remove that so we can keep consitant aligment */}
|
||||||
setSeatsInputVisible(e);
|
<div className="lg:-ml-2">
|
||||||
onChange(e ? seatsPerTimeSlot : null);
|
<TextField
|
||||||
}}
|
disabled
|
||||||
fitToHeight={true}
|
name="hashedLink"
|
||||||
/>
|
label={t("private_link_label")}
|
||||||
<div className="flex flex-col">
|
data-testid="generated-hash-url"
|
||||||
<Label className="text-sm font-semibold leading-none text-black">{t("offer_seats")}</Label>
|
labelSrOnly
|
||||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("offer_seats_description")}</p>
|
type="text"
|
||||||
</div>
|
hint={t("private_link_hint")}
|
||||||
</div>
|
defaultValue={placeholderHashedLink}
|
||||||
{seatsInputVisible && (
|
addOnSuffix={
|
||||||
<div className="">
|
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
|
||||||
<TextField
|
<Button
|
||||||
required
|
color="minimal"
|
||||||
name="seatsPerTimeSlot"
|
onClick={() => {
|
||||||
label={t("number_of_seats")}
|
navigator.clipboard.writeText(placeholderHashedLink);
|
||||||
type="number"
|
if (eventType.hashedLink) {
|
||||||
defaultValue={seatsPerTimeSlot || 2}
|
showToast(t("private_link_copied"), "success");
|
||||||
addOnSuffix={<>{t("seats")}</>}
|
} else {
|
||||||
onChange={(e) => {
|
showToast(t("enabled_after_update_description"), "warning");
|
||||||
formMethods.setValue("seatsPerTimeSlot", Number(e.target.value));
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
className="hover:stroke-3 hover:bg-transparent hover:text-black"
|
||||||
</div>
|
type="button">
|
||||||
)}
|
<Icon.FiCopy />
|
||||||
</>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsToggle>
|
||||||
|
<hr />
|
||||||
|
<Controller
|
||||||
|
name="seatsPerTimeSlotEnabled"
|
||||||
|
control={formMethods.control}
|
||||||
|
defaultValue={!!eventType.seatsPerTimeSlot}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<SettingsToggle
|
||||||
|
title={t("offer_seats")}
|
||||||
|
description={t("offer_seats_description")}
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={(e) => {
|
||||||
|
// Enabling seats will disable guests and requiring confimation until fully supported
|
||||||
|
if (e) {
|
||||||
|
formMethods.setValue("disableGuests", true);
|
||||||
|
formMethods.setValue("requiresConfirmation", false);
|
||||||
|
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||||
|
} else {
|
||||||
|
formMethods.setValue("seatsPerTimeSlot", null);
|
||||||
|
formMethods.setValue("disableGuests", false);
|
||||||
|
}
|
||||||
|
onChange(e);
|
||||||
|
}}>
|
||||||
|
<Controller
|
||||||
|
name="seatsPerTimeSlot"
|
||||||
|
control={formMethods.control}
|
||||||
|
defaultValue={eventType.seatsPerTimeSlot}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<div className="lg:-ml-2">
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
name="seatsPerTimeSlot"
|
||||||
|
labelSrOnly
|
||||||
|
label={t("number_of_seats")}
|
||||||
|
type="number"
|
||||||
|
defaultValue={value || 2}
|
||||||
|
addOnSuffix={<>{t("seats")}</>}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(Number(e.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CheckboxField
|
||||||
|
description={t("show_attendees")}
|
||||||
|
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||||
|
defaultChecked={!!eventType.seatsShowAttendees}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsToggle>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showEventNameTip && (
|
{showEventNameTip && (
|
||||||
<Dialog open={showEventNameTip} onOpenChange={setShowEventNameTip}>
|
<Dialog open={showEventNameTip} onOpenChange={setShowEventNameTip}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
|||||||
@@ -1,208 +1,134 @@
|
|||||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
import { Controller } from "react-hook-form";
|
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
|
||||||
|
|
||||||
import { SelectGifInput } from "@calcom/app-store/giphy/components";
|
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||||
import RainbowInstallForm from "@calcom/app-store/rainbow/components/RainbowInstallForm";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||||
import { Icon } from "@calcom/ui";
|
import { Icon } from "@calcom/ui";
|
||||||
import { Alert, Button, EmptyScreen, Select, Switch, TextField } from "@calcom/ui/v2";
|
import ErrorBoundary from "@calcom/ui/ErrorBoundary";
|
||||||
|
import { Button, EmptyScreen } from "@calcom/ui/v2";
|
||||||
|
|
||||||
const AppCard = ({
|
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
|
||||||
logo,
|
EventTypeAppCardComponentProps["eventType"];
|
||||||
name,
|
|
||||||
description,
|
|
||||||
switchOnClick,
|
|
||||||
switchChecked,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
logo: string;
|
|
||||||
name: string;
|
|
||||||
description: React.ReactNode;
|
|
||||||
switchChecked?: boolean;
|
|
||||||
switchOnClick?: (e: boolean) => void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="mb-4 rounded-md border border-gray-200 p-8">
|
|
||||||
<div className="flex w-full">
|
|
||||||
<img src={logo} alt={name} className="mr-3 h-auto w-[42px] rounded-sm" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-semibold leading-none text-black">{name}</span>
|
|
||||||
<p className="pt-2 text-sm font-normal text-gray-600">{description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex items-center">
|
|
||||||
<Switch onCheckedChange={switchOnClick} checked={switchChecked} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EventAppsTab = ({
|
function AppCardWrapper({
|
||||||
hasPaymentIntegration,
|
app,
|
||||||
currency,
|
|
||||||
eventType,
|
eventType,
|
||||||
hasGiphyIntegration,
|
getAppData,
|
||||||
hasRainbowIntegration,
|
setAppData,
|
||||||
}: Pick<
|
}: {
|
||||||
EventTypeSetupInfered,
|
app: inferQueryOutput<"viewer.apps">[number];
|
||||||
"eventType" | "hasPaymentIntegration" | "hasGiphyIntegration" | "hasRainbowIntegration" | "currency"
|
eventType: EventType;
|
||||||
>) => {
|
getAppData: GetAppData;
|
||||||
const formMethods = useFormContext<FormValues>();
|
setAppData: SetAppData;
|
||||||
const [showGifSelection, setShowGifSelection] = useState(
|
}) {
|
||||||
hasGiphyIntegration && !!eventType.metadata["giphyThankYouPage"]
|
const dirName = app.slug === "stripe" ? "stripepayment" : app.slug;
|
||||||
);
|
const Component = EventTypeAddonMap[dirName as keyof typeof EventTypeAddonMap];
|
||||||
const [showRainbowSection, setShowRainbowSection] = useState(
|
|
||||||
hasRainbowIntegration &&
|
|
||||||
!!eventType.metadata["blockchainId"] &&
|
|
||||||
!!eventType.metadata["smartContractAddress"]
|
|
||||||
);
|
|
||||||
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
|
||||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
|
||||||
|
|
||||||
const getCurrencySymbol = (locale: string, currency: string) =>
|
if (!Component) {
|
||||||
(0)
|
throw new Error('No component found for "' + dirName + '"');
|
||||||
.toLocaleString(locale, {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
})
|
|
||||||
.replace(/\d/g, "")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const { t } = useLocale();
|
|
||||||
|
|
||||||
const installedApps = [hasPaymentIntegration, hasGiphyIntegration].filter(Boolean).length;
|
|
||||||
|
|
||||||
if (installedApps === 0) {
|
|
||||||
return (
|
|
||||||
<div className="pt-4 before:border-0">
|
|
||||||
<EmptyScreen
|
|
||||||
Icon={Icon.FiGrid}
|
|
||||||
headline={t("empty_installed_apps_headline")}
|
|
||||||
description={t("empty_installed_apps_description")}
|
|
||||||
buttonRaw={
|
|
||||||
<Button target="_blank" color="secondary" href="/apps">
|
|
||||||
{t("empty_installed_apps_button")}{" "}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="pt-4 before:border-0">
|
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
|
||||||
{/* TODO:Strip isnt fully setup yet */}
|
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
|
||||||
{hasPaymentIntegration && (
|
<Component key={app.slug} app={app} eventType={eventType} />
|
||||||
<AppCard
|
</EventTypeAppContext.Provider>
|
||||||
name="Stripe"
|
</ErrorBoundary>
|
||||||
switchChecked={requirePayment}
|
);
|
||||||
switchOnClick={(e) => {
|
}
|
||||||
if (!e) {
|
|
||||||
formMethods.setValue("price", 0);
|
|
||||||
setRequirePayment(false);
|
|
||||||
} else {
|
|
||||||
setRequirePayment(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
<div className="">
|
|
||||||
{t("require_payment")} (0.5% +{" "}
|
|
||||||
<IntlProvider locale="en">
|
|
||||||
<FormattedNumber value={0.1} style="currency" currency={currency} />
|
|
||||||
</IntlProvider>{" "}
|
|
||||||
{t("commission_per_transaction")})
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
logo="/api/app-store/stripepayment/icon.svg">
|
|
||||||
<>
|
|
||||||
{recurringEventDefined ? (
|
|
||||||
<Alert severity="warning" title={t("warning_recurring_event_payment")} />
|
|
||||||
) : (
|
|
||||||
requirePayment && (
|
|
||||||
<div className="block items-center sm:flex">
|
|
||||||
<Controller
|
|
||||||
defaultValue={eventType.price}
|
|
||||||
control={formMethods.control}
|
|
||||||
name="price"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
label=""
|
|
||||||
addOnLeading={<>{getCurrencySymbol("en", currency)}</>}
|
|
||||||
{...field}
|
|
||||||
step="0.01"
|
|
||||||
min="0.5"
|
|
||||||
type="number"
|
|
||||||
required
|
|
||||||
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
|
|
||||||
placeholder="Price"
|
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(e.target.valueAsNumber * 100);
|
|
||||||
}}
|
|
||||||
value={field.value > 0 ? field.value / 100 : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</AppCard>
|
|
||||||
)}
|
|
||||||
{hasGiphyIntegration && (
|
|
||||||
<AppCard
|
|
||||||
name="Giphy"
|
|
||||||
description={t("confirmation_page_gif")}
|
|
||||||
logo="/api/app-store/giphy/icon.svg"
|
|
||||||
switchOnClick={(e) => {
|
|
||||||
if (!e) {
|
|
||||||
setShowGifSelection(false);
|
|
||||||
formMethods.setValue("giphyThankYouPage", "");
|
|
||||||
} else {
|
|
||||||
setShowGifSelection(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
switchChecked={showGifSelection}>
|
|
||||||
{showGifSelection && (
|
|
||||||
<SelectGifInput
|
|
||||||
defaultValue={eventType.metadata["giphyThankYouPage"] as string}
|
|
||||||
onChange={(url: string) => {
|
|
||||||
formMethods.setValue("giphyThankYouPage", url);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AppCard>
|
|
||||||
)}
|
|
||||||
{hasRainbowIntegration && (
|
|
||||||
<AppCard
|
|
||||||
name="Rainbow"
|
|
||||||
description={t("confirmation_page_rainbow")}
|
|
||||||
logo="/api/app-store/rainbow/icon.svg"
|
|
||||||
switchOnClick={(e) => {
|
|
||||||
if (!e) {
|
|
||||||
formMethods.setValue("blockchainId", 1);
|
|
||||||
formMethods.setValue("smartContractAddress", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowRainbowSection(e);
|
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||||
}}
|
const { t } = useLocale();
|
||||||
switchChecked={showRainbowSection}>
|
const { data: eventTypeApps, isLoading } = trpc.useQuery([
|
||||||
{showRainbowSection && (
|
"viewer.apps",
|
||||||
<RainbowInstallForm
|
{
|
||||||
formMethods={formMethods}
|
extendsFeature: "EventType",
|
||||||
blockchainId={(eventType.metadata.blockchainId as number) || 1}
|
},
|
||||||
smartContractAddress={(eventType.metadata.smartContractAddress as string) || ""}
|
]);
|
||||||
|
const methods = useFormContext<FormValues>();
|
||||||
|
const installedApps = eventTypeApps?.filter((app) => app.credentials.length);
|
||||||
|
const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length);
|
||||||
|
const allAppsData = methods.watch("metadata")?.apps || {};
|
||||||
|
|
||||||
|
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
|
||||||
|
methods.setValue("metadata", {
|
||||||
|
...methods.getValues("metadata"),
|
||||||
|
apps: _allAppsData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
|
||||||
|
return function (key) {
|
||||||
|
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
|
||||||
|
if (key) {
|
||||||
|
return appData[key as keyof typeof appData];
|
||||||
|
}
|
||||||
|
return appData;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAppDataSetter = (appId: EventTypeAppsList): SetAppData => {
|
||||||
|
return function (key, value) {
|
||||||
|
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
|
||||||
|
const allAppsDataFromForm = methods.getValues("metadata")?.apps || {};
|
||||||
|
const appData = allAppsDataFromForm[appId];
|
||||||
|
setAllAppsData({
|
||||||
|
...allAppsDataFromForm,
|
||||||
|
[appId]: {
|
||||||
|
...appData,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="before:border-0">
|
||||||
|
{!isLoading && !installedApps?.length ? (
|
||||||
|
<EmptyScreen
|
||||||
|
Icon={Icon.FiGrid}
|
||||||
|
headline={t("empty_installed_apps_headline")}
|
||||||
|
description={t("empty_installed_apps_description")}
|
||||||
|
buttonRaw={
|
||||||
|
<Button target="_blank" color="secondary" href="/apps">
|
||||||
|
{t("empty_installed_apps_button")}{" "}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</AppCard>
|
{installedApps?.map((app) => (
|
||||||
)}
|
<AppCardWrapper
|
||||||
</div>
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||||
|
key={app.slug}
|
||||||
|
app={app}
|
||||||
|
eventType={eventType}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{!isLoading && notInstalledApps?.length ? (
|
||||||
|
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">Available Apps</h2>
|
||||||
|
) : null}
|
||||||
|
<div className="before:border-0">
|
||||||
|
{notInstalledApps?.map((app) => (
|
||||||
|
<AppCardWrapper
|
||||||
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||||
|
key={app.slug}
|
||||||
|
app={app}
|
||||||
|
eventType={eventType}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useFormContext, Controller, useWatch } from "react-hook-form";
|
import { useFormContext, Controller, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { PeriodType } from "@calcom/prisma/client";
|
import { PeriodType } from "@calcom/prisma/client";
|
||||||
import { Select, Switch, Label, Input } from "@calcom/ui/v2";
|
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||||
|
import { Icon } from "@calcom/ui";
|
||||||
|
import { Select, Switch, Label, Input, MinutesField, Button, SettingsToggle } from "@calcom/ui/v2";
|
||||||
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
|
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
|
||||||
|
|
||||||
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
|
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
const { eventType } = props;
|
const { eventType } = props;
|
||||||
|
|
||||||
const PERIOD_TYPES = [
|
const PERIOD_TYPES = [
|
||||||
{
|
{
|
||||||
type: "ROLLING" as const,
|
type: "ROLLING" as const,
|
||||||
@@ -113,35 +115,12 @@ 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="flex flex-col space-y-4 pt-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
|
<MinutesField
|
||||||
<Controller
|
required
|
||||||
name="minimumBookingNotice"
|
label={t("minimum_booking_notice")}
|
||||||
control={formMethods.control}
|
type="number"
|
||||||
defaultValue={eventType.minimumBookingNotice || 0}
|
placeholder="120"
|
||||||
render={({ field: { onChange, value } }) => {
|
{...formMethods.register("minimumBookingNotice", { valueAsNumber: true })}
|
||||||
const minimumBookingOptions = [
|
|
||||||
{
|
|
||||||
label: t("event_buffer_default"),
|
|
||||||
value: 0,
|
|
||||||
},
|
|
||||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
|
||||||
label: minutes + " " + t("minutes"),
|
|
||||||
value: minutes,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
isSearchable={false}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val) onChange(val.value);
|
|
||||||
}}
|
|
||||||
defaultValue={
|
|
||||||
minimumBookingOptions.find((option) => option.value === value) || minimumBookingOptions[0]
|
|
||||||
}
|
|
||||||
options={minimumBookingOptions}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -177,110 +156,233 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className="my-8" />
|
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
|
||||||
<fieldset className="block flex-col sm:flex">
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<Controller
|
|
||||||
name="periodType"
|
|
||||||
control={formMethods.control}
|
|
||||||
defaultValue={periodType?.type !== "UNLIMITED" ? "ROLLING" : "UNLIMITED"}
|
|
||||||
render={({ field: { value } }) => (
|
|
||||||
<Switch
|
|
||||||
fitToHeight={true}
|
|
||||||
checked={value !== "UNLIMITED"}
|
|
||||||
onCheckedChange={(bool) =>
|
|
||||||
formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="">
|
<hr className="my-8" />
|
||||||
<Label className="text-sm font-semibold leading-none text-black">Limit Future Bookings</Label>
|
|
||||||
<p className="-mt-2 text-sm leading-normal text-gray-600">
|
<Controller
|
||||||
Limit how far in the future people can book a time
|
name="bookingLimits"
|
||||||
</p>
|
control={formMethods.control}
|
||||||
</div>
|
render={({ field: { value } }) => (
|
||||||
</div>
|
<SettingsToggle
|
||||||
<div className="mt-4 lg:ml-14">
|
title={t("limit_booking_frequency")}
|
||||||
<Controller
|
description={t("limit_booking_frequency_description")}
|
||||||
name="periodType"
|
checked={Object.keys(value ?? {}).length > 0}
|
||||||
control={formMethods.control}
|
onCheckedChange={(active) => {
|
||||||
defaultValue={periodType?.type}
|
if (active) {
|
||||||
render={() => (
|
formMethods.setValue("bookingLimits", {
|
||||||
<RadioGroup.Root
|
PER_DAY: 1,
|
||||||
defaultValue={watchPeriodType}
|
});
|
||||||
value={watchPeriodType}
|
} else {
|
||||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
formMethods.setValue("bookingLimits", {});
|
||||||
{PERIOD_TYPES.map((period) => {
|
}
|
||||||
if (period.type === "UNLIMITED") return null;
|
}}>
|
||||||
return (
|
<BookingLimits />
|
||||||
<div
|
</SettingsToggle>
|
||||||
className={classNames(
|
)}
|
||||||
"mb-2 flex flex-wrap items-center text-sm",
|
/>
|
||||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
|
||||||
)}
|
<hr className="my-8" />
|
||||||
key={period.type}>
|
<Controller
|
||||||
<RadioGroup.Item
|
name="periodType"
|
||||||
id={period.type}
|
control={formMethods.control}
|
||||||
value={period.type}
|
render={({ field: { value } }) => (
|
||||||
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">
|
<SettingsToggle
|
||||||
<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" />
|
title={t("limit_future_bookings")}
|
||||||
</RadioGroup.Item>
|
description={t("limit_future_bookings_description")}
|
||||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
checked={value !== "UNLIMITED"}
|
||||||
{period.type === "ROLLING" && (
|
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||||
<div className="flex h-9">
|
<RadioGroup.Root
|
||||||
<Input
|
defaultValue={watchPeriodType}
|
||||||
type="number"
|
value={watchPeriodType}
|
||||||
className="block w-16 rounded-md border-gray-300 py-3 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||||
placeholder="30"
|
{PERIOD_TYPES.map((period) => {
|
||||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
if (period.type === "UNLIMITED") return null;
|
||||||
defaultValue={eventType.periodDays || 30}
|
return (
|
||||||
/>
|
<div
|
||||||
<select
|
className={classNames(
|
||||||
id=""
|
"mb-2 flex flex-wrap items-center text-sm",
|
||||||
className="block h-9 w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
|
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||||
{...formMethods.register("periodCountCalendarDays")}
|
)}
|
||||||
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
|
key={period.type}>
|
||||||
<option value="1">{t("calendar_days")}</option>
|
<RadioGroup.Item
|
||||||
<option value="0">{t("business_days")}</option>
|
id={period.type}
|
||||||
</select>
|
value={period.type}
|
||||||
</div>
|
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" />
|
||||||
{period.type === "RANGE" && (
|
</RadioGroup.Item>
|
||||||
<div className="inline-flex space-x-2 ltr:ml-2 rtl:mr-2 rtl:space-x-reverse">
|
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||||
<Controller
|
{period.type === "ROLLING" && (
|
||||||
name="periodDates"
|
<div className="flex h-9">
|
||||||
control={formMethods.control}
|
<Input
|
||||||
defaultValue={periodDates}
|
type="number"
|
||||||
render={() => (
|
className="block w-16 rounded-md border-gray-300 py-3 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||||
<DateRangePicker
|
placeholder="30"
|
||||||
startDate={formMethods.getValues("periodDates").startDate}
|
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||||
endDate={formMethods.getValues("periodDates").endDate}
|
defaultValue={eventType.periodDays || 30}
|
||||||
onDatesChange={({ startDate, endDate }) => {
|
/>
|
||||||
formMethods.setValue("periodDates", {
|
<select
|
||||||
startDate,
|
id=""
|
||||||
endDate,
|
className="block h-9 w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
|
||||||
});
|
{...formMethods.register("periodCountCalendarDays")}
|
||||||
}}
|
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
|
||||||
/>
|
<option value="1">{t("calendar_days")}</option>
|
||||||
)}
|
<option value="0">{t("business_days")}</option>
|
||||||
/>
|
</select>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{period.suffix ? (
|
|
||||||
<span className="ltr:ml-2 rtl:mr-2"> {period.suffix}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
{period.type === "RANGE" && (
|
||||||
</RadioGroup.Root>
|
<div className="inline-flex space-x-2 ltr:ml-2 rtl:mr-2 rtl:space-x-reverse">
|
||||||
)}
|
<Controller
|
||||||
/>
|
name="periodDates"
|
||||||
</div>
|
control={formMethods.control}
|
||||||
</fieldset>
|
defaultValue={periodDates}
|
||||||
</div>
|
render={() => (
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={formMethods.getValues("periodDates").startDate}
|
||||||
|
endDate={formMethods.getValues("periodDates").endDate}
|
||||||
|
onDatesChange={({ startDate, endDate }) => {
|
||||||
|
formMethods.setValue("periodDates", {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{period.suffix ? <span className="ltr:ml-2 rtl:mr-2"> {period.suffix}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</SettingsToggle>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
|
||||||
|
type BookingLimitsKey = keyof BookingLimit;
|
||||||
|
const BookingLimits = () => {
|
||||||
|
const { watch, setValue, control } = useFormContext<FormValues>();
|
||||||
|
const watchBookingLimits = watch("bookingLimits");
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||||
|
|
||||||
|
const BOOKING_LIMIT_OPTIONS: {
|
||||||
|
value: keyof BookingLimit;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "PER_DAY",
|
||||||
|
label: "Per Day",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "PER_WEEK",
|
||||||
|
label: "Per Week",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "PER_MONTH",
|
||||||
|
label: "Per Month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "PER_YEAR",
|
||||||
|
label: "Per Year",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name="bookingLimits"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => {
|
||||||
|
const currentBookingLimits = value;
|
||||||
|
return (
|
||||||
|
<ul ref={animateRef}>
|
||||||
|
{currentBookingLimits &&
|
||||||
|
watchBookingLimits &&
|
||||||
|
Object.entries(currentBookingLimits)
|
||||||
|
.sort(([limitkeyA], [limitKeyB]) => {
|
||||||
|
return (
|
||||||
|
validationOrderKeys.indexOf(limitkeyA as BookingLimitsKey) -
|
||||||
|
validationOrderKeys.indexOf(limitKeyB as BookingLimitsKey)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(([key, bookingAmount]) => {
|
||||||
|
const bookingLimitKey = key as BookingLimitsKey;
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex items-center space-x-2 text-sm" key={bookingLimitKey}>
|
||||||
|
<Input
|
||||||
|
id={`${bookingLimitKey}-limit`}
|
||||||
|
type="number"
|
||||||
|
className="mb-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||||
|
placeholder="1"
|
||||||
|
min={1}
|
||||||
|
defaultValue={bookingAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setValue(`bookingLimits.${bookingLimitKey}`, parseInt(val));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={BOOKING_LIMIT_OPTIONS.filter(
|
||||||
|
(option) => !Object.keys(currentBookingLimits).includes(option.value)
|
||||||
|
)}
|
||||||
|
isSearchable={false}
|
||||||
|
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
|
||||||
|
onChange={(val) => {
|
||||||
|
const current = currentBookingLimits;
|
||||||
|
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||||
|
delete current[bookingLimitKey];
|
||||||
|
const newData = {
|
||||||
|
...current,
|
||||||
|
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||||
|
[val?.value as BookingLimitsKey]: watchBookingLimits[bookingLimitKey],
|
||||||
|
};
|
||||||
|
onChange(newData);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
StartIcon={Icon.FiTrash}
|
||||||
|
color="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
const current = currentBookingLimits;
|
||||||
|
delete current[key as BookingLimitsKey];
|
||||||
|
onChange(current);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{currentBookingLimits && Object.keys(currentBookingLimits).length <= 3 && (
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
StartIcon={Icon.FiPlus}
|
||||||
|
onClick={() => {
|
||||||
|
if (!currentBookingLimits || !watchBookingLimits) return;
|
||||||
|
const currentKeys = Object.keys(watchBookingLimits);
|
||||||
|
|
||||||
|
const rest = Object.values(BOOKING_LIMIT_OPTIONS).filter(
|
||||||
|
(option) => !currentKeys.includes(option.value)
|
||||||
|
);
|
||||||
|
if (!rest || !currentKeys) return;
|
||||||
|
//currentBookingLimits is always defined so can be casted
|
||||||
|
|
||||||
|
setValue("bookingLimits", {
|
||||||
|
...watchBookingLimits,
|
||||||
|
[rest[0].value]: undefined,
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
{t("add_limit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
import { EventTypeSetupInfered } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
|
||||||
|
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||||
|
|
||||||
import RecurringEventController from "./RecurringEventController";
|
import RecurringEventController from "./RecurringEventController";
|
||||||
|
|
||||||
export const EventRecurringTab = ({
|
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||||
eventType,
|
const stripeAppData = getStripeAppData(eventType);
|
||||||
hasPaymentIntegration,
|
|
||||||
}: Pick<EventTypeSetupInfered, "eventType" | "hasPaymentIntegration">) => {
|
const requirePayment = stripeAppData.price > 0;
|
||||||
const requirePayment = eventType.price > 0;
|
|
||||||
const [recurringEventDefined, setRecurringEventDefined] = useState(
|
|
||||||
eventType.recurringEvent?.count !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
<RecurringEventController
|
<RecurringEventController paymentEnabled={requirePayment} recurringEvent={eventType.recurringEvent} />
|
||||||
paymentEnabled={hasPaymentIntegration && requirePayment}
|
|
||||||
onRecurringEventDefined={setRecurringEventDefined}
|
|
||||||
recurringEvent={eventType.recurringEvent}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import autoAnimate from "@formkit/auto-animate";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ import { getEventLocationType, EventLocationType } from "@calcom/app-store/locat
|
|||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import { Select, Label, TextField, Button } from "@calcom/ui/v2";
|
import { Select, Label, TextField, Button, Skeleton } from "@calcom/ui/v2";
|
||||||
|
|
||||||
import { slugify } from "@lib/slugify";
|
import { slugify } from "@lib/slugify";
|
||||||
|
|
||||||
@@ -85,11 +85,8 @@ export const EventSetupTab = (
|
|||||||
|
|
||||||
const Locations = () => {
|
const Locations = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const animationRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||||
animationRef.current && autoAnimate(animationRef.current);
|
|
||||||
}, [animationRef]);
|
|
||||||
|
|
||||||
const validLocations = formMethods.getValues("locations").filter((location) => {
|
const validLocations = formMethods.getValues("locations").filter((location) => {
|
||||||
const eventLocation = getEventLocationType(location.type);
|
const eventLocation = getEventLocationType(location.type);
|
||||||
@@ -135,14 +132,14 @@ export const EventSetupTab = (
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<li key={location.type} className="mb-2 rounded-md border border-neutral-300 py-1.5 px-2">
|
<li key={location.type} className="mb-2 rounded-md border border-neutral-300 py-1.5 px-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex max-w-full justify-between">
|
||||||
<div key={index} className="flex flex-grow items-center">
|
<div key={index} className="flex flex-grow items-center">
|
||||||
<img
|
<img
|
||||||
src={eventLocationType.iconUrl}
|
src={eventLocationType.iconUrl}
|
||||||
className="h-6 w-6"
|
className="h-6 w-6"
|
||||||
alt={`${eventLocationType.label} logo`}
|
alt={`${eventLocationType.label} logo`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm ltr:ml-2 rtl:mr-2">
|
<span className="truncate text-sm ltr:ml-2 rtl:mr-2">
|
||||||
{location[eventLocationType.defaultValueVariable] || eventLocationType.label}
|
{location[eventLocationType.defaultValueVariable] || eventLocationType.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +219,9 @@ export const EventSetupTab = (
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Label>{t("location")}</Label>
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
|
{t("location")}
|
||||||
|
</Skeleton>
|
||||||
<Controller
|
<Controller
|
||||||
name="locations"
|
name="locations"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SchedulingType } from "@prisma/client/";
|
import { SchedulingType } from "@prisma/client/";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { TFunction } from "next-i18next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Loader } from "react-feather";
|
import { Loader } from "react-feather";
|
||||||
import { UseFormReturn } from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
@@ -9,21 +10,20 @@ import { CAL_URL } from "@calcom/lib/constants";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
import { Icon } from "@calcom/ui/Icon";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Tooltip,
|
|
||||||
showToast,
|
|
||||||
VerticalTabItemProps,
|
|
||||||
VerticalTabs,
|
|
||||||
HorizontalTabs,
|
HorizontalTabs,
|
||||||
Switch,
|
|
||||||
Label,
|
Label,
|
||||||
HorizontalTabItemProps,
|
showToast,
|
||||||
|
Switch,
|
||||||
|
Tooltip,
|
||||||
|
VerticalTabs,
|
||||||
} from "@calcom/ui/v2";
|
} from "@calcom/ui/v2";
|
||||||
|
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||||
import { Dialog } from "@calcom/ui/v2/core/Dialog";
|
import { Dialog } from "@calcom/ui/v2/core/Dialog";
|
||||||
|
import Divider from "@calcom/ui/v2/core/Divider";
|
||||||
import Dropdown, {
|
import Dropdown, {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
@@ -31,6 +31,7 @@ import Dropdown, {
|
|||||||
} from "@calcom/ui/v2/core/Dropdown";
|
} from "@calcom/ui/v2/core/Dropdown";
|
||||||
import Shell from "@calcom/ui/v2/core/Shell";
|
import Shell from "@calcom/ui/v2/core/Shell";
|
||||||
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
|
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
|
||||||
|
import { Skeleton } from "@calcom/ui/v2/core/skeleton";
|
||||||
|
|
||||||
import { ClientSuspense } from "@components/ClientSuspense";
|
import { ClientSuspense } from "@components/ClientSuspense";
|
||||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||||
@@ -42,10 +43,66 @@ type Props = {
|
|||||||
team: EventTypeSetupInfered["team"];
|
team: EventTypeSetupInfered["team"];
|
||||||
disableBorder?: boolean;
|
disableBorder?: boolean;
|
||||||
enabledAppsNumber: number;
|
enabledAppsNumber: number;
|
||||||
|
installedAppsNumber: number;
|
||||||
enabledWorkflowsNumber: number;
|
enabledWorkflowsNumber: number;
|
||||||
formMethods: UseFormReturn<FormValues>;
|
formMethods: UseFormReturn<FormValues>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getNavigation(props: {
|
||||||
|
t: TFunction;
|
||||||
|
eventType: Props["eventType"];
|
||||||
|
enabledAppsNumber: number;
|
||||||
|
enabledWorkflowsNumber: number;
|
||||||
|
installedAppsNumber: number;
|
||||||
|
}) {
|
||||||
|
const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber } = props;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "event_setup_tab_title",
|
||||||
|
href: `/event-types/${eventType.id}?tabName=setup`,
|
||||||
|
icon: Icon.FiLink,
|
||||||
|
info: `${eventType.length} Mins`, // TODO: Get this from props
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "availability",
|
||||||
|
href: `/event-types/${eventType.id}?tabName=availability`,
|
||||||
|
icon: Icon.FiCalendar,
|
||||||
|
info: `Working Hours`, // TODO: Get this from props
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "event_limit_tab_title",
|
||||||
|
href: `/event-types/${eventType.id}?tabName=limits`,
|
||||||
|
icon: Icon.FiClock,
|
||||||
|
info: `event_limit_tab_description`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "event_advanced_tab_title",
|
||||||
|
href: `/event-types/${eventType.id}?tabName=advanced`,
|
||||||
|
icon: Icon.FiSliders,
|
||||||
|
info: `event_advanced_tab_description`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recurring",
|
||||||
|
href: `/event-types/${eventType.id}?tabName=recurring`,
|
||||||
|
icon: Icon.FiRepeat,
|
||||||
|
info: `recurring_event_tab_description`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "apps",
|
||||||
|
href: `/event-types/${eventType.id}?tabName=apps`,
|
||||||
|
icon: Icon.FiGrid,
|
||||||
|
//TODO: Handle proper translation with count handling
|
||||||
|
info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "workflows",
|
||||||
|
href: `/event-types/${eventType.id}?tabName=workflows`,
|
||||||
|
icon: Icon.FiZap,
|
||||||
|
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function EventTypeSingleLayout({
|
function EventTypeSingleLayout({
|
||||||
children,
|
children,
|
||||||
eventType,
|
eventType,
|
||||||
@@ -53,12 +110,13 @@ function EventTypeSingleLayout({
|
|||||||
team,
|
team,
|
||||||
disableBorder,
|
disableBorder,
|
||||||
enabledAppsNumber,
|
enabledAppsNumber,
|
||||||
|
installedAppsNumber,
|
||||||
enabledWorkflowsNumber,
|
enabledWorkflowsNumber,
|
||||||
formMethods,
|
formMethods,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
|
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
|
||||||
@@ -67,6 +125,7 @@ function EventTypeSingleLayout({
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.invalidateQueries(["viewer.eventTypes"]);
|
await utils.invalidateQueries(["viewer.eventTypes"]);
|
||||||
showToast(t("event_type_deleted_successfully"), "success");
|
showToast(t("event_type_deleted_successfully"), "success");
|
||||||
|
await router.push("/event-types");
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -82,73 +141,23 @@ function EventTypeSingleLayout({
|
|||||||
|
|
||||||
// Define tab navigation here
|
// Define tab navigation here
|
||||||
const EventTypeTabs = useMemo(() => {
|
const EventTypeTabs = useMemo(() => {
|
||||||
const navigation: (VerticalTabItemProps & HorizontalTabItemProps)[] = [
|
const navigation = getNavigation({
|
||||||
{
|
t,
|
||||||
name: "event_setup_tab_title",
|
eventType,
|
||||||
tabName: "setup",
|
enabledAppsNumber,
|
||||||
icon: Icon.FiLink,
|
installedAppsNumber,
|
||||||
info: `${eventType.length} Mins`, // TODO: Get this from props
|
enabledWorkflowsNumber,
|
||||||
},
|
});
|
||||||
{
|
|
||||||
name: "availability",
|
|
||||||
tabName: "availability",
|
|
||||||
icon: Icon.FiCalendar,
|
|
||||||
info: `Working Hours`, // TODO: Get this from props
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "event_limit_tab_title",
|
|
||||||
tabName: "limits",
|
|
||||||
icon: Icon.FiClock,
|
|
||||||
info: `event_limit_tab_description`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "event_advanced_tab_title",
|
|
||||||
tabName: "advanced",
|
|
||||||
icon: Icon.FiSliders,
|
|
||||||
info: `event_advanced_tab_description`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "recurring",
|
|
||||||
tabName: "recurring",
|
|
||||||
icon: Icon.FiRepeat,
|
|
||||||
info: `recurring_event_tab_description`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "apps",
|
|
||||||
tabName: "apps",
|
|
||||||
icon: Icon.FiGrid,
|
|
||||||
info: `${enabledAppsNumber} ${t("active")}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "workflows",
|
|
||||||
tabName: "workflows",
|
|
||||||
icon: Icon.FiZap,
|
|
||||||
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// If there is a team put this navigation item within the tabs
|
// If there is a team put this navigation item within the tabs
|
||||||
if (team)
|
if (team)
|
||||||
navigation.splice(2, 0, {
|
navigation.splice(2, 0, {
|
||||||
name: "scheduling_type",
|
name: "scheduling_type",
|
||||||
tabName: "team",
|
href: `/event-types/${eventType.id}?tabName=team`,
|
||||||
icon: Icon.FiUsers,
|
icon: Icon.FiUsers,
|
||||||
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
||||||
});
|
});
|
||||||
return navigation;
|
return navigation;
|
||||||
}, [eventType, enabledAppsNumber, team]);
|
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Default to the first in the list
|
|
||||||
if (!router.query.tabName) {
|
|
||||||
router.push({
|
|
||||||
query: {
|
|
||||||
...router.query,
|
|
||||||
tabName: EventTypeTabs[0].tabName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [EventTypeTabs, router]);
|
|
||||||
|
|
||||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||||
eventType.slug
|
eventType.slug
|
||||||
@@ -159,15 +168,18 @@ function EventTypeSingleLayout({
|
|||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
backPath="/event-types"
|
backPath="/event-types"
|
||||||
title={t("event_type_title", { eventTypeTitle: eventType.title })}
|
title={eventType.title + " | " + t("event_type")}
|
||||||
heading={eventType.title}
|
heading={eventType.title}
|
||||||
subtitle={eventType.description || ""}
|
subtitle={eventType.description || ""}
|
||||||
CTA={
|
CTA={
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<div className="flex items-center rounded-md px-2 sm:hover:bg-gray-100">
|
<div className="hidden items-center rounded-md px-2 sm:flex sm:hover:bg-gray-100">
|
||||||
<Label htmlFor="hiddenSwitch" className="mt-2 hidden cursor-pointer self-center pr-2 sm:inline">
|
<Skeleton
|
||||||
|
as={Label}
|
||||||
|
htmlFor="hiddenSwitch"
|
||||||
|
className="mt-2 hidden cursor-pointer self-center whitespace-nowrap pr-2 sm:inline">
|
||||||
{t("hide_from_profile")}
|
{t("hide_from_profile")}
|
||||||
</Label>
|
</Skeleton>
|
||||||
<Switch
|
<Switch
|
||||||
id="hiddenSwitch"
|
id="hiddenSwitch"
|
||||||
defaultChecked={formMethods.getValues("hidden")}
|
defaultChecked={formMethods.getValues("hidden")}
|
||||||
@@ -208,6 +220,7 @@ function EventTypeSingleLayout({
|
|||||||
StartIcon={Icon.FiCode}
|
StartIcon={Icon.FiCode}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
tooltip={t("embed")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
@@ -219,37 +232,55 @@ function EventTypeSingleLayout({
|
|||||||
/>
|
/>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
<VerticalDivider />
|
<VerticalDivider className="hidden lg:block" />
|
||||||
|
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownMenuTrigger className="focus:ring-brand-900 block h-9 w-9 justify-center rounded-md border border-gray-200 bg-transparent text-gray-700 focus:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-1 lg:hidden">
|
<DropdownMenuTrigger className="block h-9 w-9 justify-center rounded-md border border-gray-200 bg-transparent text-gray-700 lg:hidden">
|
||||||
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
|
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem className="focus:ring-gray-100">
|
||||||
<Button
|
<Button
|
||||||
color="minimal"
|
color="minimal"
|
||||||
StartIcon={Icon.FiExternalLink}
|
StartIcon={Icon.FiExternalLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={permalink}
|
href={permalink}
|
||||||
rel="noreferrer">
|
rel="noreferrer"
|
||||||
|
className="min-w-full">
|
||||||
{t("preview")}
|
{t("preview")}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem className="focus:ring-gray-100">
|
||||||
<Button color="minimal" StartIcon={Icon.FiLink}>
|
<Button color="minimal" StartIcon={Icon.FiLink}>
|
||||||
{t("copy_link")}
|
{t("copy_link")}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem className="focus:ring-gray-100">
|
||||||
<Button
|
<Button
|
||||||
color="minimal"
|
color="minimal"
|
||||||
StartIcon={Icon.FiTrash}
|
StartIcon={Icon.FiTrash}
|
||||||
disabled={!hasPermsToDelete}
|
disabled={!hasPermsToDelete}
|
||||||
onClick={() => setDeleteDialogOpen(true)}>
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
className="min-w-full">
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<Divider />
|
||||||
|
<div className="flex items-center rounded-md py-1.5 px-4 sm:hidden sm:hover:bg-gray-100">
|
||||||
|
<Skeleton
|
||||||
|
as={Label}
|
||||||
|
htmlFor="hiddenSwitch"
|
||||||
|
className="mt-2 inline cursor-pointer self-center pr-2 sm:hidden">
|
||||||
|
{t("hide_from_profile")}
|
||||||
|
</Skeleton>
|
||||||
|
<Switch
|
||||||
|
id="hiddenSwitch"
|
||||||
|
defaultChecked={formMethods.getValues("hidden")}
|
||||||
|
onCheckedChange={(e) => {
|
||||||
|
formMethods.setValue("hidden", e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<div className="border-l-2 border-gray-300" />
|
<div className="border-l-2 border-gray-300" />
|
||||||
@@ -265,10 +296,15 @@ function EventTypeSingleLayout({
|
|||||||
<ClientSuspense fallback={<Loader />}>
|
<ClientSuspense fallback={<Loader />}>
|
||||||
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
||||||
<div className="hidden xl:block">
|
<div className="hidden xl:block">
|
||||||
<VerticalTabs className="primary-navigation" tabs={EventTypeTabs} sticky />
|
<VerticalTabs
|
||||||
|
className="primary-navigation"
|
||||||
|
tabs={EventTypeTabs}
|
||||||
|
sticky
|
||||||
|
linkProps={{ shallow: true }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
||||||
<HorizontalTabs tabNameKey="tabName" tabs={EventTypeTabs} />
|
<HorizontalTabs tabs={EventTypeTabs} linkProps={{ shallow: true }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||||
<div
|
<div
|
||||||
@@ -292,7 +328,7 @@ function EventTypeSingleLayout({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
deleteMutation.mutate({ id: eventType.id });
|
deleteMutation.mutate({ id: eventType.id });
|
||||||
}}>
|
}}>
|
||||||
{t("delete_event_type_description") as string}
|
{t("delete_event_type_description")}
|
||||||
</ConfirmationDialogContent>
|
</ConfirmationDialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<EmbedDialog />
|
<EmbedDialog />
|
||||||
|
|||||||
@@ -6,18 +6,16 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import { Label, Select, Switch } from "@calcom/ui/v2";
|
import { Label, Select, Switch, SettingsToggle } from "@calcom/ui/v2";
|
||||||
|
|
||||||
type RecurringEventControllerProps = {
|
type RecurringEventControllerProps = {
|
||||||
recurringEvent: RecurringEvent | null;
|
recurringEvent: RecurringEvent | null;
|
||||||
paymentEnabled: boolean;
|
paymentEnabled: boolean;
|
||||||
onRecurringEventDefined: (value: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RecurringEventController({
|
export default function RecurringEventController({
|
||||||
recurringEvent,
|
recurringEvent,
|
||||||
paymentEnabled,
|
paymentEnabled,
|
||||||
onRecurringEventDefined,
|
|
||||||
}: RecurringEventControllerProps) {
|
}: RecurringEventControllerProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
||||||
@@ -38,95 +36,85 @@ export default function RecurringEventController({
|
|||||||
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
<Alert severity="warning" title={t("warning_payment_recurring_event")} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex space-x-3 ">
|
<SettingsToggle
|
||||||
<Switch
|
title={t("recurring_event")}
|
||||||
name="requireConfirmation"
|
description={t("recurring_event_description")}
|
||||||
data-testid="recurring-event-check"
|
checked={recurringEventState !== null}
|
||||||
fitToHeight={true}
|
data-testid="recurring-event-check"
|
||||||
checked={recurringEventState !== null}
|
onCheckedChange={(e) => {
|
||||||
onCheckedChange={(e) => {
|
if (!e) {
|
||||||
if (!e) {
|
formMethods.setValue("recurringEvent", null);
|
||||||
formMethods.setValue("recurringEvent", null);
|
setRecurringEventState(null);
|
||||||
setRecurringEventState(null);
|
} else {
|
||||||
} else {
|
const newVal = recurringEvent || {
|
||||||
const newVal = recurringEvent || {
|
interval: 1,
|
||||||
interval: 1,
|
count: 12,
|
||||||
count: 12,
|
freq: Frequency.WEEKLY,
|
||||||
freq: Frequency.WEEKLY,
|
};
|
||||||
};
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
setRecurringEventState(newVal);
|
||||||
setRecurringEventState(newVal);
|
}
|
||||||
}
|
}}>
|
||||||
}}
|
{recurringEventState && (
|
||||||
/>
|
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||||
<div className="flex flex-col">
|
<div className="flex items-center">
|
||||||
<Label className="text-sm font-semibold leading-none text-black">
|
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||||
{t("recurring_event")}
|
<input
|
||||||
</Label>
|
type="number"
|
||||||
<p className="-mt-2 text-sm leading-normal text-gray-600">
|
min="1"
|
||||||
{t("recurring_event_description")}
|
max="20"
|
||||||
</p>
|
className="block h-[36px] w-16 rounded-md border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||||
{recurringEventState && (
|
defaultValue={recurringEventState.interval}
|
||||||
<div data-testid="recurring-event-collapsible" className="mt-4 text-sm">
|
onChange={(event) => {
|
||||||
<div className="flex items-center">
|
const newVal = {
|
||||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
...recurringEventState,
|
||||||
<input
|
interval: parseInt(event?.target.value),
|
||||||
type="number"
|
};
|
||||||
min="1"
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
max="20"
|
setRecurringEventState(newVal);
|
||||||
className="block h-[36px] w-16 rounded-md border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
}}
|
||||||
defaultValue={recurringEventState.interval}
|
/>
|
||||||
onChange={(event) => {
|
<Select
|
||||||
const newVal = {
|
options={recurringEventFreqOptions}
|
||||||
...recurringEventState,
|
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||||
interval: parseInt(event?.target.value),
|
isSearchable={false}
|
||||||
};
|
className="w-18 block h-[36px] min-w-0 rounded-md text-sm"
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
onChange={(event) => {
|
||||||
setRecurringEventState(newVal);
|
const newVal = {
|
||||||
}}
|
...recurringEventState,
|
||||||
/>
|
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||||
<Select
|
};
|
||||||
options={recurringEventFreqOptions}
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
setRecurringEventState(newVal);
|
||||||
isSearchable={false}
|
}}
|
||||||
className="w-18 block h-[36px] min-w-0 rounded-md text-sm"
|
/>
|
||||||
onChange={(event) => {
|
|
||||||
const newVal = {
|
|
||||||
...recurringEventState,
|
|
||||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
|
||||||
};
|
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
|
||||||
setRecurringEventState(newVal);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-center">
|
|
||||||
<p className="mr-2 text-neutral-900">{t("for_a_maximum_of")}</p>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
className="block h-[36px] w-16 rounded-md border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
|
||||||
defaultValue={recurringEventState.count}
|
|
||||||
onChange={(event) => {
|
|
||||||
const newVal = {
|
|
||||||
...recurringEventState,
|
|
||||||
count: parseInt(event?.target.value),
|
|
||||||
};
|
|
||||||
formMethods.setValue("recurringEvent", newVal);
|
|
||||||
setRecurringEventState(newVal);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="mr-2 text-neutral-900">
|
|
||||||
{t("events", {
|
|
||||||
count: recurringEventState.count,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-4 flex items-center">
|
||||||
</div>
|
<p className="mr-2 text-neutral-900">{t("for_a_maximum_of")}</p>
|
||||||
</div>
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
className="block h-[36px] w-16 rounded-md border-gray-300 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||||
|
defaultValue={recurringEventState.count}
|
||||||
|
onChange={(event) => {
|
||||||
|
const newVal = {
|
||||||
|
...recurringEventState,
|
||||||
|
count: parseInt(event?.target.value),
|
||||||
|
};
|
||||||
|
formMethods.setValue("recurringEvent", newVal);
|
||||||
|
setRecurringEventState(newVal);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mr-2 text-neutral-900">
|
||||||
|
{t("events", {
|
||||||
|
count: recurringEventState.count,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsToggle>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMutation } from "react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
@@ -12,6 +12,7 @@ export function CalendarSwitch(props: {
|
|||||||
externalId: string;
|
externalId: string;
|
||||||
title: string;
|
title: string;
|
||||||
defaultSelected: boolean;
|
defaultSelected: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ export function CalendarSwitch(props: {
|
|||||||
key={props.externalId}
|
key={props.externalId}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
label={props.title}
|
label={props.title}
|
||||||
defaultChecked={props.defaultSelected}
|
defaultChecked={props.isSelected}
|
||||||
onCheckedChange={(isOn: boolean) => {
|
onCheckedChange={(isOn: boolean) => {
|
||||||
mutation.mutate({ isOn });
|
mutation.mutate({ isOn });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
import { Webhook } from "@prisma/client";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import customTemplate, { hasTemplateIntegration } from "@calcom/features/webhooks/utils/integrationTemplate";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
import { DialogFooter } from "@calcom/ui/Dialog";
|
|
||||||
import Switch from "@calcom/ui/Switch";
|
|
||||||
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields";
|
|
||||||
|
|
||||||
import { WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP } from "@lib/webhooks/constants";
|
|
||||||
|
|
||||||
import { TWebhook } from "@components/webhook/WebhookListItem";
|
|
||||||
import WebhookTestDisclosure from "@components/webhook/WebhookTestDisclosure";
|
|
||||||
|
|
||||||
/** @deprecated Moved to `packages/features/webhooks` */
|
|
||||||
export default function WebhookDialogForm(props: {
|
|
||||||
eventTypeId?: number;
|
|
||||||
defaultValues?: TWebhook;
|
|
||||||
app?: string;
|
|
||||||
handleClose: () => void;
|
|
||||||
webhooks: Webhook[];
|
|
||||||
}) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
const appId = props.app;
|
|
||||||
const webhooks = props.webhooks;
|
|
||||||
|
|
||||||
const triggers = !appId
|
|
||||||
? WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["core"]
|
|
||||||
: WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP[appId as keyof typeof WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP];
|
|
||||||
const {
|
|
||||||
defaultValues = {
|
|
||||||
id: "",
|
|
||||||
eventTriggers: triggers,
|
|
||||||
subscriberUrl: "",
|
|
||||||
active: true,
|
|
||||||
payloadTemplate: null,
|
|
||||||
secret: null,
|
|
||||||
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
|
|
||||||
const [changeSecret, setChangeSecret] = useState(false);
|
|
||||||
const [newSecret, setNewSecret] = useState("");
|
|
||||||
const hasSecretKey = !!defaultValues.secret;
|
|
||||||
const currentSecret = defaultValues.secret;
|
|
||||||
|
|
||||||
const subscriberUrlReserved = (subscriberUrl: string, id: string): boolean => {
|
|
||||||
return !!webhooks.find((webhook) => webhook.subscriberUrl === subscriberUrl && webhook.id !== id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleInput = (event: React.FormEvent<HTMLInputElement>) => {
|
|
||||||
setNewSecret(event.currentTarget.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (changeSecret) {
|
|
||||||
form.unregister("secret", { keepDefaultValue: false });
|
|
||||||
}
|
|
||||||
}, [changeSecret]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
data-testid="WebhookDialogForm"
|
|
||||||
form={form}
|
|
||||||
handleSubmit={async (event) => {
|
|
||||||
if (subscriberUrlReserved(event.subscriberUrl, event.id)) {
|
|
||||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const e = changeSecret
|
|
||||||
? { ...event, eventTypeId: props.eventTypeId, appId }
|
|
||||||
: { ...event, secret: currentSecret, eventTypeId: props.eventTypeId, appId };
|
|
||||||
if (!useCustomPayloadTemplate && event.payloadTemplate) {
|
|
||||||
event.payloadTemplate = null;
|
|
||||||
}
|
|
||||||
if (event.id) {
|
|
||||||
await utils.client.mutation("viewer.webhook.edit", e);
|
|
||||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
|
||||||
showToast(t("webhook_updated_successfully"), "success");
|
|
||||||
} else {
|
|
||||||
await utils.client.mutation("viewer.webhook.create", e);
|
|
||||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
|
||||||
showToast(t("webhook_created_successfully"), "success");
|
|
||||||
}
|
|
||||||
props.handleClose();
|
|
||||||
}}
|
|
||||||
className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<input type="hidden" {...form.register("id")} />
|
|
||||||
</div>
|
|
||||||
<fieldset className="space-y-2">
|
|
||||||
<InputGroupBox className="border-0 bg-gray-50">
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="active"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Switch
|
|
||||||
label={field.value ? t("webhook_enabled") : t("webhook_disabled")}
|
|
||||||
defaultChecked={field.value}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
form.setValue("active", isChecked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</InputGroupBox>
|
|
||||||
</fieldset>
|
|
||||||
<div>
|
|
||||||
<TextField
|
|
||||||
label={t("subscriber_url")}
|
|
||||||
{...form.register("subscriberUrl")}
|
|
||||||
required
|
|
||||||
type="url"
|
|
||||||
onChange={(e) => {
|
|
||||||
form.setValue("subscriberUrl", e.target.value);
|
|
||||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
|
||||||
setUseCustomPayloadTemplate(true);
|
|
||||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<fieldset className="space-y-2">
|
|
||||||
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
|
|
||||||
<InputGroupBox className="border-0 bg-gray-50">
|
|
||||||
{triggers.map((key) => (
|
|
||||||
<Controller
|
|
||||||
key={key}
|
|
||||||
control={form.control}
|
|
||||||
name="eventTriggers"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Switch
|
|
||||||
label={t(key.toLowerCase())}
|
|
||||||
defaultChecked={field.value.includes(key)}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
const value = field.value;
|
|
||||||
const newValue = isChecked ? [...value, key] : value.filter((v) => v !== key);
|
|
||||||
|
|
||||||
form.setValue("eventTriggers", newValue, {
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</InputGroupBox>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset className="space-y-2">
|
|
||||||
{!!hasSecretKey && !changeSecret && (
|
|
||||||
<>
|
|
||||||
<FieldsetLegend>{t("secret")}</FieldsetLegend>
|
|
||||||
<div className="rounded-sm bg-gray-50 p-2 text-xs text-neutral-900">
|
|
||||||
{t("forgotten_secret_description")}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
type="button"
|
|
||||||
className="py-1 text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
setChangeSecret(true);
|
|
||||||
}}>
|
|
||||||
{t("change_secret")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!!hasSecretKey && changeSecret && (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
autoComplete="off"
|
|
||||||
label={t("secret")}
|
|
||||||
{...form.register("secret")}
|
|
||||||
value={newSecret}
|
|
||||||
onChange={handleInput}
|
|
||||||
type="text"
|
|
||||||
placeholder={t("leave_blank_to_remove_secret")}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
type="button"
|
|
||||||
className="py-1 text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
setChangeSecret(false);
|
|
||||||
}}>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!hasSecretKey && (
|
|
||||||
<TextField autoComplete="off" label={t("secret")} {...form.register("secret")} type="text" />
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
<fieldset className="space-y-2">
|
|
||||||
<FieldsetLegend>{t("payload_template")}</FieldsetLegend>
|
|
||||||
<div className="space-x-3 text-sm rtl:space-x-reverse">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
className="text-neutral-900 focus:ring-neutral-500"
|
|
||||||
type="radio"
|
|
||||||
name="useCustomPayloadTemplate"
|
|
||||||
onChange={(value) => setUseCustomPayloadTemplate(!value.target.checked)}
|
|
||||||
defaultChecked={!useCustomPayloadTemplate}
|
|
||||||
/>{" "}
|
|
||||||
Default
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
className="text-neutral-900 focus:ring-neutral-500"
|
|
||||||
onChange={(value) => setUseCustomPayloadTemplate(value.target.checked)}
|
|
||||||
name="useCustomPayloadTemplate"
|
|
||||||
type="radio"
|
|
||||||
defaultChecked={useCustomPayloadTemplate}
|
|
||||||
/>{" "}
|
|
||||||
Custom
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{useCustomPayloadTemplate && (
|
|
||||||
<TextArea
|
|
||||||
{...form.register("payloadTemplate")}
|
|
||||||
defaultValue={useCustomPayloadTemplate && (defaultValues.payloadTemplate || "")}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
<WebhookTestDisclosure />
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" color="secondary" onClick={props.handleClose} tabIndex={-1}>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
{t("save")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
import { List } from "@calcom/ui/List";
|
|
||||||
import { ShellSubHeading } from "@calcom/ui/Shell";
|
|
||||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
|
||||||
|
|
||||||
import WebhookDialogForm from "@components/webhook/WebhookDialogForm";
|
|
||||||
import WebhookListItem, { TWebhook } from "@components/webhook/WebhookListItem";
|
|
||||||
|
|
||||||
export type WebhookListContainerType = {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
eventTypeId?: number;
|
|
||||||
appId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @deprecated Moved to `packages/features/webhooks` */
|
|
||||||
export default function WebhookListContainer(props: WebhookListContainerType) {
|
|
||||||
const router = useRouter();
|
|
||||||
const query = trpc.useQuery(
|
|
||||||
["viewer.webhook.list", { eventTypeId: props.eventTypeId, appId: props.appId }],
|
|
||||||
{
|
|
||||||
suspense: true,
|
|
||||||
enabled: router.isReady,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const [newWebhookModal, setNewWebhookModal] = useState(false);
|
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<TWebhook | null>(null);
|
|
||||||
return (
|
|
||||||
<QueryCell
|
|
||||||
query={query}
|
|
||||||
customLoader={<SkeletonLoader />}
|
|
||||||
success={({ data }) => (
|
|
||||||
<div className="border-b border-gray-200 py-8 pl-2 pr-1">
|
|
||||||
<ShellSubHeading
|
|
||||||
className="mt-2"
|
|
||||||
title={props.title}
|
|
||||||
subtitle={props.subtitle}
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
color="minimal"
|
|
||||||
size="icon"
|
|
||||||
StartIcon={Icon.FiPlus}
|
|
||||||
onClick={() => setNewWebhookModal(true)}
|
|
||||||
data-testid="new_webhook"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{data.length ? (
|
|
||||||
<List className="mt-6">
|
|
||||||
{data.map((item) => (
|
|
||||||
<WebhookListItem
|
|
||||||
key={item.id}
|
|
||||||
webhook={item}
|
|
||||||
onEditWebhook={() => {
|
|
||||||
setEditing(item);
|
|
||||||
setEditModalOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* New webhook dialog */}
|
|
||||||
<Dialog open={newWebhookModal} onOpenChange={(isOpen) => !isOpen && setNewWebhookModal(false)}>
|
|
||||||
<DialogContent>
|
|
||||||
<WebhookDialogForm
|
|
||||||
app={props.appId}
|
|
||||||
webhooks={data}
|
|
||||||
eventTypeId={props.eventTypeId}
|
|
||||||
handleClose={() => setNewWebhookModal(false)}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
{/* Edit webhook dialog */}
|
|
||||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
|
||||||
<DialogContent>
|
|
||||||
{editing && (
|
|
||||||
<WebhookDialogForm
|
|
||||||
app={props.appId}
|
|
||||||
key={editing.id}
|
|
||||||
webhooks={data}
|
|
||||||
eventTypeId={props.eventTypeId || undefined}
|
|
||||||
handleClose={() => setEditModalOpen(false)}
|
|
||||||
defaultValues={editing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import classNames from "@calcom/lib/classNames";
|
|
||||||
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 { Icon } from "@calcom/ui/Icon";
|
|
||||||
import { ListItem } from "@calcom/ui/List";
|
|
||||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
|
||||||
|
|
||||||
/** @deprecated Moved to `packages/features/webhooks` */
|
|
||||||
export default function WebhookListItem(props: { webhook: TWebhook; onEditWebhook: () => void }) {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const utils = trpc.useContext();
|
|
||||||
const deleteWebhook = trpc.useMutation("viewer.webhook.delete", {
|
|
||||||
async onSuccess() {
|
|
||||||
await utils.invalidateQueries(["viewer.webhook.list"]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ListItem className={classNames("flex w-full p-4", props.webhook.active ? "bg-white" : "bg-gray-100")}>
|
|
||||||
<div className="flex w-full justify-between">
|
|
||||||
<div className="flex max-w-full flex-col truncate">
|
|
||||||
<div className="flex space-y-1">
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
"truncate text-sm",
|
|
||||||
props.webhook.active ? "text-neutral-700" : "text-neutral-400"
|
|
||||||
)}>
|
|
||||||
{props.webhook.subscriberUrl}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex">
|
|
||||||
<span className="flex flex-col space-x-2 space-y-1 text-xs sm:flex-row sm:space-y-0 sm:rtl:space-x-reverse">
|
|
||||||
{props.webhook.eventTriggers.map((eventTrigger, ind) => (
|
|
||||||
<span
|
|
||||||
key={ind}
|
|
||||||
className={classNames(
|
|
||||||
"w-max rounded-sm px-1 text-xs",
|
|
||||||
props.webhook.active ? "text-grey-200 bg-gray-200" : "bg-grey-50 text-neutral-400"
|
|
||||||
)}>
|
|
||||||
{t(`${eventTrigger.toLowerCase()}`)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<Tooltip side="top" content={t("edit_webhook")}>
|
|
||||||
<Button
|
|
||||||
onClick={() => props.onEditWebhook()}
|
|
||||||
color="minimal"
|
|
||||||
size="icon"
|
|
||||||
StartIcon={Icon.FiEdit2}
|
|
||||||
className="ml-4 w-full self-center p-2"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Dialog>
|
|
||||||
<Tooltip side="top" content={t("delete_webhook")}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
size="icon"
|
|
||||||
color="warn"
|
|
||||||
StartIcon={Icon.FiTrash}
|
|
||||||
className="ml-2 w-full self-center p-2"
|
|
||||||
/>
|
|
||||||
</DialogTrigger>
|
|
||||||
</Tooltip>
|
|
||||||
<ConfirmationDialogContent
|
|
||||||
variety="danger"
|
|
||||||
title={t("delete_webhook")}
|
|
||||||
confirmBtnText={t("confirm_delete_webhook")}
|
|
||||||
cancelBtnText={t("cancel")}
|
|
||||||
onConfirm={() =>
|
|
||||||
deleteWebhook.mutate({
|
|
||||||
id: props.webhook.id,
|
|
||||||
eventTypeId: props.webhook.eventTypeId || undefined,
|
|
||||||
})
|
|
||||||
}>
|
|
||||||
{t("delete_webhook_confirmation_message")}
|
|
||||||
</ConfirmationDialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useWatch } from "react-hook-form";
|
|
||||||
|
|
||||||
import classNames from "@calcom/lib/classNames";
|
|
||||||
import showToast from "@calcom/lib/notification";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import Button from "@calcom/ui/Button";
|
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
|
||||||
import { InputGroupBox } from "@calcom/ui/form/fields";
|
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
/** @deprecated Moved to `packages/features/webhooks` */
|
|
||||||
export default function WebhookTestDisclosure() {
|
|
||||||
const subscriberUrl: string = useWatch({ name: "subscriberUrl" });
|
|
||||||
const payloadTemplate = useWatch({ name: "payloadTemplate" }) || null;
|
|
||||||
const { t } = useLocale();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const mutation = trpc.useMutation("viewer.webhook.testTrigger", {
|
|
||||||
onError(err) {
|
|
||||||
showToast(err.message, "error");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible open={open} onOpenChange={() => setOpen(!open)}>
|
|
||||||
<CollapsibleTrigger type="button" className="flex w-full cursor-pointer">
|
|
||||||
<Icon.FiChevronRight className={`${open ? "rotate-90 transform" : ""} h-5 w-5 text-neutral-500`} />
|
|
||||||
<span className="text-sm font-medium text-gray-700">{t("webhook_test")}</span>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<InputGroupBox className="space-y-0 border-0 px-0">
|
|
||||||
<div className="flex justify-between bg-gray-50 p-2">
|
|
||||||
<h3 className="self-center text-gray-700">{t("webhook_response")}</h3>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
color="minimal"
|
|
||||||
disabled={mutation.isLoading}
|
|
||||||
onClick={() => mutation.mutate({ url: subscriberUrl, type: "PING", payloadTemplate })}>
|
|
||||||
{t("ping_test")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="border-8 border-gray-50 p-2 text-gray-500">
|
|
||||||
{!mutation.data && <em>{t("no_data_yet")}</em>}
|
|
||||||
{mutation.status === "success" && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"ml-auto w-max px-2 py-1 text-xs",
|
|
||||||
mutation.data.ok ? "bg-green-50 text-green-500" : "bg-red-50 text-red-500"
|
|
||||||
)}>
|
|
||||||
{mutation.data.ok ? t("success") : t("failed")}
|
|
||||||
</div>
|
|
||||||
<pre className="overflow-x-auto">{JSON.stringify(mutation.data, null, 4)}</pre>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</InputGroupBox>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
import {
|
import {
|
||||||
QueryObserverIdleResult,
|
|
||||||
QueryObserverLoadingErrorResult,
|
QueryObserverLoadingErrorResult,
|
||||||
QueryObserverLoadingResult,
|
QueryObserverLoadingResult,
|
||||||
QueryObserverRefetchErrorResult,
|
QueryObserverRefetchErrorResult,
|
||||||
QueryObserverSuccessResult,
|
QueryObserverSuccessResult,
|
||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
} from "react-query";
|
} from "@tanstack/react-query";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||||
import type { UseTRPCQueryOptions } from "@calcom/trpc/react";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type {
|
import type {
|
||||||
inferHandlerInput,
|
inferHandlerInput,
|
||||||
@@ -22,6 +20,8 @@ import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
|||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import Loader from "@calcom/ui/Loader";
|
import Loader from "@calcom/ui/Loader";
|
||||||
|
|
||||||
|
import type { UseTRPCQueryOptions } from "@trpc/react/shared";
|
||||||
|
|
||||||
type ErrorLike = {
|
type ErrorLike = {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
@@ -34,7 +34,6 @@ interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
|
|||||||
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
|
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
|
||||||
) => JSXElementOrNull;
|
) => JSXElementOrNull;
|
||||||
loading?: (query: QueryObserverLoadingResult<TData, TError> | null) => JSXElementOrNull;
|
loading?: (query: QueryObserverLoadingResult<TData, TError> | null) => JSXElementOrNull;
|
||||||
idle?: (query: QueryObserverIdleResult<TData, TError>) => JSXElementOrNull;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueryCellOptionsNoEmpty<TData, TError extends ErrorLike>
|
interface QueryCellOptionsNoEmpty<TData, TError extends ErrorLike>
|
||||||
@@ -84,14 +83,11 @@ export function QueryCell<TData, TError extends ErrorLike>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.status === "idle") {
|
|
||||||
return opts.idle?.(query) ?? StatusLoader;
|
|
||||||
}
|
|
||||||
// impossible state
|
// impossible state
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type inferProcedures<TObj extends ProcedureRecord<any, any, any, any, any, any>> = {
|
type inferProcedures<TObj extends ProcedureRecord> = {
|
||||||
[TPath in keyof TObj]: {
|
[TPath in keyof TObj]: {
|
||||||
input: inferProcedureInput<TObj[TPath]>;
|
input: inferProcedureInput<TObj[TPath]>;
|
||||||
output: inferProcedureOutput<TObj[TPath]>;
|
output: inferProcedureOutput<TObj[TPath]>;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ComponentProps, ReactNode } from "react";
|
|||||||
|
|
||||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc, proxy } from "@calcom/trpc/react";
|
||||||
import { MetaProvider } from "@calcom/ui/v2/core/Meta";
|
import { MetaProvider } from "@calcom/ui/v2/core/Meta";
|
||||||
|
|
||||||
import usePublicPage from "@lib/hooks/usePublicPage";
|
import usePublicPage from "@lib/hooks/usePublicPage";
|
||||||
@@ -38,7 +38,8 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
|
|||||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||||
* We intend to not cache i18n query
|
* We intend to not cache i18n query
|
||||||
**/
|
**/
|
||||||
const { i18n, locale } = trpc.useQuery(["viewer.public.i18n"], { context: { skipBatch: true } }).data ?? {
|
const { i18n, locale } = trpc.useQuery(["viewer.public.i18n"], { trpc: { context: { skipBatch: true } } })
|
||||||
|
.data ?? {
|
||||||
locale: "en",
|
locale: "en",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AppProviders = (props: AppPropsWithChildren) => {
|
const AppProviders = (props: AppPropsWithChildren) => {
|
||||||
const session = trpc.useQuery(["viewer.public.session"]).data;
|
const session = proxy.public.session.useQuery().data;
|
||||||
// No need to have intercom on public pages - Good for Page Performance
|
// No need to have intercom on public pages - Good for Page Performance
|
||||||
const isPublicPage = usePublicPage();
|
const isPublicPage = usePublicPage();
|
||||||
const isThemeSupported =
|
const isThemeSupported =
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// handles logic related to user clock display using 24h display / timeZone options.
|
// handles logic related to user clock display using 24h display / timeZone options.
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
import {
|
||||||
|
getIs24hClockFromLocalStorage,
|
||||||
|
isBrowserLocale24h,
|
||||||
|
setIs24hClockInLocalStorage,
|
||||||
|
} from "@calcom/lib/timeFormat";
|
||||||
import { localStorage } from "@calcom/lib/webstorage";
|
import { localStorage } from "@calcom/lib/webstorage";
|
||||||
|
|
||||||
interface TimeOptions {
|
interface TimeOptions {
|
||||||
@@ -20,8 +24,8 @@ const initClock = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// This only sets browser locale if there's no preference on localStorage.
|
// This only sets browser locale if there's no preference on localStorage.
|
||||||
if (!localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
|
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
|
||||||
timeOptions.is24hClock = localStorage.getItem("timeOption.is24hClock") === "true";
|
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
|
||||||
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
|
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@ const is24h = (is24hClock?: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const set24hClock = (is24hClock: boolean) => {
|
const set24hClock = (is24hClock: boolean) => {
|
||||||
localStorage.setItem("timeOption.is24hClock", is24hClock.toString());
|
setIs24hClockInLocalStorage(is24hClock);
|
||||||
timeOptions.is24hClock = is24hClock;
|
timeOptions.is24hClock = is24hClock;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ async function getBooking(prisma: PrismaClient, uid: string) {
|
|||||||
description: true,
|
description: true,
|
||||||
customInputs: true,
|
customInputs: true,
|
||||||
smsReminderNumber: true,
|
smsReminderNumber: true,
|
||||||
|
location: true,
|
||||||
attendees: {
|
attendees: {
|
||||||
select: {
|
select: {
|
||||||
email: true,
|
email: true,
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import jackson, { IAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson";
|
|
||||||
|
|
||||||
import { BASE_URL } from "@lib/config/constants";
|
|
||||||
import { samlDatabaseUrl } from "@lib/saml";
|
|
||||||
|
|
||||||
// Set the required options. Refer to https://github.com/boxyhq/jackson#configuration for the full list
|
|
||||||
const opts: JacksonOption = {
|
|
||||||
externalUrl: BASE_URL,
|
|
||||||
samlPath: "/api/auth/saml/callback",
|
|
||||||
db: {
|
|
||||||
engine: "sql",
|
|
||||||
type: "postgres",
|
|
||||||
url: samlDatabaseUrl,
|
|
||||||
encryptionKey: process.env.CALENDSO_ENCRYPTION_KEY,
|
|
||||||
},
|
|
||||||
samlAudience: "https://saml.cal.com",
|
|
||||||
};
|
|
||||||
|
|
||||||
let apiController: IAPIController;
|
|
||||||
let oauthController: IOAuthController;
|
|
||||||
|
|
||||||
const g = global as any;
|
|
||||||
|
|
||||||
export default async function init() {
|
|
||||||
if (!g.apiController || !g.oauthController) {
|
|
||||||
const ret = await jackson(opts);
|
|
||||||
apiController = ret.apiController;
|
|
||||||
oauthController = ret.oauthController;
|
|
||||||
g.apiController = apiController;
|
|
||||||
g.oauthController = oauthController;
|
|
||||||
} else {
|
|
||||||
apiController = g.apiController;
|
|
||||||
oauthController = g.oauthController;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
apiController,
|
|
||||||
oauthController,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,46 @@
|
|||||||
|
import { AppsStatus } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||||
import { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
import { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
||||||
|
|
||||||
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
|
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
|
||||||
|
|
||||||
const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
|
const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
|
||||||
return Promise.all(
|
const createdBookings: BookingResponse[] = [];
|
||||||
data.map((booking, key) => {
|
// Reversing to accumulate results for noEmail instances first, to then lastly, create the
|
||||||
// We only want to send the first occurrence of the meeting at the moment, not all at once
|
// emailed booking taking into account accumulated results to send app status accurately
|
||||||
if (key === 0) {
|
for (let key = 0; key < data.length; key++) {
|
||||||
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", booking);
|
const booking = data[key];
|
||||||
} else {
|
if (key === data.length - 1) {
|
||||||
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", {
|
const calcAppsStatus: { [key: string]: AppsStatus } = createdBookings
|
||||||
...booking,
|
.flatMap((book) => (book.appsStatus !== undefined ? book.appsStatus : []))
|
||||||
noEmail: true,
|
.reduce((prev, curr) => {
|
||||||
});
|
if (prev[curr.type]) {
|
||||||
}
|
prev[curr.type].failures += curr.failures;
|
||||||
})
|
prev[curr.type].success += curr.success;
|
||||||
);
|
} else {
|
||||||
|
prev[curr.type] = curr;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
}, {} as { [key: string]: AppsStatus });
|
||||||
|
const appsStatus = Object.values(calcAppsStatus);
|
||||||
|
const response = await fetch.post<
|
||||||
|
ExtendedBookingCreateBody & { appsStatus: AppsStatus[] },
|
||||||
|
BookingResponse
|
||||||
|
>("/api/book/event", {
|
||||||
|
...booking,
|
||||||
|
appsStatus,
|
||||||
|
});
|
||||||
|
createdBookings.push(response);
|
||||||
|
} else {
|
||||||
|
const response = await fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", {
|
||||||
|
...booking,
|
||||||
|
noEmail: true,
|
||||||
|
});
|
||||||
|
createdBookings.push(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createdBookings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default createRecurringBooking;
|
export default createRecurringBooking;
|
||||||
@@ -20,6 +20,15 @@ export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
|||||||
return processDate(date, i18n);
|
return processDate(date, i18n);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// tzid is currently broken in rrule library.
|
||||||
|
// @see https://github.com/jakubroztocil/rrule/issues/523
|
||||||
|
const dateWithZone = (d: Date, timeZone?: string) => {
|
||||||
|
const dateInLocalTZ = new Date(d.toLocaleString("en-US", { timeZone: "UTC" }));
|
||||||
|
const dateInTargetTZ = new Date(d.toLocaleString("en-US", { timeZone: timeZone || "UTC" }));
|
||||||
|
const tzOffset = dateInTargetTZ.getTime() - dateInLocalTZ.getTime();
|
||||||
|
return new Date(d.getTime() - tzOffset);
|
||||||
|
};
|
||||||
|
|
||||||
export const parseRecurringDates = (
|
export const parseRecurringDates = (
|
||||||
{
|
{
|
||||||
startDate,
|
startDate,
|
||||||
@@ -39,22 +48,41 @@ export const parseRecurringDates = (
|
|||||||
const rule = new RRule({
|
const rule = new RRule({
|
||||||
...restRecurringEvent,
|
...restRecurringEvent,
|
||||||
count: recurringCount,
|
count: recurringCount,
|
||||||
dtstart: dayjs(startDate).toDate(),
|
dtstart: dayjs(startDate).utc(true).toDate(),
|
||||||
});
|
});
|
||||||
const dateStrings = rule.all().map((r) => {
|
// UTC times with tzOffset applied to account for DST
|
||||||
return processDate(dayjs(r).tz(timeZone), i18n);
|
const times = rule.all().map((t) => dateWithZone(t, timeZone));
|
||||||
|
const dateStrings = times.map((t) => {
|
||||||
|
// undo DST diffs for localized display.
|
||||||
|
return processDate(dayjs.utc(t).tz(timeZone), i18n);
|
||||||
});
|
});
|
||||||
return [dateStrings, rule.all()];
|
|
||||||
|
return [dateStrings, times];
|
||||||
};
|
};
|
||||||
|
|
||||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
|
||||||
|
|
||||||
export const extractRecurringDates = (
|
export const extractRecurringDates = (
|
||||||
bookings: BookingItem[],
|
booking: inferQueryOutput<"viewer.bookings">["bookings"][number] & {
|
||||||
|
eventType: { recurringEvent: RecurringEvent | null };
|
||||||
|
recurringEventId: string | null;
|
||||||
|
recurringBookings: inferQueryOutput<"viewer.bookings">["recurringInfo"];
|
||||||
|
},
|
||||||
timeZone: string | undefined,
|
timeZone: string | undefined,
|
||||||
i18n: I18n
|
i18n: I18n
|
||||||
): [string[], Date[]] => {
|
): [string[], Date[]] => {
|
||||||
const dateStrings = bookings.map((booking) => processDate(dayjs(booking.startTime).tz(timeZone), i18n));
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const allDates = dateStrings.map((dateString) => dayjs(dateString).tz(timeZone).toDate());
|
const { count = 0, ...rest } =
|
||||||
|
booking.eventType.recurringEvent !== null ? booking.eventType.recurringEvent : {};
|
||||||
|
const recurringInfo = booking.recurringBookings.find(
|
||||||
|
(val) => val.recurringEventId === booking.recurringEventId
|
||||||
|
);
|
||||||
|
const allDates = new RRule({
|
||||||
|
...rest,
|
||||||
|
count: recurringInfo?._count.recurringEventId,
|
||||||
|
dtstart: recurringInfo?._min.startTime,
|
||||||
|
}).all();
|
||||||
|
const utcOffset = dayjs(recurringInfo?._min.startTime).tz(timeZone).utcOffset();
|
||||||
|
const dateStrings = allDates.map((r) => {
|
||||||
|
return processDate(dayjs.utc(r).utcOffset(utcOffset), i18n);
|
||||||
|
});
|
||||||
return [dateStrings, allDates];
|
return [dateStrings, allDates];
|
||||||
};
|
};
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
import { TRPCError } from "@calcom/trpc/server";
|
|
||||||
|
|
||||||
import { BASE_URL } from "@lib/config/constants";
|
|
||||||
|
|
||||||
export const samlDatabaseUrl = process.env.SAML_DATABASE_URL || "";
|
|
||||||
export const samlLoginUrl = BASE_URL;
|
|
||||||
|
|
||||||
export const isSAMLLoginEnabled = samlDatabaseUrl.length > 0;
|
|
||||||
|
|
||||||
export const samlTenantID = "Cal.com";
|
|
||||||
export const samlProductID = "Cal.com";
|
|
||||||
|
|
||||||
const samlAdmins = (process.env.SAML_ADMINS || "").split(",");
|
|
||||||
export const hostedCal = BASE_URL === "https://app.cal.com";
|
|
||||||
export const tenantPrefix = "team-";
|
|
||||||
|
|
||||||
export const isSAMLAdmin = (email: string) => {
|
|
||||||
for (const admin of samlAdmins) {
|
|
||||||
if (admin.toLowerCase() === email.toLowerCase() && admin.toUpperCase() === email.toUpperCase()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const samlTenantProduct = async (prisma: PrismaClient, email: string) => {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
invitedTo: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message: "Unauthorized Request",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.invitedTo) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message:
|
|
||||||
"Could not find a SAML Identity Provider for your email. Please contact your admin to ensure you have been given access to Cal",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tenant: tenantPrefix + user.invitedTo,
|
|
||||||
product: samlProductID,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Attendee, Booking } from "@prisma/client";
|
import { Attendee, Booking } from "@prisma/client";
|
||||||
|
|
||||||
|
import { AppsStatus } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
export type BookingCreateBody = {
|
export type BookingCreateBody = {
|
||||||
email: string;
|
email: string;
|
||||||
end: string;
|
end: string;
|
||||||
@@ -33,4 +35,5 @@ export type BookingCreateBody = {
|
|||||||
export type BookingResponse = Booking & {
|
export type BookingResponse = Booking & {
|
||||||
paymentUid?: string;
|
paymentUid?: string;
|
||||||
attendees: Attendee[];
|
attendees: Attendee[];
|
||||||
|
appsStatus?: AppsStatus[];
|
||||||
};
|
};
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { WebhookTriggerEvents } from "@prisma/client";
|
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
|
||||||
|
|
||||||
export type GetSubscriberOptions = {
|
|
||||||
userId: number;
|
|
||||||
eventTypeId: number;
|
|
||||||
triggerEvent: WebhookTriggerEvents;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @deprecated use `packages/lib/webhooks/subscriptions.tsx` */
|
|
||||||
const getWebhooks = async (options: GetSubscriberOptions) => {
|
|
||||||
const { userId, eventTypeId } = options;
|
|
||||||
const allWebhooks = await prisma.webhook.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
eventTypeId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
AND: {
|
|
||||||
eventTriggers: {
|
|
||||||
has: options.triggerEvent,
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
equals: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
subscriberUrl: true,
|
|
||||||
payloadTemplate: true,
|
|
||||||
appId: true,
|
|
||||||
secret: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return allWebhooks;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default getWebhooks;
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||||
|
|
||||||
|
export type EmbedProps = {
|
||||||
|
isEmbed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
||||||
|
return async (context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<EmbedProps>> => {
|
||||||
|
const ssrResponse = await getServerSideProps(context);
|
||||||
|
if (!("props" in ssrResponse)) {
|
||||||
|
return ssrResponse;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...ssrResponse,
|
||||||
|
props: {
|
||||||
|
...ssrResponse.props,
|
||||||
|
isEmbed: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Loaded 100 of 748 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user