Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f9011a167 | ||
|
|
ae004c8a6c | ||
|
|
ccbb16fe76 | ||
|
|
b77ce4a010 | ||
|
|
f72f709e34 | ||
|
|
37ceb25f8c | ||
|
|
0141d8f954 | ||
|
|
47fe791006 | ||
|
|
0cbaba73e4 | ||
|
|
aaac491dc7 | ||
|
|
fc4203e963 | ||
|
|
8430e9612e | ||
|
|
de81934619 | ||
|
|
5aedcbee55 | ||
|
|
7bc8d7e991 | ||
|
|
df183f5882 | ||
|
|
27d9fad292 | ||
|
|
bed798d440 | ||
|
|
e5a8a4e34d | ||
|
|
de446db434 | ||
|
|
81546412bf | ||
|
|
459530fba3 | ||
|
|
42766b42c0 | ||
|
|
38b3563350 | ||
|
|
37159914cf | ||
|
|
268bf68532 | ||
|
|
b02c7e01d5 | ||
|
|
45d10a3bc4 | ||
|
|
9250b20c1d | ||
|
|
d9da311141 | ||
|
|
89a52dad21 | ||
|
|
2a1acc1294 | ||
|
|
8302990f84 | ||
|
|
5a03b61d63 | ||
|
|
6b6f239936 | ||
|
|
9c2748e8e9 | ||
|
|
bb5cc33737 | ||
|
|
5616a64827 | ||
|
|
33530ad8c8 | ||
|
|
fe14839f1b | ||
|
|
15123c1aa5 | ||
|
|
71ee9c72fe | ||
|
|
5503539856 | ||
|
|
6548f01efa | ||
|
|
518a4484d6 | ||
|
|
3535a25a59 | ||
|
|
06cc6741e6 | ||
|
|
ed1478e1ba | ||
|
|
854c05d673 | ||
|
|
bcf5fb18c5 | ||
|
|
3f822e814b | ||
|
|
4fd25c7215 | ||
|
|
f42bed2a55 | ||
|
|
469c205033 | ||
|
|
31551f3b9e | ||
|
|
69925af17c | ||
|
|
2502868ebc | ||
|
|
38d242add9 | ||
|
|
c272e281cf | ||
|
|
d4e19b0fa4 | ||
|
|
c12877a58f | ||
|
|
af83350d04 | ||
|
|
9d0cf6be0b | ||
|
|
2fe9c782d4 | ||
|
|
d0a3d8d144 | ||
|
|
cefcb1eaf8 | ||
|
|
83b60de640 | ||
|
|
a076418989 | ||
|
|
64ecce1ccb | ||
|
|
cd56a5d4f0 | ||
|
|
d5ca121ac6 | ||
|
|
1e20e80b65 | ||
|
|
75ac3fd40c | ||
|
|
3b91cc7f02 | ||
|
|
23450b61e0 | ||
|
|
dcc226976c | ||
|
|
196fde72f6 | ||
|
|
1e913cd406 | ||
|
|
02425702d1 | ||
|
|
2494655369 | ||
|
|
0e0a78c47e | ||
|
|
ed02c10f54 | ||
|
|
9c40f76184 | ||
|
|
8eda30d88c | ||
|
|
9de19d35a8 | ||
|
|
0770403ca5 | ||
|
|
1a45cb35c1 | ||
|
|
15cdff76d9 | ||
|
|
8c4eb4b5fe | ||
|
|
ae30aafafe | ||
|
|
3b74717591 | ||
|
|
7a6349b4c0 | ||
|
|
3b51fff88d | ||
|
+5 |
2a0b0be9d0 | ||
|
|
f373c9d818 | ||
|
|
540bf3a1cb | ||
|
|
2d9064e92f | ||
|
|
9487819ec1 | ||
|
|
49bf14b196 | ||
|
|
344f16d6cc | ||
|
|
ccbf3b1e54 | ||
|
|
dcf0fad35d | ||
|
|
a836800c69 | ||
|
|
b8c6ac43f7 | ||
|
|
f5e0c6b384 | ||
|
|
2a5dd9035f | ||
|
|
2cddad4336 | ||
|
|
4bcbff51b1 | ||
|
|
ae541e4c37 | ||
|
|
f254711d85 | ||
|
|
533e208643 | ||
|
|
795574ef88 | ||
|
|
48611d5c99 | ||
|
|
11a07961d0 | ||
|
|
6be0fd52e7 | ||
|
|
78da7ba338 | ||
|
|
9a72c648c5 | ||
|
|
3e42da7fc7 | ||
|
|
1d986cad04 | ||
|
|
f603e4eff2 | ||
|
|
1f4e6c1987 | ||
|
|
e57f734e79 | ||
|
|
7fad76ae23 | ||
|
|
7659a016b4 | ||
|
|
34fe1c4a95 | ||
|
|
205f73c4bc | ||
|
|
c4bd2167e1 | ||
|
|
49a7111b66 | ||
|
|
ca045a040b | ||
|
|
a8b4198bf7 | ||
|
|
2ab24e530b | ||
|
|
734b06dfdf | ||
|
|
467084993b | ||
|
|
1e2c8516d2 | ||
|
|
d57738f325 | ||
|
|
c39a7dba85 | ||
|
|
fd37397e5c | ||
|
|
df0a522656 | ||
|
|
916468b8cc | ||
|
|
b789e58624 | ||
|
|
4368536141 | ||
|
|
9eef9766ce | ||
|
|
68dbfbd7d0 | ||
|
|
692a84a86e | ||
|
|
1671095c94 | ||
|
|
3b896bee5c | ||
|
|
cc1985442e | ||
|
|
630f438b32 | ||
|
|
6fae01d45a | ||
|
|
7009373b11 | ||
|
|
c2f150dbab | ||
|
|
688541923b | ||
|
|
20402ac26c | ||
|
|
a54952b41a | ||
|
|
987756c4d4 | ||
|
|
3ee294ffc5 | ||
|
|
a638708871 | ||
|
|
3456cc5521 | ||
|
|
546f0c3881 | ||
|
|
7b18272d60 | ||
|
|
cf6efabdbb | ||
|
|
92d15a00f5 | ||
|
|
2f2b72dd54 | ||
|
|
8fd5d6b5b5 | ||
|
|
2078f12a85 | ||
|
|
77b8eb8a7f | ||
|
|
463787b607 | ||
|
|
1f4e7ca695 | ||
|
|
9eb52bbfb2 | ||
|
|
61e6f76bc6 | ||
|
|
2e6c3a6068 | ||
|
|
8f48ecafec | ||
|
|
8849d1148c | ||
|
|
98a4684a2b | ||
|
|
da0bdfacae | ||
|
|
68c7425922 | ||
|
|
c3418cbe1f | ||
|
|
3a641e0bc1 | ||
|
|
b7fcf6ade1 | ||
|
|
53d160e495 | ||
|
|
df4df6846b | ||
|
|
c1bbb9b139 | ||
|
|
56c07ec351 | ||
|
|
3080b47aec | ||
|
|
c1e23bfdc6 | ||
|
|
e7ff5508d7 | ||
|
|
5850d227ee | ||
|
|
ede987ba19 | ||
|
|
bc0d83164c | ||
|
|
30f0b080c5 | ||
|
|
ee39ea316f | ||
|
|
fbc2708259 | ||
|
|
9bb83aba7e | ||
|
|
3a67e5617e | ||
|
|
f401aa7669 | ||
|
|
a6e0678e59 | ||
|
|
e832015f26 | ||
|
|
5e86881e15 | ||
|
|
3b71f98460 | ||
|
|
144d57adb2 | ||
|
|
e1f1c15318 | ||
|
|
d88b1b2f8f | ||
|
|
2e61c15f2c | ||
|
|
df73473c7f | ||
|
|
7167973afd | ||
|
|
3c17bc2d1a | ||
|
|
a9a295dc54 | ||
|
+2 |
21dd1f4e95 | ||
|
|
5d775bd4a3 | ||
|
|
4e02b22472 | ||
|
|
4686d3ff48 | ||
|
|
02e9d8d45f | ||
|
|
7ce5096f13 | ||
|
|
83e51e9b9d | ||
|
|
dca3278c40 | ||
|
|
7501635fbf | ||
|
|
80c96ea5ac | ||
|
|
8c001e0d34 | ||
|
|
bf275497a2 | ||
|
|
6e351a4b20 | ||
|
|
5360cea3bf | ||
|
+2 |
2f36985462 | ||
|
|
6b26e9a7de | ||
|
|
d7655f0521 | ||
|
|
f4ed345a87 | ||
|
|
c3122c82e4 | ||
|
|
cc9b49131e | ||
|
|
190db1a0f6 | ||
|
|
bd48a72e99 | ||
|
|
a6836a1488 | ||
|
|
2b67ea9635 | ||
|
|
a9117c2d36 | ||
|
+1 |
10d3b5f1b9 | ||
|
|
b757cb6ba1 | ||
|
|
5968e0aa19 | ||
|
|
a06fffc4ec | ||
|
|
3312c84e7c | ||
|
|
d86710d5e9 | ||
|
|
2d8e65c160 | ||
|
|
f74eaf7cb3 | ||
|
|
bcfd57c953 | ||
|
|
c38588b93d | ||
|
|
32ed43574d | ||
|
|
38a5a22b9e | ||
|
|
2120e301c3 | ||
|
|
dd0aec4e97 | ||
|
|
3815b86258 | ||
|
|
7a6545bd03 | ||
|
|
11f467af5d | ||
|
|
0930cf50b6 | ||
|
|
c37cddd0b7 | ||
|
|
09253f83bf | ||
|
|
1cdcebac01 | ||
|
|
58270fa653 | ||
|
|
1a26a254ab | ||
|
|
a36d15894d | ||
|
|
cf077fd33c | ||
|
|
0c979d3604 | ||
|
|
33bc786bb3 | ||
|
|
a7f16462db | ||
|
|
2c7fcbcf51 | ||
|
|
d3e4d26fa5 | ||
|
|
3be6c05722 | ||
|
|
e293e590bd | ||
|
+1 |
d8c1c103a1 | ||
|
|
19d00e691c | ||
|
|
78aa4aecef | ||
|
|
3ab002e547 | ||
|
|
7f461bc275 | ||
|
|
571176102e | ||
|
|
8f1d3fbc50 | ||
|
|
5e88ad50ea | ||
|
|
056406bdd9 | ||
|
|
58af5a18a4 | ||
|
|
90b6581d76 | ||
|
|
b33dcc8841 | ||
|
|
d951a5b872 | ||
|
|
7122a9cfbf | ||
|
|
f1b92c49fc | ||
|
|
ef3ad36c2c | ||
|
|
072ce452b7 | ||
|
|
0e0940f5f0 | ||
|
|
310936125d | ||
|
|
a9df3309b9 | ||
|
|
148d658f14 | ||
|
|
242d571d7d | ||
|
|
dff49ec28a | ||
|
|
0c746ec10f | ||
|
|
901f54ca3a | ||
|
|
16fda5f599 | ||
|
|
d3e9adf382 | ||
|
|
cf559e0726 | ||
|
|
8447b1bbd5 | ||
|
|
0d03006653 | ||
|
|
d44c5fd8b0 | ||
|
|
74aa366f1e | ||
|
|
7ebea57602 | ||
|
|
26d675e162 | ||
|
|
e16d29e916 | ||
|
|
0e89fb9f6a |
@@ -0,0 +1,42 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-outside-of-docker
|
||||
{
|
||||
"name": "Docker outside of Docker",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-from-docker:1": {
|
||||
"version": "latest",
|
||||
"enableNonRootDocker": "true",
|
||||
"moby": "true"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {},
|
||||
"ghcr.io/devcontainers-contrib/features/npm-package:1": {},
|
||||
"ghcr.io/devcontainers-contrib/features/jest:2": {},
|
||||
"ghcr.io/devcontainers-contrib/features/prisma:2": {},
|
||||
"ghcr.io/guiyomh/features/vim:0": {}
|
||||
},
|
||||
|
||||
// Use this environment variable if you need to bind mount your local source code into a new container.
|
||||
"remoteEnv": {
|
||||
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
|
||||
},
|
||||
|
||||
"hostRequirements": {
|
||||
"cpus": 4,
|
||||
"memory": "8gb"
|
||||
},
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "./deploy/install.sh"
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
🚀 Welcome to Cal.com! Try typing 'yarn dx' to get a quick dev envionment.
|
||||
+10
-3
@@ -7,14 +7,13 @@
|
||||
# - E-MAIL SETTINGS
|
||||
|
||||
# - LICENSE *************************************************************************************************
|
||||
# Set this value to 'agree' to accept our license:
|
||||
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
|
||||
# https://github.com/calendso/calendso/blob/main/LICENSE
|
||||
#
|
||||
# Summary of terms:
|
||||
# - The codebase has to stay open source, whether it was modified or not
|
||||
# - You can not repackage or sell the codebase
|
||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
#
|
||||
# To enable enterprise-only features, fill your license key in here.
|
||||
# @see https://console.cal.com
|
||||
CALCOM_LICENSE_KEY=
|
||||
@@ -86,6 +85,9 @@ SENDGRID_EMAIL=
|
||||
TWILIO_SID=
|
||||
TWILIO_TOKEN=
|
||||
TWILIO_MESSAGING_SID=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
NEXT_PUBLIC_SENDER_ID=
|
||||
TWILIO_VERIFY_SID=
|
||||
|
||||
# This is used so we can bypass emails in auth flows for E2E testing
|
||||
# Set it to "1" if you need to run E2E tests locally
|
||||
@@ -140,3 +142,8 @@ SENDGRID_SYNC_API_KEY=
|
||||
# Sentry
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_IGNORE_API_RESOLUTION_ERROR=
|
||||
|
||||
# Change your Brand
|
||||
NEXT_PUBLIC_APP_NAME="Cal.com"
|
||||
NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS="help@cal.com"
|
||||
NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc."
|
||||
|
||||
@@ -3,3 +3,10 @@
|
||||
|
||||
automerge:
|
||||
- apps/web/public/static/locales/**/*.json
|
||||
|
||||
"❗️ migrations":
|
||||
- packages/prisma/migrations/**/migration.sql
|
||||
|
||||
"❗️ .env changes":
|
||||
- .env.example
|
||||
- .env.appStore.example
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
name: Add bugs to Bug Hunting
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- labeled
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
add-to-project:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/9
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
labeled: 🙋🏻♂️help wanted
|
||||
@@ -12,8 +12,5 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
# Checking the actor will prevent your Action run failing on non-Dependabot
|
||||
# PRs but also ensures that it only does work for Dependabot PRs.
|
||||
if: ${{ github.actor == 'github-actions[bot]' }}
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
@@ -11,6 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: main # Always checkout main even for tagged releases
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- run: git push origin +main:production
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<!-- PROJECT LOGO -->
|
||||
<p align="center">
|
||||
<a href="https://github.com/calcom/cal.com">
|
||||
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Cal.com (formerly Calendso)</h3>
|
||||
@@ -37,7 +36,7 @@
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=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/9"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><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://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>
|
||||
@@ -292,7 +291,11 @@ You can deploy Cal on [Railway](https://railway.app/) using the button above. Th
|
||||
|
||||
Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY,NEXT_PUBLIC_LICENSE_CONSENT&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
|
||||
|
||||
### Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/calcom/docker)
|
||||
|
||||
<!-- ROADMAP -->
|
||||
|
||||
@@ -310,7 +313,7 @@ Please see our [contributing guide](/CONTRIBUTING.md).
|
||||
|
||||
### Good First Issues
|
||||
|
||||
We have a list of [help wanted](https://github.com/orgs/calcom/projects/1/views/25) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||
We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||
|
||||
## Integrations
|
||||
|
||||
@@ -354,7 +357,6 @@ following
|
||||
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
|
||||
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
|
||||
|
||||
|
||||
### Obtaining Zoom Client ID and Secret
|
||||
|
||||
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
||||
@@ -419,14 +421,18 @@ following
|
||||
2. Click ‘Get a Twilio phone number’
|
||||
3. Copy Account SID to your .env file into the TWILIO_SID field
|
||||
4. Copy Auth Token to your .env file into the TWILIO_TOKEN field
|
||||
5. Create a messaging service (Develop -> Messaging -> Services)
|
||||
6. Choose any name for the messaging service
|
||||
7. Click 'Add Senders'
|
||||
8. Choose phone number as sender type
|
||||
9. Add the listed phone number
|
||||
10. Leave all other fields as they are
|
||||
11. Complete setup and click ‘View my new Messaging Service’
|
||||
12. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
||||
5. Copy your Twilio phone number to your .env file into the TWILIO_PHONE_NUMBER field
|
||||
6. Add your own sender id to the .env file into the NEXT_PUBLIC_SENDER_ID field (fallback is Cal)
|
||||
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||
8. Choose any name for the messaging service
|
||||
9. Click 'Add Senders'
|
||||
10. Choose phone number as sender type
|
||||
11. Add the listed phone number
|
||||
12. Leave all other fields as they are
|
||||
13. Complete setup and click ‘View my new Messaging Service’
|
||||
14. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
||||
15. Create a verify service
|
||||
16. Copy Verify Service SID to your .env file into the TWILIO_VERIFY_SID field
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* This is a Playwright script Checkly generated for you based on your Vercel project.
|
||||
* To learn more about Browser checks and Playwright visit: https://www.checklyhq.com/docs/browser-checks
|
||||
*/
|
||||
|
||||
// Create a Chromium browser
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
// Checkly supports top level await, but we wrap your code in an async function so you can run it locally too.
|
||||
async function run() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const targetUrl = process.env.ENVIRONMENT_URL || "https://cal.com";
|
||||
await page.goto(`${targetUrl}/rick/test-location-link`);
|
||||
|
||||
await page.waitForSelector('[data-testid="day"][data-disabled="false"]');
|
||||
await page.click('[data-testid="day"][data-disabled="false"]');
|
||||
|
||||
await page.waitForSelector('[data-testid="time"]');
|
||||
await page.click('[data-testid="time"]');
|
||||
|
||||
await page.waitForSelector("#name");
|
||||
await page.click("#name");
|
||||
|
||||
await page.type("#name", "Calcom");
|
||||
|
||||
await page.type('[name="email"]', "cal@cal.com");
|
||||
|
||||
await page.waitForSelector('[data-testid="confirm-book-button"]');
|
||||
await page.click('[data-testid="confirm-book-button"]');
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -33,11 +33,6 @@
|
||||
"generator": "secret",
|
||||
"required": "true"
|
||||
},
|
||||
"NEXT_PUBLIC_LICENSE_CONSENT": {
|
||||
"description": "License consent",
|
||||
"value": "true",
|
||||
"required": "true"
|
||||
},
|
||||
"CRON_API_KEY": {
|
||||
"description": "ApiKey for cronjobs",
|
||||
"value": ""
|
||||
|
||||
+1
-1
Submodule apps/api updated: 055699f612...9c23a8e5ab
+1
-1
Submodule apps/console updated: 9174a0c261...2d577dd9ed
@@ -14,14 +14,13 @@ Cal.com uses [TailwindCSS](https://tailwindcss.com) as a replacement for traditi
|
||||
|
||||
CSS files should be stored in the `styles` directory within the codebase.
|
||||
|
||||
Within the `styles` directory, you will find two CSS files, `fonts.css` and `global.css`. We suggest not to add to these files, and instead create new CSS files and import them into the application. This helps reduce conflicts when pulling in changes that we've made to either of the existing CSS files.
|
||||
Within the `styles` directory, you will find a single CSS file, `global.css`. We suggest not to add to this file, and instead create new CSS files and import them into the application. This helps reduce conflicts when pulling in changes that we've made to either of the existing CSS files.
|
||||
|
||||
## Adding new stylesheets
|
||||
Firstly, create the CSS file inside the `styles` directory.
|
||||
|
||||
Then, open the `pages/_app.tsx` file, and you will see the following two lines:
|
||||
```javascript
|
||||
import "../styles/fonts.css";
|
||||
import "../styles/globals.css";
|
||||
```
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ module.exports = {
|
||||
stories: [
|
||||
"../intro.stories.mdx",
|
||||
"../../../packages/ui/components/**/*.stories.mdx",
|
||||
"../../../packages/features/**/*.stories.mdx",
|
||||
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
|
||||
],
|
||||
addons: [
|
||||
@@ -50,6 +51,21 @@ module.exports = {
|
||||
vm: false,
|
||||
zlib: false,
|
||||
};
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
"style-loader",
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: true, // Enable modules to help you using className
|
||||
},
|
||||
},
|
||||
],
|
||||
include: path.resolve(__dirname, "../src"),
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,7 +15,11 @@ export const parameters = {
|
||||
},
|
||||
};
|
||||
|
||||
addDecorator((storyFn) => <I18nextProvider i18n={i18n}>{storyFn()}</I18nextProvider>);
|
||||
addDecorator((storyFn) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<div style={{ margin: "2rem" }}>{storyFn()}</div>
|
||||
</I18nextProvider>
|
||||
));
|
||||
|
||||
window.getEmbedNamespace = () => {
|
||||
const url = new URL(document.URL);
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import "../../../packages/ui/styles/shared-globals.css"
|
||||
@import "../../../packages/ui/styles/shared-globals.css"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url("../../../packages/features/calendars/weeklyview/styles/styles.css");
|
||||
|
||||
.sbdocs {
|
||||
font-family: 'Inter var' !important;
|
||||
padding: 0!important;
|
||||
@@ -36,7 +38,7 @@
|
||||
|
||||
/** Docs table **/
|
||||
.custom-args-wrapper{
|
||||
max-height: 600px;
|
||||
max-height: 400px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
margin-bottom: 1rem;
|
||||
@@ -187,3 +189,7 @@
|
||||
padding: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-story {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
14.17
|
||||
v16
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextRouter, useRouter } from "next/router";
|
||||
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
||||
import { components, ControlProps } from "react-select";
|
||||
|
||||
import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Button,
|
||||
@@ -20,8 +21,6 @@ import {
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||
|
||||
import ColorPicker from "@components/ui/colorpicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
@@ -45,6 +44,7 @@ type PreviewState = {
|
||||
palette: {
|
||||
brandColor: string;
|
||||
};
|
||||
hideEventTypeDetails: boolean;
|
||||
};
|
||||
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
||||
|
||||
@@ -126,10 +126,12 @@ const getEmbedUIInstructionString = ({
|
||||
apiName,
|
||||
theme,
|
||||
brandColor,
|
||||
hideEventTypeDetails,
|
||||
}: {
|
||||
apiName: string;
|
||||
theme?: string;
|
||||
brandColor: string;
|
||||
hideEventTypeDetails: boolean;
|
||||
}) => {
|
||||
theme = theme !== "auto" ? theme : undefined;
|
||||
return getInstructionString({
|
||||
@@ -142,6 +144,7 @@ const getEmbedUIInstructionString = ({
|
||||
brandColor,
|
||||
},
|
||||
},
|
||||
hideEventTypeDetails: hideEventTypeDetails,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -254,18 +257,21 @@ const getEmbedTypeSpecificString = ({
|
||||
apiName: string;
|
||||
theme: PreviewState["theme"];
|
||||
brandColor: string;
|
||||
hideEventTypeDetails: boolean;
|
||||
};
|
||||
if (embedFramework === "react") {
|
||||
uiInstructionStringArg = {
|
||||
apiName: "cal",
|
||||
theme: previewState.theme,
|
||||
brandColor: previewState.palette.brandColor,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
};
|
||||
} else {
|
||||
uiInstructionStringArg = {
|
||||
apiName: "Cal",
|
||||
theme: previewState.theme,
|
||||
brandColor: previewState.palette.brandColor,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
};
|
||||
}
|
||||
if (!frameworkCodes[embedType]) {
|
||||
@@ -498,7 +504,9 @@ const tabs = [
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
||||
<small className="flex py-4 text-neutral-500">
|
||||
{t("place_where_cal_widget_appear", { appName: APP_NAME })}
|
||||
</small>
|
||||
</div>
|
||||
<TextArea
|
||||
data-testid="embed-code"
|
||||
@@ -619,19 +627,19 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DialogContent type="creation" useOwnActionButtons size="lg">
|
||||
<DialogContent type="creation" size="lg">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("how_you_want_add_cal_site")}
|
||||
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site")}</p>
|
||||
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site", { appName: APP_NAME })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
{embeds.map((embed, index) => (
|
||||
<button
|
||||
className="mr-2 w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-neutral-100"
|
||||
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-neutral-100 ltr:mr-2 rtl:ml-2"
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
@@ -688,6 +696,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
theme: Theme.auto,
|
||||
floatingPopup: {},
|
||||
elementClick: {},
|
||||
hideEventTypeDetails: false,
|
||||
palette: {
|
||||
brandColor: "#000000",
|
||||
},
|
||||
@@ -752,6 +761,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
name: "ui",
|
||||
arg: {
|
||||
theme: previewState.theme,
|
||||
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||
styles: {
|
||||
branding: {
|
||||
...previewState.palette,
|
||||
@@ -797,7 +807,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<DialogContent size="xl" className="p-0.5" type="creation" useOwnActionButtons>
|
||||
<DialogContent size="xl" className="p-0.5" type="creation">
|
||||
<div className="flex">
|
||||
<div className="flex w-1/3 flex-col bg-gray-50 p-8">
|
||||
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||
@@ -810,7 +820,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
{embed.title}
|
||||
</h3>
|
||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
|
||||
<div className="max-h-97 flex flex-col overflow-y-auto">
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
@@ -837,7 +847,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<div className="text-sm">Embed Window Sizing</div>
|
||||
<div className="justify-left flex items-center">
|
||||
<TextField
|
||||
name="width"
|
||||
labelProps={{ className: "hidden" }}
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
@@ -859,7 +868,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<span className="p-2">×</span>
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
name="height"
|
||||
value={previewState.inline.height}
|
||||
required
|
||||
onChange={(e) => {
|
||||
@@ -887,7 +895,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<div className="mb-2 text-sm">Button Text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
name="buttonText"
|
||||
labelProps={{ className: "hidden" }}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
@@ -907,7 +914,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-start",
|
||||
embedType === "floating-popup" ? "space-x-2" : "hidden"
|
||||
embedType === "floating-popup" ? "space-x-2 rtl:space-x-reverse" : "hidden"
|
||||
)}>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
@@ -1003,6 +1010,20 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<div className="mb-4 flex items-center justify-start space-x-2 rtl:space-x-reverse">
|
||||
<Switch
|
||||
checked={previewState.hideEventTypeDetails}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
hideEventTypeDetails: checked,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm">{t("hide_eventtype_details")}</div>
|
||||
</div>
|
||||
<Label className="">
|
||||
<div className="mb-2">Theme</div>
|
||||
<Select
|
||||
@@ -1095,9 +1116,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
{t("copy_code")}
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("Close")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getDirFromLang } from "@calcom/lib/i18n";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export function useViewerI18n() {
|
||||
@@ -21,12 +22,16 @@ export function useViewerI18n() {
|
||||
*/
|
||||
const I18nLanguageHandler = (): null => {
|
||||
const { i18n } = useTranslation("common");
|
||||
const locale = useViewerI18n().data?.locale;
|
||||
const locale = useViewerI18n().data?.locale || "en";
|
||||
|
||||
useEffect(() => {
|
||||
if (locale && i18n.language && i18n.language !== locale) {
|
||||
if (typeof i18n.changeLanguage === "function") i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
const dir = getDirFromLang(locale);
|
||||
document.documentElement.setAttribute("lang", locale);
|
||||
document.documentElement.setAttribute("dir", dir);
|
||||
}, [locale, i18n]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -156,12 +156,8 @@ export default function ImageUploader({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</DialogClose>
|
||||
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -61,12 +61,10 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
<Select
|
||||
name="additionalCalendar"
|
||||
placeholder={
|
||||
<div className="flex justify-start text-black">
|
||||
<Icon.FiPlus className="-ml-1 h-4 w-4 ltr:mr-2 rtl:ml-2 rtl:-mr-1" />
|
||||
<p>{t("add")}</p>
|
||||
</div>
|
||||
<Button StartIcon={Icon.FiPlus} color="secondary">
|
||||
{t("add")}
|
||||
</Button>
|
||||
}
|
||||
className="min-w-44"
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
isLoading={isLoading}
|
||||
@@ -80,11 +78,16 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
width: "max-content",
|
||||
right: "0",
|
||||
}),
|
||||
indicatorSeparator: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
display: "none",
|
||||
}),
|
||||
control: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
padding: "0",
|
||||
border: "0",
|
||||
borderRadius: "inherit",
|
||||
borderRadius: "6px",
|
||||
minHeight: "auto",
|
||||
}),
|
||||
dropdownIndicator: (defaultStyles) => ({
|
||||
...defaultStyles,
|
||||
|
||||
@@ -4,22 +4,14 @@ import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import {
|
||||
Button,
|
||||
DisconnectIntegration,
|
||||
Icon,
|
||||
Shell,
|
||||
showToast,
|
||||
SkeletonButton,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import HeadSeo from "@components/seo/head-seo";
|
||||
import { Button, Icon, Shell, showToast, SkeletonButton, SkeletonText, HeadSeo } from "@calcom/ui";
|
||||
|
||||
const Component = ({
|
||||
name,
|
||||
@@ -48,7 +40,7 @@ const Component = ({
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data.setupPending) return;
|
||||
if (data?.setupPending) return;
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -72,7 +64,9 @@ const Component = ({
|
||||
}
|
||||
);
|
||||
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
||||
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
|
||||
// Such apps, can only be installed once.
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
@@ -201,7 +195,7 @@ const Component = ({
|
||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||
<span>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
t("free_to_use_apps")
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
@@ -279,8 +273,10 @@ const Component = ({
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-8" />
|
||||
<span className="leading-1 block text-xs text-gray-500">{t("every_app_published")}</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<span className="leading-1 block text-xs text-gray-500">
|
||||
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
|
||||
</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
|
||||
<Icon.FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,19 @@ import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
DisconnectIntegration,
|
||||
EmptyScreen,
|
||||
Icon,
|
||||
List,
|
||||
ShellSubHeading,
|
||||
showToast,
|
||||
SkeletonLoader,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
Switch,
|
||||
} from "@calcom/ui";
|
||||
|
||||
@@ -159,6 +159,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
if (!data.connectedCalendars.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
@@ -168,7 +169,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
slug={item.integration.slug}
|
||||
title={item.integration.title}
|
||||
logo={item.integration.logo}
|
||||
description={item.primary?.externalId || "No external Id"}
|
||||
description={item.primary?.email ?? item.integration.description}
|
||||
separate={true}
|
||||
actions={
|
||||
<div className="flex w-32 justify-end">
|
||||
|
||||
@@ -44,7 +44,7 @@ function IntegrationListItem(props: {
|
||||
expanded={!!props.children}
|
||||
className={classNames(
|
||||
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
||||
"my-0 flex-col border transition-colors duration-500 ",
|
||||
"my-0 flex-col border transition-colors duration-500",
|
||||
highlight ? "bg-yellow-100" : ""
|
||||
)}>
|
||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
||||
@@ -56,7 +56,7 @@ function IntegrationListItem(props: {
|
||||
<ListItemText component="p">{props.description}</ListItemText>
|
||||
{/* Alert error that key stopped working. */}
|
||||
{props.invalidCredential && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<Icon.FiAlertCircle className="w-8 text-red-500 sm:w-4" />
|
||||
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||
{t("invalid_credential")}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ComponentProps } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen, Icon, Shell, HorizontalTabs, HorizontalTabItemProps } from "@calcom/ui";
|
||||
|
||||
const tabs: HorizontalTabItemProps[] = [
|
||||
{
|
||||
name: "app_store",
|
||||
href: "/apps",
|
||||
},
|
||||
{
|
||||
name: "installed_apps",
|
||||
href: "/apps/installed",
|
||||
},
|
||||
];
|
||||
|
||||
type AppsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
actions?: (className?: string) => JSX.Element;
|
||||
emptyStore?: boolean;
|
||||
} & Omit<ComponentProps<typeof Shell>, "actions">;
|
||||
|
||||
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
|
||||
return (
|
||||
<Shell {...rest} actions={actions?.("hidden sm:block")}>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<div className="block lg:hidden">
|
||||
<HorizontalTabs tabs={tabs} actions={actions?.("ml-6 mr-4 mt-3 block sm:hidden")} />
|
||||
</div>
|
||||
<main className="w-full">
|
||||
{emptyStore ? (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiAlertCircle}
|
||||
headline={t("no_apps")}
|
||||
description={session.data?.user.role === "ADMIN" ? "You can enable apps in the settings" : ""}
|
||||
buttonText={session.data?.user.role === "ADMIN" ? t("apps_settings") : ""}
|
||||
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
|
||||
/>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
export const getLayout = (page: React.ReactElement) => <AppsLayout>{page}</AppsLayout>;
|
||||
+10
-16
@@ -1,14 +1,11 @@
|
||||
import React, { ComponentProps } from "react";
|
||||
|
||||
import AppCategoryNavigation from "@calcom/app-store/_components/AppCategoryNavigation";
|
||||
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { Icon } from "../../../Icon";
|
||||
import Shell from "../Shell";
|
||||
import type { HorizontalTabItemProps } from "../navigation/tabs/HorizontalTabItem";
|
||||
import HorizontalTabs from "../navigation/tabs/HorizontalTabs";
|
||||
import type { VerticalTabItemProps } from "../navigation/tabs/VerticalTabItem";
|
||||
import VerticalTabs from "../navigation/tabs/VerticalTabs";
|
||||
import { Icon, Shell } from "@calcom/ui";
|
||||
import type { HorizontalTabItemProps } from "@calcom/ui/v2/core/navigation/tabs/HorizontalTabItem";
|
||||
import type { VerticalTabItemProps } from "@calcom/ui/v2/core/navigation/tabs/VerticalTabItem";
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
@@ -55,17 +52,14 @@ export default function InstalledAppsLayout({
|
||||
if (query.data?.items.length === 0) {
|
||||
actualTabs = tabs.filter((tab) => tab.name !== InstalledAppVariants.payment);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell {...rest}>
|
||||
<div className="flex flex-col p-2 md:p-0 xl:flex-row">
|
||||
<div className="hidden xl:block">
|
||||
<VerticalTabs tabs={actualTabs} sticky linkProps={{ shallow: true }} />
|
||||
</div>
|
||||
<div className="block xl:hidden">
|
||||
<HorizontalTabs tabs={actualTabs} linkProps={{ shallow: true }} />
|
||||
</div>
|
||||
<main className="w-full xl:mx-5 xl:w-4/5 xl:pr-5">{children}</main>
|
||||
</div>
|
||||
<AppCategoryNavigation
|
||||
baseURL="/apps/installed"
|
||||
containerClassname="w-full xl:mx-5 xl:w-4/5 xl:max-w-2xl xl:pr-5">
|
||||
{children}
|
||||
</AppCategoryNavigation>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -2,9 +2,9 @@ import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ComponentProps, useEffect } from "react";
|
||||
|
||||
import ErrorBoundary from "../../../ErrorBoundary";
|
||||
import Shell from "../Shell";
|
||||
import SettingsLayout from "./SettingsLayout";
|
||||
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { ErrorBoundary, Shell } from "@calcom/ui";
|
||||
|
||||
import { UserPermissionRole } from ".prisma/client";
|
||||
|
||||
export default function AdminLayout({
|
||||
@@ -23,7 +23,7 @@ export default function AdminLayout({
|
||||
|
||||
return (
|
||||
<SettingsLayout {...rest}>
|
||||
<div className="mx-auto flex max-w-4xl flex-row divide-y divide-gray-200 md:px-12">
|
||||
<div className="mx-auto flex max-w-4xl flex-row divide-y divide-gray-200">
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</div>
|
||||
@@ -1,10 +1,13 @@
|
||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon, Tooltip } from "@calcom/ui";
|
||||
|
||||
import { Props } from "./pages/AvailabilityPage";
|
||||
|
||||
export function AvailableEventLocations({ locations }: { locations: Props["eventType"]["locations"] }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return locations.length ? (
|
||||
<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) => {
|
||||
@@ -16,19 +19,19 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
||||
return (
|
||||
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<Icon.FiLink className="dark:text-darkgray-600 mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 " />
|
||||
<Icon.FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||
) : (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"mr-[10px] ml-[2px] h-4 w-4 opacity-70 dark:opacity-100 ",
|
||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={locationKeyToString(location)}>
|
||||
<p className="truncate">{locationKeyToString(location)}</p>
|
||||
<Tooltip content={t(locationKeyToString(location) ?? "")}>
|
||||
<p className="truncate">{t(locationKeyToString(location) ?? "")}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
||||
</span>
|
||||
<span className="text-bookinglight font-medium">
|
||||
, {date.toDate().toLocaleString(i18n.language, { month: "long" })} {date.format(" D ")}
|
||||
, {date.toDate().toLocaleString(i18n.language, { month: "short" })} {date.format(" D ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
@@ -71,7 +71,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mb-5 grid flex-grow grid-cols-1 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
||||
<div className="flex-grow overflow-y-auto sm:block md:h-[364px]">
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
@@ -101,7 +101,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={dayjs(slot.time).format()}>
|
||||
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
||||
{/* ^ data-slot-owner is helpful in debugging and used to identify the owners of the slot. Owners are the users which have the timeslot in their schedule. It doesn't consider if a user has that timeslot booked */}
|
||||
{/* Current there is no way to disable Next.js Links */}
|
||||
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { FC, ReactNode, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Icon, Badge } from "@calcom/ui";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import { UserAvatars } from "@components/booking/UserAvatars";
|
||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||
@@ -39,7 +42,34 @@ interface Props {
|
||||
|
||||
const BookingDescription: FC<Props> = (props) => {
|
||||
const { profile, eventType, isBookingPage = false, children } = props;
|
||||
const { date: bookingDate } = useRouterQuery("date");
|
||||
const { t } = useLocale();
|
||||
const { duration = eventType.length.toString(), setQuery: setDuration } = useRouterQuery("duration");
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
eventType.metadata?.multipleDuration &&
|
||||
!eventType.metadata?.multipleDuration?.includes(Number(duration))
|
||||
) {
|
||||
setDuration(eventType.length.toString());
|
||||
}
|
||||
}, [duration, setDuration, eventType.length, eventType.metadata?.multipleDuration]);
|
||||
|
||||
let requiresConfirmation = eventType?.requiresConfirmation;
|
||||
let requiresConfirmationText = t("requires_confirmation");
|
||||
const rcThreshold = eventType?.metadata?.requiresConfirmationThreshold;
|
||||
if (rcThreshold) {
|
||||
if (isBookingPage) {
|
||||
if (dayjs(bookingDate).diff(dayjs(), rcThreshold.unit) > rcThreshold.time) {
|
||||
requiresConfirmation = false;
|
||||
}
|
||||
} else {
|
||||
requiresConfirmationText = t("requires_confirmation_threshold", {
|
||||
...rcThreshold,
|
||||
unit: rcThreshold.unit.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<UserAvatars
|
||||
@@ -65,7 +95,7 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
<div>
|
||||
<Icon.FiInfo
|
||||
className={classNames(
|
||||
"mr-[10px] ml-[2px] inline-block h-4 w-4",
|
||||
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
|
||||
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
|
||||
)}
|
||||
/>
|
||||
@@ -75,29 +105,61 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
{requiresConfirmation && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center",
|
||||
"items-top flex",
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
)}>
|
||||
<div>
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
||||
<Icon.FiCheckSquare className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
</div>
|
||||
{t("requires_confirmation")}
|
||||
{requiresConfirmationText}
|
||||
</div>
|
||||
)}
|
||||
<AvailableEventLocations
|
||||
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
||||
/>
|
||||
<p
|
||||
<div
|
||||
className={classNames(
|
||||
"text-sm font-medium",
|
||||
"flex flex-nowrap text-sm font-medium",
|
||||
isBookingPage && "dark:text-darkgray-600 text-gray-600"
|
||||
)}>
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
<Icon.FiClock
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||
isBookingPage && "mt-[2px]"
|
||||
)}
|
||||
/>
|
||||
{eventType.metadata?.multipleDuration !== undefined ? (
|
||||
!isBookingPage ? (
|
||||
<ul className="-mt-1 flex flex-wrap gap-1">
|
||||
{eventType.metadata.multipleDuration.map((dur, idx) => (
|
||||
<li key={idx}>
|
||||
<Badge
|
||||
variant="gray"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
duration === dur.toString()
|
||||
? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast"
|
||||
: "hover:bg-darkgray-200 dark:hover:bg-darkmodebrand hover:text-darkgray-900 dark:hover:text-darkmodebrandcontrast dark:bg-darkgray-200 bg-gray-200 text-gray-900 dark:text-white",
|
||||
"cursor-pointer"
|
||||
)}
|
||||
onClick={() => {
|
||||
setDuration(dur);
|
||||
}}>
|
||||
{dur} {t("minute_timeUnit")}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
`${duration} ${t("minutes")}`
|
||||
)
|
||||
) : (
|
||||
`${eventType.length} ${t("minutes")}`
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
|
||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -22,15 +23,16 @@ import {
|
||||
showToast,
|
||||
TextArea,
|
||||
Tooltip,
|
||||
ActionType,
|
||||
TableActions,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["status"];
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
@@ -48,6 +50,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.status === BookingStatus.REJECTED) {
|
||||
@@ -112,13 +115,24 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
{
|
||||
id: "view_recordings",
|
||||
label: t("view_recordings"),
|
||||
onClick: () => {
|
||||
setViewRecordingsDialogIsOpen(true);
|
||||
},
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
|
||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/success?uid=${booking.uid}&cancel=true${
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}`,
|
||||
icon: Icon.FiX,
|
||||
@@ -158,6 +172,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
|
||||
if (isPast && isPending && !isConfirmed) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
|
||||
}
|
||||
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||
@@ -195,16 +213,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const onClickTableData = () => {
|
||||
router.push({
|
||||
pathname: "/success",
|
||||
pathname: `/booking/${booking.uid}`,
|
||||
query: {
|
||||
uid: booking.uid,
|
||||
allRemainingBookings: isTabRecurring,
|
||||
listingStatus: booking.listingStatus,
|
||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showRecordingsButtons = booking.location === "integrations:daily" && isPast && isConfirmed;
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -218,7 +234,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
/>
|
||||
|
||||
{showRecordingsButtons && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={user?.timeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent>
|
||||
@@ -237,9 +260,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose />
|
||||
|
||||
<Button
|
||||
disabled={mutation.isLoading}
|
||||
@@ -252,7 +273,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<tr className="flex flex-col hover:bg-neutral-50 sm:flex-row">
|
||||
<tr className="group 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:min-w-[12rem]"
|
||||
onClick={onClickTableData}>
|
||||
@@ -279,9 +300,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||
{t("pending_payment")}
|
||||
{booking.paid && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
||||
{t("paid")}
|
||||
</Badge>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
@@ -342,12 +363,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
<span> </span>
|
||||
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
||||
<Badge className="hidden ltr:ml-2 ltr:mr-2 rtl:ml-2 sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
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"
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 truncate text-sm text-gray-600"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
@@ -375,6 +398,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
@@ -530,13 +554,4 @@ const DisplayAttendees = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItem;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { EventType } from "@prisma/client";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
@@ -14,12 +13,13 @@ import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedStyles,
|
||||
useEmbedUiConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import DatePicker from "@calcom/features/calendars/DatePicker";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
@@ -28,41 +28,21 @@ import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon, DatePicker } from "@calcom/ui";
|
||||
import { Icon, HeadSeo } from "@calcom/ui";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
// import { timeZone } from "@lib/clock";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import Gates, { Gate, GateState } from "@components/Gates";
|
||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||
import BookingDescription from "@components/booking/BookingDescription";
|
||||
import TimeOptions from "@components/booking/TimeOptions";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||
|
||||
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
// Get router variables
|
||||
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
|
||||
const router = useRouter();
|
||||
const path = router.asPath.split("/");
|
||||
path.pop(); // Remove the last item (where we currently are)
|
||||
path.shift(); // Removes first item e.g. if we were visitng "/teams/test/30mins" the array will new look like ["teams","test"]
|
||||
const slug = path.join("/");
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<button title={t("profile")} onClick={() => router.replace(`${WEBSITE_URL}/${slug}`)}>
|
||||
<Icon.FiArrowLeft className="dark:text-darkgray-600 h-4 w-4 text-black transition-opacity hover:cursor-pointer" />
|
||||
<p className="sr-only">Go Back</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useSlots = ({
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
@@ -70,6 +50,7 @@ const useSlots = ({
|
||||
endTime,
|
||||
usernameList,
|
||||
timeZone,
|
||||
duration,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
eventTypeSlug: string;
|
||||
@@ -77,6 +58,7 @@ const useSlots = ({
|
||||
endTime?: Dayjs;
|
||||
usernameList: string[];
|
||||
timeZone?: string;
|
||||
duration?: string;
|
||||
}) => {
|
||||
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
||||
{
|
||||
@@ -86,6 +68,7 @@ const useSlots = ({
|
||||
startTime: startTime?.toISOString() || "",
|
||||
endTime: endTime?.toISOString() || "",
|
||||
timeZone,
|
||||
duration,
|
||||
},
|
||||
{
|
||||
enabled: !!startTime && !!endTime,
|
||||
@@ -126,6 +109,7 @@ const SlotPicker = ({
|
||||
}) => {
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||
const { duration } = useRouterQuery("duration");
|
||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||
const router = useRouter();
|
||||
@@ -151,7 +135,7 @@ const SlotPicker = ({
|
||||
setSelectedDate(dayjs.tz(date, timeZone));
|
||||
}
|
||||
}
|
||||
}, [router.isReady, month, date, timeZone]);
|
||||
}, [router.isReady, month, date, duration, timeZone]);
|
||||
|
||||
const { i18n, isLocaleReady } = useLocale();
|
||||
const { slots: _1 } = useSlots({
|
||||
@@ -161,14 +145,19 @@ const SlotPicker = ({
|
||||
startTime: selectedDate?.startOf("day"),
|
||||
endTime: selectedDate?.endOf("day"),
|
||||
timeZone,
|
||||
duration,
|
||||
});
|
||||
const { slots: _2, isLoading } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
usernameList: users,
|
||||
startTime: browsingDate?.startOf("month"),
|
||||
startTime:
|
||||
browsingDate === undefined || browsingDate.get("month") === dayjs.tz(undefined, timeZone).get("month")
|
||||
? dayjs.tz(undefined, timeZone).subtract(2, "days").startOf("day")
|
||||
: browsingDate?.startOf("month"),
|
||||
endTime: browsingDate?.endOf("month"),
|
||||
timeZone,
|
||||
duration,
|
||||
});
|
||||
|
||||
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
||||
@@ -229,14 +218,14 @@ function TimezoneDropdown({
|
||||
|
||||
return (
|
||||
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 inline-block rounded-md px-2 py-2 text-left text-gray-600">
|
||||
<p className="text-sm font-medium">
|
||||
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
|
||||
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
|
||||
<p className="flex items-center text-sm font-medium">
|
||||
<Icon.FiGlobe className="min-h-4 min-w-4 ml-[2px] -mt-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{timeZone}
|
||||
{isTimeOptionsOpen ? (
|
||||
<Icon.FiChevronUp className="ml-1 inline-block h-4 w-4" />
|
||||
<Icon.FiChevronUp className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||
) : (
|
||||
<Icon.FiChevronDown className="ml-1 inline-block h-4 w-4" />
|
||||
<Icon.FiChevronDown className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||
)}
|
||||
</p>
|
||||
</Popover.Trigger>
|
||||
@@ -258,23 +247,6 @@ const dateQuerySchema = z.object({
|
||||
timeZone: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
const useRouterQuery = <T extends string>(name: T) => {
|
||||
const router = useRouter();
|
||||
const existingQueryParams = router.asPath.split("?")[1];
|
||||
|
||||
const urlParams = new URLSearchParams(existingQueryParams);
|
||||
const query = Object.fromEntries(urlParams);
|
||||
|
||||
const setQuery = (newValue: string | number | null | undefined) => {
|
||||
router.replace({ query: { ...router.query, [name]: newValue } }, undefined, { shallow: true });
|
||||
router.replace({ query: { ...router.query, ...query, [name]: newValue } }, undefined, { shallow: true });
|
||||
};
|
||||
|
||||
return { [name]: query[name], setQuery } as {
|
||||
[K in T]: string | undefined;
|
||||
} & { setQuery: typeof setQuery };
|
||||
};
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
@@ -285,6 +257,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
useTheme(profile.theme);
|
||||
const { t } = useLocale();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
//TODO: Plan to remove shouldAlignCentrallyInEmbed config
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
@@ -309,9 +282,6 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||
}, []);
|
||||
|
||||
// TODO: Improve this;
|
||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
@@ -324,7 +294,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
);
|
||||
}
|
||||
}, [telemetry]);
|
||||
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
// get dynamic user list here
|
||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||
|
||||
@@ -337,6 +307,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
|
||||
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||
|
||||
// Define conditional gates here
|
||||
const gates = [
|
||||
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
||||
@@ -370,7 +342,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
<div>
|
||||
<main
|
||||
className={classNames(
|
||||
"flex-col md:mx-4 lg:flex",
|
||||
"flex flex-col md:mx-4",
|
||||
shouldAlignCentrally ? "items-center" : "items-start",
|
||||
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
||||
)}>
|
||||
@@ -385,60 +357,55 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
isEmbed && "mx-auto"
|
||||
)}>
|
||||
<div className="overflow-hidden md:flex">
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
{showEventTypeDetails && (
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</BookingDescription>
|
||||
)}
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</BookingDescription>
|
||||
|
||||
{!isEmbed && (
|
||||
<div className="mt-auto hidden md:block">
|
||||
<GoBackToPreviousPage t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
||||
@@ -446,12 +413,13 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
{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" />
|
||||
<CalendarIcon className="ltr:mr-[10px] rtl:ml-[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>
|
||||
)}
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
@@ -477,7 +445,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
{/* FIXME: We don't show branding in Embed yet because we need to place branding on top of the main content. Keeping it outside the main content would have visibility issues because outside main content background is transparent */}
|
||||
{!restProps.isBrandingHidden && !isEmbed && <PoweredByCal />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -26,11 +26,13 @@ import { LocationObject, LocationType } from "@calcom/core/location";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedUiConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
@@ -38,11 +40,12 @@ import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { AddressInput, Button, EmailInput, Form, Icon, PhoneInput, Tooltip } from "@calcom/ui";
|
||||
import { Group, RadioField } from "@calcom/ui";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { ensureArray } from "@lib/ensureArray";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
@@ -85,10 +88,9 @@ const BookingPage = ({
|
||||
...restProps
|
||||
}: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const { duration: queryDuration } = useRouterQuery("duration");
|
||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const router = useRouter();
|
||||
@@ -103,6 +105,11 @@ const BookingPage = ({
|
||||
{}
|
||||
);
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
// Define duration now that we support multiple duration eventTypes
|
||||
let duration = eventType.length;
|
||||
if (queryDuration && !isNaN(Number(queryDuration))) {
|
||||
duration = Number(queryDuration);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
@@ -131,12 +138,12 @@ const BookingPage = ({
|
||||
}
|
||||
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
pathname: `/booking/${uid}`,
|
||||
query: {
|
||||
uid,
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
formerTime: booking?.startTime.toString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -147,12 +154,12 @@ const BookingPage = ({
|
||||
const { uid } = responseData[0] || {};
|
||||
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
pathname: `/booking/${uid}`,
|
||||
query: {
|
||||
uid,
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
formerTime: booking?.startTime.toString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -237,7 +244,7 @@ const BookingPage = ({
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
email: z.string().trim().email(),
|
||||
phone: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
@@ -294,6 +301,39 @@ const BookingPage = ({
|
||||
}
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
const bookingCustomInputs = Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||
value: booking.customInputs && booking.customInputs[inputId] ? booking.customInputs[inputId] : "",
|
||||
}));
|
||||
|
||||
// Checking if custom inputs of type Phone number are valid to display error message on UI
|
||||
if (eventType.customInputs.length) {
|
||||
let isErrorFound = false;
|
||||
eventType.customInputs.forEach((customInput) => {
|
||||
if (customInput.required && customInput.type === EventTypeCustomInputType.PHONE) {
|
||||
const input = bookingCustomInputs.find((i) => i.label === customInput.label);
|
||||
try {
|
||||
z.string({
|
||||
errorMap: () => ({
|
||||
message: `Missing ${customInput.type} customInput: '${customInput.label}'`,
|
||||
}),
|
||||
})
|
||||
.refine((val) => isValidPhoneNumber(val), {
|
||||
message: "Phone number is invalid",
|
||||
})
|
||||
.parse(input?.value);
|
||||
} catch (err) {
|
||||
isErrorFound = true;
|
||||
bookingForm.setError(`customInputs.${customInput.id}`, {
|
||||
type: "custom",
|
||||
message: "Invalid Phone number",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if (isErrorFound) return;
|
||||
}
|
||||
|
||||
telemetry.event(
|
||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||
{ isTeamBooking: document.URL.includes("team/") }
|
||||
@@ -312,13 +352,29 @@ const BookingPage = ({
|
||||
{}
|
||||
);
|
||||
|
||||
if (eventType.customInputs.length > 0) {
|
||||
// find all required custom inputs and ensure they are filled out in the booking form
|
||||
const requiredCustomInputs = eventType.customInputs.filter((input) => input.required);
|
||||
const missingRequiredCustomInputs = requiredCustomInputs.filter(
|
||||
(input) => !booking?.customInputs?.[input.id]
|
||||
);
|
||||
if (missingRequiredCustomInputs.length > 0) {
|
||||
missingRequiredCustomInputs.forEach((input) => {
|
||||
bookingForm.setError(`customInputs.${input.id}`, {
|
||||
type: "required",
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (recurringDates.length) {
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...booking,
|
||||
start: dayjs(recurringDate).format(),
|
||||
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
||||
end: dayjs(recurringDate).add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
@@ -334,10 +390,7 @@ const BookingPage = ({
|
||||
attendeeAddress: booking.attendeeAddress,
|
||||
}),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
||||
})),
|
||||
customInputs: bookingCustomInputs,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
smsReminderNumber:
|
||||
@@ -351,7 +404,7 @@ const BookingPage = ({
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
end: dayjs(date).add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
@@ -365,10 +418,7 @@ const BookingPage = ({
|
||||
attendeeAddress: booking.attendeeAddress,
|
||||
}),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
||||
})),
|
||||
customInputs: bookingCustomInputs,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
smsReminderNumber:
|
||||
@@ -403,6 +453,7 @@ const BookingPage = ({
|
||||
}
|
||||
});
|
||||
}
|
||||
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
|
||||
// Define conditional gates here
|
||||
@@ -426,7 +477,7 @@ const BookingPage = ({
|
||||
eventTypeTitle: eventType.title,
|
||||
profileName: profile.name,
|
||||
})}{" "}
|
||||
| Cal.com
|
||||
| {APP_NAME}
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
@@ -436,7 +487,7 @@ const BookingPage = ({
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed ? "" : "sm:my-24",
|
||||
"my-0 max-w-3xl "
|
||||
"my-0 max-w-3xl"
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -445,95 +496,97 @@ const BookingPage = ({
|
||||
"dark:border-darkgray-300 rounded-md sm:border"
|
||||
)}>
|
||||
<div className="sm:flex">
|
||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
||||
<Icon.FiRefreshCw className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringEventCount,
|
||||
})}
|
||||
{showEventTypeDetails && (
|
||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
{stripeAppData.price > 0 && (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<Icon.FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
||||
<div className="text-sm font-medium">
|
||||
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
||||
{!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
||||
return <p key={key}>{timeFormatted}</p>;
|
||||
})}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||
<p key={key}>{timeFormatted}</p>
|
||||
))}>
|
||||
<p className="dark:text-darkgray-600 text-sm">
|
||||
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="line-through ">
|
||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
||||
<Icon.FiRefreshCw className="ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<p className="-ml-2 inline-block items-center px-2">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiUser
|
||||
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
} mb-2 font-medium`}>
|
||||
{booking
|
||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||
</p>
|
||||
<Icon.FiCalendar className="ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<div className="text-sm font-medium">
|
||||
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
||||
{!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
||||
return <p key={key}>{timeFormatted}</p>;
|
||||
})}
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||
<p key={key}>{timeFormatted}</p>
|
||||
))}>
|
||||
<p className="dark:text-darkgray-600 text-sm">
|
||||
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</BookingDescription>
|
||||
</div>
|
||||
<div className="p-6 sm:w-1/2">
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="line-through ">
|
||||
<Icon.FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiUser
|
||||
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
} mb-2 font-medium`}>
|
||||
{booking
|
||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</BookingDescription>
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames("p-6", showEventTypeDetails ? "sm:w-1/2" : "w-full")}>
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
@@ -570,7 +623,7 @@ const BookingPage = ({
|
||||
/>
|
||||
{bookingForm.formState.errors.email && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
||||
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t("email_validation_error")}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -611,8 +664,8 @@ const BookingPage = ({
|
||||
value={location.type}
|
||||
defaultChecked={i === 0}
|
||||
/>
|
||||
<span className="text-sm ltr:ml-2 rtl:mr-2 dark:text-white">
|
||||
{locationKeyToString(location)}
|
||||
<span className="text-sm ltr:ml-2 ltr:mr-2 rtl:ml-2 dark:text-white">
|
||||
{t(locationKeyToString(location) ?? "")}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -636,7 +689,7 @@ const BookingPage = ({
|
||||
{selectedLocationType === LocationType.Phone
|
||||
? t("phone_number")
|
||||
: selectedLocationType === LocationType.AttendeeInPerson
|
||||
? t("Address")
|
||||
? t("address")
|
||||
: ""}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
@@ -663,7 +716,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
{bookingForm.formState.errors.phone && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
||||
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t("invalid_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -676,8 +729,11 @@ const BookingPage = ({
|
||||
{input.type !== EventTypeCustomInputType.BOOL && (
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
className={classNames(
|
||||
"mb-1 block text-sm font-medium text-gray-700 transition-colors dark:text-white",
|
||||
bookingForm.formState.errors.customInputs?.[input.id] && "!text-red-700"
|
||||
)}>
|
||||
{input.label} {input.required && <span className="text-red-700">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
||||
@@ -741,6 +797,49 @@ const BookingPage = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{input.options && input.type === EventTypeCustomInputType.RADIO && (
|
||||
<div className="flex">
|
||||
<Group
|
||||
name={`customInputs.${input.id}`}
|
||||
required={input.required}
|
||||
onValueChange={(e) => {
|
||||
bookingForm.setValue(`customInputs.${input.id}`, e);
|
||||
}}>
|
||||
<>
|
||||
{input.options.map((option, i) => (
|
||||
<RadioField
|
||||
label={option.label}
|
||||
key={`option.${input.id}.${i}.radio`}
|
||||
value={option.label}
|
||||
id={`option.${input.id}.${i}.radio`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
{bookingForm.formState.errors.customInputs?.[input.id] && (
|
||||
<div className="mt-px flex items-center text-xs text-red-700 ">
|
||||
<p>{t("required")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.PHONE && (
|
||||
<div>
|
||||
<PhoneInput<BookingFormValues>
|
||||
name={`customInputs.${input.id}`}
|
||||
control={bookingForm.control}
|
||||
placeholder={t("enter_phone_number")}
|
||||
id={`customInputs.${input.id}`}
|
||||
required={input.required}
|
||||
/>
|
||||
{bookingForm.formState.errors?.customInputs?.[input.id] && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t("invalid_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!eventType.disableGuests && guestToggle && (
|
||||
@@ -815,7 +914,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
{bookingForm.formState.errors.smsReminderNumber && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
||||
<Icon.FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t("invalid_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -839,7 +938,7 @@ const BookingPage = ({
|
||||
) : (
|
||||
<textarea
|
||||
{...bookingForm.register("notes")}
|
||||
required={!!eventType.metadata.additionalNotesRequired}
|
||||
required={!!eventType.metadata?.additionalNotesRequired}
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
|
||||
@@ -212,7 +212,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog}>
|
||||
<DialogContent useOwnActionButtons>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon.FiMapPin className="text-primary-600 h-6 w-6" />
|
||||
@@ -297,7 +297,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<Select<{ label: string; value: string; icon?: string }>
|
||||
maxMenuHeight={150}
|
||||
maxMenuHeight={300}
|
||||
name="location"
|
||||
defaultValue={selection}
|
||||
options={locationOptions}
|
||||
@@ -354,7 +354,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<DialogFooter>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
|
||||
@@ -2,7 +2,17 @@ import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogContent, Icon, showToast, TextArea } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
showToast,
|
||||
TextArea,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface IRescheduleDialog {
|
||||
isOpenDialog: boolean;
|
||||
@@ -30,44 +40,41 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent
|
||||
Icon={Icon.FiClock}
|
||||
useOwnActionButtons
|
||||
title={t("send_reschedule_request")}
|
||||
description={t("reschedule_modal_description")}>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-black">
|
||||
{t("reason_for_reschedule_request")}
|
||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||
</p>
|
||||
<TextArea
|
||||
data-testid="reschedule_reason"
|
||||
name={t("reschedule_reason")}
|
||||
value={rescheduleReason}
|
||||
onChange={(e) => setRescheduleReason(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRescheduleReason("");
|
||||
setIsOpenDialog(false);
|
||||
}}
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
rescheduleApi({
|
||||
bookingId,
|
||||
rescheduleReason,
|
||||
});
|
||||
}}>
|
||||
{t("send_reschedule_request")}
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<Icon.FiClock className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("send_reschedule_request")} />
|
||||
<p className="text-sm text-gray-500">{t("reschedule_modal_description")}</p>
|
||||
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
||||
{t("reason_for_reschedule_request")}
|
||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||
</p>
|
||||
<TextArea
|
||||
data-testid="reschedule_reason"
|
||||
name={t("reschedule_reason")}
|
||||
value={rescheduleReason}
|
||||
onChange={(e) => setRescheduleReason(e.target.value)}
|
||||
className="mb-5 sm:mb-6"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
rescheduleApi({
|
||||
bookingId,
|
||||
rescheduleReason,
|
||||
});
|
||||
}}>
|
||||
{t("send_reschedule_request")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -20,12 +20,13 @@ type AvailabilityOption = {
|
||||
|
||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||
const { label, isDefault } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
Default
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.Option>
|
||||
@@ -34,12 +35,13 @@ const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||
|
||||
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||
const { label, isDefault } = props.data;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<components.SingleValue {...props}>
|
||||
<span>{label}</span>
|
||||
{isDefault && (
|
||||
<Badge variant="blue" className="ml-2">
|
||||
Default
|
||||
{t("default")}
|
||||
</Badge>
|
||||
)}
|
||||
</components.SingleValue>
|
||||
@@ -57,6 +59,8 @@ const AvailabilitySelect = ({
|
||||
onChange: (value: AvailabilityOption | null) => void;
|
||||
}) => {
|
||||
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
if (isLoading) {
|
||||
return <SelectSkeletonLoader />;
|
||||
}
|
||||
@@ -77,6 +81,7 @@ const AvailabilitySelect = ({
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
onChange={props.onChange}
|
||||
@@ -144,7 +149,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ml-4">-</span>
|
||||
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
||||
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -159,7 +164,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
<hr />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||
<Icon.FiGlobe className="mr-2" />
|
||||
<Icon.FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
Icon,
|
||||
InputLeading,
|
||||
showToast,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { slugify } from "@lib/slugify";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import * as RadioArea from "@components/ui/form/radio-area";
|
||||
|
||||
// this describes the uniform data needed to create a new event type on Profile or Team
|
||||
export interface EventTypeParent {
|
||||
teamId: number | null | undefined; // if undefined, then it's a profile
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
interface CreateEventTypeBtnProps {
|
||||
// set true for use on the team settings page
|
||||
canAddEvents: boolean;
|
||||
// set true when in use on the team settings page
|
||||
isIndividualTeam?: boolean;
|
||||
// EventTypeParent can be a profile (as first option) or a team for the rest.
|
||||
options: EventTypeParent[];
|
||||
}
|
||||
|
||||
export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const router = useRouter();
|
||||
// URL encoded params
|
||||
const teamId: number | undefined =
|
||||
typeof router.query.teamId === "string" && router.query.teamId
|
||||
? parseInt(router.query.teamId)
|
||||
: undefined;
|
||||
const pageSlug = router.query.eventPage || props.options[0].slug;
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
const type: string = typeof router.query.type == "string" && router.query.type ? router.query.type : "";
|
||||
|
||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||
resolver: zodResolver(createEventTypeInput),
|
||||
});
|
||||
const { setValue, watch, register } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
|
||||
const title: string =
|
||||
typeof router.query.title === "string" && router.query.title ? router.query.title : "";
|
||||
const length: number =
|
||||
typeof router.query.length === "string" && router.query.length ? parseInt(router.query.length) : 15;
|
||||
const description: string =
|
||||
typeof router.query.description === "string" && router.query.description
|
||||
? router.query.description
|
||||
: "";
|
||||
const slug: string = typeof router.query.slug === "string" && router.query.slug ? router.query.slug : "";
|
||||
|
||||
setValue("title", title);
|
||||
setValue("length", length);
|
||||
setValue("description", description);
|
||||
setValue("slug", slug);
|
||||
// If query params change, update the form
|
||||
}, [router.isReady, router.query, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name, type }) => {
|
||||
if (name === "title" && type === "change") {
|
||||
if (value.title) setValue("slug", slugify(value.title));
|
||||
else setValue("slug", "");
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, setValue]);
|
||||
|
||||
const createMutation = trpc.viewer.eventTypes.create.useMutation({
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.replace("/event-types/" + eventType.id);
|
||||
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "BAD_REQUEST") {
|
||||
const message = `${err.data.code}: URL already exists.`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
const message = `${err.data.code}: You are not able to create this event`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openModal = (option: EventTypeParent) => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "new-eventtype",
|
||||
eventPage: option.slug,
|
||||
teamId: option.teamId,
|
||||
};
|
||||
if (!option.teamId) {
|
||||
delete query.teamId;
|
||||
}
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
if (!isLocaleReady) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
name="new-eventtype"
|
||||
clearQueryParamsOnClose={["eventPage", "teamId", "type", "description", "title", "length", "slug"]}>
|
||||
<CreateEventTypeTrigger
|
||||
hasTeams={hasTeams}
|
||||
canAddEvents={props.canAddEvents}
|
||||
isIndividualTeam={props.isIndividualTeam}
|
||||
openModal={openModal}
|
||||
options={props.options}
|
||||
/>
|
||||
|
||||
<DialogContent>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
createMutation.mutate(values);
|
||||
}}>
|
||||
<div className="mt-3 space-y-4">
|
||||
{teamId && (
|
||||
<TextField
|
||||
type="hidden"
|
||||
labelProps={{ style: { display: "none" } }}
|
||||
{...register("teamId", { valueAsNumber: true })}
|
||||
value={teamId}
|
||||
/>
|
||||
)}
|
||||
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
|
||||
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
|
||||
<TextField
|
||||
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
|
||||
required
|
||||
addOnLeading={<InputLeading>/{pageSlug}/</InputLeading>}
|
||||
{...register("slug")}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
label={t("url")}
|
||||
required
|
||||
addOnLeading={
|
||||
<InputLeading>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
||||
</InputLeading>
|
||||
}
|
||||
{...register("slug")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextAreaField
|
||||
label={t("description")}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<TextField
|
||||
type="number"
|
||||
required
|
||||
min="10"
|
||||
placeholder="15"
|
||||
label={t("length")}
|
||||
className="pr-20"
|
||||
{...register("length", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 mt-1.5 flex items-center pt-4 pr-3 text-sm text-gray-400">
|
||||
{t("minutes")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{teamId && (
|
||||
<div className="mb-4">
|
||||
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
|
||||
{t("scheduling_type")}
|
||||
</label>
|
||||
{form.formState.errors.schedulingType && (
|
||||
<Alert
|
||||
className="mt-1"
|
||||
severity="error"
|
||||
message={form.formState.errors.schedulingType.message}
|
||||
/>
|
||||
)}
|
||||
<RadioArea.Group
|
||||
{...register("schedulingType")}
|
||||
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
|
||||
className="relative mt-1 flex space-x-6 rounded-sm rtl:space-x-reverse">
|
||||
<RadioArea.Item
|
||||
value={SchedulingType.COLLECTIVE}
|
||||
defaultChecked={type === SchedulingType.COLLECTIVE}
|
||||
className="w-1/2 text-sm">
|
||||
<strong className="mb-1 block">{t("collective")}</strong>
|
||||
<p>{t("collective_description")}</p>
|
||||
</RadioArea.Item>
|
||||
<RadioArea.Item
|
||||
value={SchedulingType.ROUND_ROBIN}
|
||||
defaultChecked={type === SchedulingType.ROUND_ROBIN}
|
||||
className="w-1/2 text-sm">
|
||||
<strong className="mb-1 block">{t("round_robin")}</strong>
|
||||
<p>{t("round_robin_description")}</p>
|
||||
</RadioArea.Item>
|
||||
</RadioArea.Group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<Button type="submit" loading={createMutation.isLoading}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type CreateEventTypeTrigger = {
|
||||
isIndividualTeam?: boolean;
|
||||
// EventTypeParent can be a profile (as first option) or a team for the rest.
|
||||
options: EventTypeParent[];
|
||||
hasTeams: boolean;
|
||||
// set true for use on the team settings page
|
||||
canAddEvents: boolean;
|
||||
openModal: (option: EventTypeParent) => void;
|
||||
};
|
||||
|
||||
export function CreateEventTypeTrigger(props: CreateEventTypeTrigger) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!props.hasTeams || props.isIndividualTeam ? (
|
||||
<Button
|
||||
onClick={() => props.openModal(props.options[0])}
|
||||
data-testid="new-event-type"
|
||||
StartIcon={Icon.FiPlus}
|
||||
disabled={!props.canAddEvents}>
|
||||
{t("new_event_type_btn")}
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button EndIcon={Icon.FiChevronDown}>{t("new_event_type_btn")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("new_event_subtitle")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{props.options.map((option) => (
|
||||
<CreateEventTeamsItem
|
||||
key={option.slug}
|
||||
option={option}
|
||||
openModal={() => props.openModal(option)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateEventTeamsItem(props: {
|
||||
openModal: (option: EventTypeParent) => void;
|
||||
option: EventTypeParent;
|
||||
}) {
|
||||
const session = useSession();
|
||||
const membershipQuery = trpc.viewer.teams.getMembershipbyUser.useQuery({
|
||||
memberId: session.data?.user.id as number,
|
||||
teamId: props.option.teamId as number,
|
||||
});
|
||||
|
||||
const isDisabled = membershipQuery.data?.role === "MEMBER";
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={props.option.slug}
|
||||
className={classNames(
|
||||
"cursor-pointer px-3 py-2 focus:outline-none",
|
||||
isDisabled ? "cursor-default !text-gray-300" : "hover:bg-neutral-100"
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onSelect={() => props.openModal(props.option)}>
|
||||
<Avatar
|
||||
alt={props.option.name || ""}
|
||||
imageSrc={props.option.image || WEBAPP_URL + "/" + props.option.slug + "/avatar.png"}
|
||||
size={6}
|
||||
className="inline ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
{props.option.name ? props.option.name : props.option.slug}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,33 @@
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import type { CustomInputParsed } from "pages/event-types/[type]";
|
||||
import { FC } from "react";
|
||||
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
import { Control, Controller, useFieldArray, useForm, UseFormRegister, useWatch } from "react-hook-form";
|
||||
|
||||
import { Button, Select, TextField } from "@calcom/ui";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Icon, Label, Select, TextField } from "@calcom/ui";
|
||||
|
||||
interface OptionTypeBase {
|
||||
label: string;
|
||||
value: EventTypeCustomInputType;
|
||||
options?: { label: string; type: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: SubmitHandler<IFormInput>;
|
||||
onSubmit: (output: CustomInputParsed) => void;
|
||||
onCancel: () => void;
|
||||
selectedCustomInput?: EventTypeCustomInput;
|
||||
selectedCustomInput?: CustomInputParsed;
|
||||
}
|
||||
|
||||
type IFormInput = EventTypeCustomInput;
|
||||
type IFormInput = CustomInputParsed;
|
||||
|
||||
/**
|
||||
* Getting a random ID gives us the option to know WHICH field is changed
|
||||
* when the user edits a custom field.
|
||||
* This UUID is only used to check for changes in the UI and not the ID we use in the DB
|
||||
* There is very very very slim chance that this will cause a collision
|
||||
* */
|
||||
const randomId = () => Math.floor(Math.random() * 1000000 + new Date().getTime());
|
||||
|
||||
const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
const { t } = useLocale();
|
||||
@@ -26,10 +36,23 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||
{
|
||||
value: EventTypeCustomInputType.RADIO,
|
||||
label: t("radio"),
|
||||
},
|
||||
{ value: EventTypeCustomInputType.PHONE, label: t("phone_number") },
|
||||
];
|
||||
|
||||
const { selectedCustomInput } = props;
|
||||
const defaultValues = selectedCustomInput || { type: inputOptions[0].value };
|
||||
const { register, control, handleSubmit } = useForm<IFormInput>({
|
||||
|
||||
const defaultValues = selectedCustomInput
|
||||
? { ...selectedCustomInput, id: selectedCustomInput?.id || randomId() }
|
||||
: {
|
||||
id: randomId(),
|
||||
type: EventTypeCustomInputType.TEXT,
|
||||
};
|
||||
|
||||
const { register, control, getValues } = useForm<IFormInput>({
|
||||
defaultValues,
|
||||
});
|
||||
const selectedInputType = useWatch({ name: "type", control });
|
||||
@@ -40,7 +63,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(props.onSubmit)} className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
{t("input_type")}
|
||||
@@ -83,6 +106,10 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
)}
|
||||
{selectedInputType === EventTypeCustomInputType.RADIO && (
|
||||
<RadioInputHandler control={control} register={register} />
|
||||
)}
|
||||
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="required"
|
||||
@@ -107,14 +134,79 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
value={selectedCustomInput?.id || -1}
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
||||
<div className="mt-5 flex justify-end space-x-2 rtl:space-x-reverse sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2 rtl:ml-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.onSubmit(getValues());
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function RadioInputHandler({
|
||||
register,
|
||||
control,
|
||||
}: {
|
||||
register: UseFormRegister<IFormInput>;
|
||||
control: Control<IFormInput>;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { fields, append, remove } = useFieldArray<IFormInput>({
|
||||
control,
|
||||
name: "options",
|
||||
shouldUnregister: true,
|
||||
});
|
||||
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col ">
|
||||
<Label htmlFor="radio_options">{t("options")}</Label>
|
||||
<ul
|
||||
className="flex max-h-80 w-full flex-col space-y-1 overflow-y-scroll rounded-md bg-gray-50 p-4"
|
||||
ref={animateRef}>
|
||||
<>
|
||||
{fields.map((option, index) => (
|
||||
<li key={`${option.id}`}>
|
||||
<TextField
|
||||
id={option.id}
|
||||
placeholder={t("enter_option", { index: index + 1 })}
|
||||
addOnFilled={false}
|
||||
label={t("option", { index: index + 1 })}
|
||||
labelSrOnly
|
||||
{...register(`options.${index}.label` as const, { required: true })}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiX}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiPlus}
|
||||
className="!text-sm !font-medium"
|
||||
onClick={() => {
|
||||
append({ label: "", type: "text" });
|
||||
}}>
|
||||
{t("add_an_option")}
|
||||
</Button>
|
||||
</>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomInputTypeForm;
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { components } from "react-select";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
EventLocationType,
|
||||
getEventLocationType,
|
||||
getHumanReadableLocationValue,
|
||||
getMessageForOrganizer,
|
||||
LocationObject,
|
||||
LocationType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogContent, Form, Icon, Label, PhoneInput, Select } from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import { LinkText } from "@components/ui/LinkText";
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
type OptionTypeBase = {
|
||||
label: string;
|
||||
value: EventLocationType["type"];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
interface ISetLocationDialog {
|
||||
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
|
||||
selection?: OptionTypeBase;
|
||||
booking?: BookingItem;
|
||||
defaultValues?: LocationObject[];
|
||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isOpenDialog: boolean;
|
||||
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
|
||||
}
|
||||
|
||||
const LocationInput = (props: {
|
||||
eventLocationType: EventLocationType;
|
||||
locationFormMethods: ReturnType<typeof useForm>;
|
||||
id: string;
|
||||
required: boolean;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
}): JSX.Element | null => {
|
||||
const { eventLocationType, locationFormMethods, ...remainingProps } = props;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
return (
|
||||
<input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
return (
|
||||
<PhoneInput
|
||||
name={eventLocationType.variable}
|
||||
control={locationFormMethods.control}
|
||||
{...remainingProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
const {
|
||||
saveLocation,
|
||||
selection,
|
||||
booking,
|
||||
setShowLocationModal,
|
||||
isOpenDialog,
|
||||
defaultValues,
|
||||
setSelectedLocation,
|
||||
} = props;
|
||||
const { t } = useLocale();
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (selection) {
|
||||
locationFormMethods.setValue("locationType", selection?.value);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selection]);
|
||||
|
||||
const locationFormSchema = z.object({
|
||||
locationType: z.string(),
|
||||
phone: z.string().optional(),
|
||||
locationAddress: z.string().optional(),
|
||||
locationLink: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (
|
||||
eventLocationType &&
|
||||
!eventLocationType.default &&
|
||||
eventLocationType.linkType === "static" &&
|
||||
eventLocationType.urlRegExp
|
||||
) {
|
||||
const valid = z
|
||||
.string()
|
||||
.regex(new RegExp(eventLocationType.urlRegExp || ""))
|
||||
.safeParse(val).success;
|
||||
if (!valid) {
|
||||
const sampleUrl = eventLocationType.organizerInputPlaceholder;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid URL for ${eventLocationType.label}. ${
|
||||
sampleUrl ? "Sample URL: " + sampleUrl : ""
|
||||
}`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = z.string().url().optional().safeParse(val).success;
|
||||
if (!valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid URL`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}),
|
||||
displayLocationPublicly: z.boolean().optional(),
|
||||
locationPhoneNumber: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const locationFormMethods = useForm({
|
||||
mode: "onSubmit",
|
||||
resolver: zodResolver(locationFormSchema),
|
||||
});
|
||||
|
||||
const selectedLocation = useWatch({
|
||||
control: locationFormMethods.control,
|
||||
name: "locationType",
|
||||
});
|
||||
|
||||
const eventLocationType = getEventLocationType(selectedLocation);
|
||||
|
||||
const defaultLocation = defaultValues?.find(
|
||||
(location: { type: EventLocationType["type"] }) => location.type === eventLocationType?.type
|
||||
);
|
||||
|
||||
const LocationOptions = (() => {
|
||||
if (eventLocationType && eventLocationType.organizerInputType && LocationInput) {
|
||||
if (!eventLocationType.variable) {
|
||||
console.error("eventLocationType.variable can't be undefined");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Label htmlFor="locationInput">{t(eventLocationType.messageForOrganizer || "")}</Label>
|
||||
<LocationInput
|
||||
locationFormMethods={locationFormMethods}
|
||||
eventLocationType={eventLocationType}
|
||||
id="locationInput"
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
required
|
||||
className="block w-full rounded-md border-gray-300 text-sm"
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
defaultValue={
|
||||
(defaultLocation && defaultLocation[eventLocationType.defaultValueVariable]) || ""
|
||||
}
|
||||
/>
|
||||
<ErrorMessage
|
||||
errors={locationFormMethods.formState.errors}
|
||||
name={eventLocationType.variable}
|
||||
className="mt-1 text-sm text-red-500"
|
||||
as="p"
|
||||
/>
|
||||
</div>
|
||||
{!booking && (
|
||||
<div className="mt-3">
|
||||
<Controller
|
||||
name="displayLocationPublicly"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) =>
|
||||
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||
}
|
||||
informationIconText={t("display_location_info_badge")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <p className="text-sm">{getMessageForOrganizer(selectedLocation, t)}</p>;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
Icon={Icon.FiMapPin}
|
||||
useOwnActionButtons
|
||||
title={t("edit_location")}
|
||||
description={!booking ? t("this_input_will_shown_booking_this_event") : undefined}>
|
||||
{booking && (
|
||||
<>
|
||||
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
|
||||
<p className="mb-2 ml-1 text-sm text-black">
|
||||
{getHumanReadableLocationValue(booking.location, t)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Form
|
||||
form={locationFormMethods}
|
||||
className="space-y-4"
|
||||
handleSubmit={async (values) => {
|
||||
const { locationType: newLocation, displayLocationPublicly } = values;
|
||||
|
||||
let details = {};
|
||||
if (newLocation === LocationType.AttendeeInPerson) {
|
||||
details = {
|
||||
address: values.locationAddress,
|
||||
};
|
||||
}
|
||||
|
||||
if (newLocation === LocationType.InPerson) {
|
||||
details = {
|
||||
address: values.locationAddress,
|
||||
};
|
||||
}
|
||||
const eventLocationType = getEventLocationType(newLocation);
|
||||
|
||||
// TODO: There can be a property that tells if it is to be saved in `link`
|
||||
if (
|
||||
newLocation === LocationType.Link ||
|
||||
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
||||
) {
|
||||
details = { link: values.locationLink };
|
||||
}
|
||||
|
||||
if (newLocation === LocationType.UserPhone) {
|
||||
details = { hostPhoneNumber: values.locationPhoneNumber };
|
||||
}
|
||||
|
||||
if (eventLocationType?.organizerInputType) {
|
||||
details = {
|
||||
...details,
|
||||
displayLocationPublicly,
|
||||
};
|
||||
}
|
||||
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
locationFormMethods.unregister([
|
||||
"locationType",
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
}}>
|
||||
<QueryCell
|
||||
query={locationsQuery}
|
||||
success={({ data: locationOptions }) => {
|
||||
if (!locationOptions.length) return null;
|
||||
if (booking) {
|
||||
locationOptions.forEach((location) => {
|
||||
if (location.label === "phone") {
|
||||
location.options.filter((l) => l.value !== "phone");
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Controller
|
||||
name="locationType"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<Select<{ label: string; value: string; icon?: string }>
|
||||
maxMenuHeight={150}
|
||||
name="location"
|
||||
defaultValue={selection}
|
||||
options={locationOptions}
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<div className="flex items-center gap-3">
|
||||
{props.data.icon && (
|
||||
<img src={props.data.icon} alt="cover" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span
|
||||
className={classNames(
|
||||
"text-sm font-medium",
|
||||
props.isSelected ? "text-white" : "text-gray-900"
|
||||
)}>
|
||||
{props.data.label}
|
||||
</span>
|
||||
</div>
|
||||
</components.Option>
|
||||
),
|
||||
}}
|
||||
formatOptionLabel={(e) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||
<span>{e.label}</span>
|
||||
</div>
|
||||
)}
|
||||
formatGroupLabel={(e) => <p className="text-xs font-medium text-gray-600">{e.label}</p>}
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
locationFormMethods.unregister([
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
locationFormMethods.clearErrors([
|
||||
"locationLink",
|
||||
"locationPhoneNumber",
|
||||
"locationAddress",
|
||||
]);
|
||||
setSelectedLocation?.(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<div className="mt-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">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
locationFormMethods.unregister("locationType");
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("update")}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,23 @@
|
||||
import { EventTypeCustomInput } from "@prisma/client/";
|
||||
import Link from "next/link";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import type { CustomInputParsed, EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import CustomInputItem from "@calcom/features/eventtypes/components/CustomInputItem";
|
||||
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
CustomInputItem,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Icon,
|
||||
Label,
|
||||
SettingsToggle,
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
|
||||
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
const generateHashedLink = (id: number) => {
|
||||
const translator = short();
|
||||
const seed = `${id}:${new Date().getTime()}`;
|
||||
@@ -33,7 +37,19 @@ const generateHashedLink = (id: number) => {
|
||||
return uid;
|
||||
};
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered, "eventType" | "team">) => {
|
||||
const getRandomId = (length = 8) => {
|
||||
return (
|
||||
-1 *
|
||||
parseInt(
|
||||
Math.ceil(Math.random() * Date.now())
|
||||
.toPrecision(length)
|
||||
.toString()
|
||||
.replace(".", "")
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
@@ -41,13 +57,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||
const [customInputs, setCustomInputs] = useState<CustomInputParsed[]>(
|
||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<CustomInputParsed | undefined>(undefined);
|
||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
|
||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||
|
||||
const removeCustom = (index: number) => {
|
||||
@@ -60,6 +76,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||
}, [eventType.users, hashedUrl, team?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventType.customInputs) {
|
||||
setCustomInputs(eventType.customInputs.sort((a, b) => a.id - b.id));
|
||||
}
|
||||
}, [eventType.customInputs]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/**
|
||||
@@ -99,7 +121,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
<TextField
|
||||
label={t("event_name")}
|
||||
type="text"
|
||||
placeholder={t("meeting_with_user")}
|
||||
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
@@ -123,15 +145,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
onCheckedChange={(e) => {
|
||||
if (e && customInputs.length === 0) {
|
||||
// Push a placeholders
|
||||
setSelectedCustomInput(undefined);
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
} else if (!e) {
|
||||
setCustomInputs([]);
|
||||
formMethods.setValue("customInputs", []);
|
||||
}
|
||||
}}>
|
||||
<ul className="my-4">
|
||||
{customInputs.map((customInput: EventTypeCustomInput, idx: number) => (
|
||||
<ul className="my-4 rounded-md border">
|
||||
{customInputs.map((customInput, idx) => (
|
||||
<CustomInputItem
|
||||
key={idx}
|
||||
question={customInput.label}
|
||||
@@ -154,24 +174,17 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
setSelectedCustomInput(undefined);
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
}}>
|
||||
Add an input
|
||||
{t("add_input")}
|
||||
</Button>
|
||||
)}
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
<hr />
|
||||
<Controller
|
||||
name="requiresConfirmation"
|
||||
defaultValue={eventType.requiresConfirmation}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("requires_confirmation")}
|
||||
description={t("requires_confirmation_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
disabled={seatsEnabled}
|
||||
/>
|
||||
)}
|
||||
<RequiresConfirmationController
|
||||
seatsEnabled={seatsEnabled}
|
||||
metadata={eventType.metadata}
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
@@ -244,17 +257,34 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
defaultValue={eventType.successRedirectUrl || ""}
|
||||
{...formMethods.register("successRedirectUrl")}
|
||||
/>
|
||||
<div className="mt-2 flex">
|
||||
<Checkbox
|
||||
description={t("disable_success_page")}
|
||||
// Disable if it's not Self Hosted or if the redirect url is not set
|
||||
disabled={!IS_SELF_HOSTED || !formMethods.watch("successRedirectUrl")}
|
||||
{...formMethods.register("metadata.disableSuccessPage")}
|
||||
/>
|
||||
{/*TODO: Extract it out into a component when used more than once*/}
|
||||
{!IS_SELF_HOSTED && (
|
||||
<Link href="https://cal.com/pricing">
|
||||
<a target="_blank">
|
||||
<Badge variant="orange" className="ml-2">
|
||||
Platform Only
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<SettingsToggle
|
||||
data-testid="hashedLinkCheck"
|
||||
title={t("private_link")}
|
||||
description={t("private_link_description")}
|
||||
description={t("private_link_description", { appName: APP_NAME })}
|
||||
checked={hashedLinkVisible}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||
@@ -307,6 +337,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
if (e) {
|
||||
formMethods.setValue("disableGuests", true);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
@@ -352,15 +383,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
<DialogContent
|
||||
title={t("custom_event_name")}
|
||||
description={t("custom_event_name_description")}
|
||||
type="creation"
|
||||
actionText="Create"
|
||||
// Set event name back to what it was on close
|
||||
actionOnClose={() => formMethods.setValue("eventName", eventType.eventName ?? "")}
|
||||
actionOnClick={() => setShowEventNameTip(false)}>
|
||||
type="creation">
|
||||
<TextField
|
||||
label={t("event_name")}
|
||||
type="text"
|
||||
placeholder={t("meeting_with_user")}
|
||||
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName")}
|
||||
/>
|
||||
@@ -370,42 +397,61 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
|
||||
<p>{`{LOCATION} = ${t("event_location")}`}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")} />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<Controller
|
||||
name="customInputs"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.customInputs.sort((a, b) => a.id - b.id) || []}
|
||||
defaultValue={customInputs}
|
||||
render={() => (
|
||||
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
Icon={Icon.FiPlus}
|
||||
title={t("add_new_custom_input_field")}
|
||||
useOwnActionButtons
|
||||
description={t("this_input_will_shown_booking_this_event")}>
|
||||
<CustomInputTypeForm
|
||||
selectedCustomInput={selectedCustomInput}
|
||||
onSubmit={(values) => {
|
||||
const customInput: EventTypeCustomInput = {
|
||||
id: -1,
|
||||
const customInput: CustomInputParsed = {
|
||||
id: getRandomId(),
|
||||
eventTypeId: -1,
|
||||
label: values.label,
|
||||
placeholder: values.placeholder,
|
||||
required: values.required,
|
||||
type: values.type,
|
||||
options: values.options,
|
||||
hasToBeCreated: true,
|
||||
};
|
||||
|
||||
if (selectedCustomInput) {
|
||||
selectedCustomInput.label = customInput.label;
|
||||
selectedCustomInput.placeholder = customInput.placeholder;
|
||||
selectedCustomInput.required = customInput.required;
|
||||
selectedCustomInput.type = customInput.type;
|
||||
selectedCustomInput.options = customInput.options || undefined;
|
||||
selectedCustomInput.hasToBeCreated = false;
|
||||
// Update by id
|
||||
const inputIndex = customInputs.findIndex((input) => input.id === values.id);
|
||||
customInputs[inputIndex] = selectedCustomInput;
|
||||
setCustomInputs(customInputs);
|
||||
formMethods.setValue("customInputs", customInputs);
|
||||
} else {
|
||||
setCustomInputs(customInputs.concat(customInput));
|
||||
formMethods.setValue("customInputs", customInputs.concat(customInput));
|
||||
const concatted = customInputs.concat({
|
||||
...customInput,
|
||||
options: customInput.options,
|
||||
});
|
||||
console.log(concatted);
|
||||
setCustomInputs(concatted);
|
||||
formMethods.setValue("customInputs", concatted);
|
||||
}
|
||||
|
||||
setSelectedCustomInputModalOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||
@@ -9,7 +9,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Button, EmptyScreen, ErrorBoundary, Icon } from "@calcom/ui";
|
||||
|
||||
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"] &
|
||||
type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||
EventTypeAppCardComponentProps["eventType"];
|
||||
|
||||
function AppCardWrapper({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PeriodType } from "@calcom/prisma/client";
|
||||
import type { BookingLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, Icon, Input, InputField, Label, Select, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const minimumBookingNoticeType = useRef<DurationType>(findDurationType(eventType.minimumBookingNotice));
|
||||
@@ -197,7 +197,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="120"
|
||||
className="mr-2 mb-0 h-[38px] rounded-[4px]"
|
||||
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
{...formMethods.register("minimumBookingNoticeInDurationType", {
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onMinimumNoticeChange(event.target.value),
|
||||
@@ -327,7 +327,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
||||
</div>
|
||||
)}
|
||||
{period.type === "RANGE" && (
|
||||
<div className="inline-flex space-x-2 ltr:ml-2 rtl:mr-2 rtl:space-x-reverse">
|
||||
<div className="inline-flex space-x-2 ltr:ml-2 ltr:mr-2 rtl:ml-2 rtl:space-x-reverse">
|
||||
<Controller
|
||||
name="periodDates"
|
||||
control={formMethods.control}
|
||||
@@ -347,7 +347,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupInfered, "event
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{period.suffix ? <span className="ltr:ml-2 rtl:mr-2"> {period.suffix}</span> : null}
|
||||
{period.suffix ? (
|
||||
<span className="ltr:ml-2 ltr:mr-2 rtl:ml-2"> {period.suffix}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -410,7 +412,9 @@ const BookingLimits = () => {
|
||||
.map(([key, bookingAmount]) => {
|
||||
const bookingLimitKey = key as BookingLimitsKey;
|
||||
return (
|
||||
<div className="mb-2 flex items-center space-x-2 text-sm" key={bookingLimitKey}>
|
||||
<div
|
||||
className="mb-2 flex items-center space-x-2 text-sm rtl:space-x-reverse"
|
||||
key={bookingLimitKey}>
|
||||
<Input
|
||||
id={`${bookingLimitKey}-limit`}
|
||||
type="number"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { EventTypeSetupInfered } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
|
||||
import RecurringEventController from "./RecurringEventController";
|
||||
|
||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
|
||||
const requirePayment = stripeAppData.price > 0;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||
import { MultiValue } from "react-select";
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Icon, Label, Select, Skeleton, TextField } from "@calcom/ui";
|
||||
import { Button, Icon, Label, Select, Skeleton, TextField, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
import { slugify } from "@lib/slugify";
|
||||
|
||||
@@ -22,7 +23,7 @@ type OptionTypeBase = {
|
||||
};
|
||||
|
||||
export const EventSetupTab = (
|
||||
props: Pick<EventTypeSetupInfered, "eventType" | "locationOptions" | "team" | "teamMembers">
|
||||
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
@@ -30,6 +31,22 @@ export const EventSetupTab = (
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata.multipleDuration);
|
||||
|
||||
const multipleDurationOptions = [5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 180].map((mins) => ({
|
||||
value: mins,
|
||||
label: t("multiple_duration_mins", { count: mins }),
|
||||
}));
|
||||
|
||||
const [selectedMultipleDuration, setSelectedMultipleDuration] = useState<
|
||||
MultiValue<{
|
||||
value: number;
|
||||
label: string;
|
||||
}>
|
||||
>(multipleDurationOptions.filter((mdOpt) => multipleDuration?.includes(mdOpt.value)));
|
||||
const [defaultDuration, setDefaultDuration] = useState(
|
||||
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
|
||||
);
|
||||
|
||||
const openLocationModal = (type: EventLocationType["type"]) => {
|
||||
setSelectedLocation(locationOptions.find((option) => option.value === type));
|
||||
@@ -113,6 +130,7 @@ export const EventSetupTab = (
|
||||
{validLocations.length === 0 && (
|
||||
<div className="flex">
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
@@ -151,7 +169,7 @@ export const EventSetupTab = (
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">
|
||||
{location[eventLocationType.defaultValueVariable] || eventLocationType.label}
|
||||
{t(location[eventLocationType.defaultValueVariable] || eventLocationType.label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
@@ -195,7 +213,7 @@ export const EventSetupTab = (
|
||||
<div className="space-y-8">
|
||||
<TextField
|
||||
required
|
||||
label={t("Title")}
|
||||
label={t("title")}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
@@ -219,17 +237,91 @@ export const EventSetupTab = (
|
||||
setValueAs: (v) => slugify(v),
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
name="length"
|
||||
type="number"
|
||||
label={t("duration")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("length", Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
{multipleDuration ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("available_durations")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
isMulti
|
||||
defaultValue={selectedMultipleDuration}
|
||||
name="metadata.multipleDuration"
|
||||
isSearchable={false}
|
||||
className="h-auto !min-h-[36px] text-sm"
|
||||
options={multipleDurationOptions}
|
||||
value={selectedMultipleDuration}
|
||||
onChange={(options) => {
|
||||
let newOptions = [...options];
|
||||
newOptions = newOptions.sort((a, b) => {
|
||||
return a?.value - b?.value;
|
||||
});
|
||||
const values = newOptions.map((opt) => opt.value);
|
||||
setMultipleDuration(values);
|
||||
setSelectedMultipleDuration(newOptions);
|
||||
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
|
||||
if (newOptions.length > 0) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
} else {
|
||||
setDefaultDuration(null);
|
||||
}
|
||||
}
|
||||
if (newOptions.length === 1 && defaultDuration === null) {
|
||||
setDefaultDuration(newOptions[0]);
|
||||
formMethods.setValue("length", newOptions[0].value);
|
||||
}
|
||||
formMethods.setValue("metadata.multipleDuration", values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("default_duration")}
|
||||
</Skeleton>
|
||||
<Select
|
||||
value={defaultDuration}
|
||||
isSearchable={false}
|
||||
name="length"
|
||||
className="text-sm"
|
||||
noOptionsMessage={() => t("default_duration_no_options")}
|
||||
options={selectedMultipleDuration}
|
||||
onChange={(option) => {
|
||||
setDefaultDuration(
|
||||
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
|
||||
);
|
||||
if (option) formMethods.setValue("length", option.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
label={t("duration")}
|
||||
defaultValue={eventType.length ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
/>
|
||||
)}
|
||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||
<SettingsToggle
|
||||
title={t("allow_booker_to_select_duration")}
|
||||
checked={multipleDuration !== undefined}
|
||||
onCheckedChange={() => {
|
||||
if (multipleDuration !== undefined) {
|
||||
setMultipleDuration(undefined);
|
||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||
formMethods.setValue("length", eventType.length);
|
||||
} else {
|
||||
setMultipleDuration([]);
|
||||
formMethods.setValue("metadata.multipleDuration", []);
|
||||
formMethods.setValue("length", 0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
@@ -250,7 +342,7 @@ export const EventSetupTab = (
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation ? { value: selectedLocation.value, label: selectedLocation.label } : undefined
|
||||
selectedLocation ? { value: selectedLocation.value, label: t(selectedLocation.label) } : undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { SchedulingType } from "@prisma/client/";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar, Button, CheckedTeamSelect, Icon, Label, Select } from "@calcom/ui";
|
||||
import { Label, Select } from "@calcom/ui";
|
||||
|
||||
interface IMemberToValue {
|
||||
id: number | null;
|
||||
@@ -25,8 +26,7 @@ export const EventTeamTab = ({
|
||||
eventType,
|
||||
team,
|
||||
teamMembers,
|
||||
currentUserMembership,
|
||||
}: Pick<EventTypeSetupInfered, "eventType" | "teamMembers" | "team" | "currentUserMembership">) => {
|
||||
}: Pick<EventTypeSetupProps, "eventType" | "teamMembers" | "team">) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -101,48 +101,3 @@ export const EventTeamTab = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamProfileCard = ({
|
||||
id,
|
||||
name,
|
||||
hasPermsToDelete,
|
||||
email,
|
||||
username,
|
||||
}: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
username: string | null;
|
||||
hasPermsToDelete: boolean;
|
||||
}) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const avatar = `${WEBAPP_URL}/${username}/avatar.png`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center border border-gray-300 px-6 py-4 first:rounded-t-md last:rounded-b-md only:rounded-md">
|
||||
<Avatar imageSrc={avatar} gravatarFallbackMd5="ajsdkwakhsd1231" size="md" alt="Team Image" />
|
||||
<div className="flex flex-col pl-3">
|
||||
<div className="">
|
||||
<span className="pr-2 text-sm font-semibold leading-none text-black">{name}</span>
|
||||
</div>
|
||||
<p className="text-sm font-normal leading-normal text-gray-700">{email}</p>
|
||||
</div>
|
||||
{hasPermsToDelete && (
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
color="destructive"
|
||||
StartIcon={Icon.FiTrash}
|
||||
onClick={() => {
|
||||
formMethods.setValue(
|
||||
"users",
|
||||
formMethods.getValues("users").filter((user) => user !== id.toString())
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { TbWebhook } from "react-icons/tb";
|
||||
|
||||
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||
import { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Webhook } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Dialog, DialogContent, EmptyScreen, Icon, showToast } from "@calcom/ui";
|
||||
|
||||
export const EventTeamWebhooksTab = ({
|
||||
eventType,
|
||||
team,
|
||||
}: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery({ eventTypeId: eventType.id });
|
||||
|
||||
const { data: installedApps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||
variant: "other",
|
||||
onlyInstalled: true,
|
||||
});
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [webhookToEdit, setWebhookToEdit] = useState<Webhook>();
|
||||
|
||||
const subscriberUrlReserved = (subscriberUrl: string, id?: string): boolean => {
|
||||
return !!webhooks?.find(
|
||||
(webhook) => webhook.subscriberUrl === subscriberUrl && (!id || webhook.id !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess() {
|
||||
setEditModalOpen(false);
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
},
|
||||
onError(error) {
|
||||
showToast(`${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const createWebhookMutation = trpc.viewer.webhook.create.useMutation({
|
||||
async onSuccess() {
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
setCreateModalOpen(false);
|
||||
},
|
||||
onError(error) {
|
||||
showToast(`${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateWebhook = async (values: WebhookFormSubmitData) => {
|
||||
if (subscriberUrlReserved(values.subscriberUrl, values.id)) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!values.payloadTemplate) {
|
||||
values.payloadTemplate = null;
|
||||
}
|
||||
|
||||
createWebhookMutation.mutate({
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
eventTriggers: values.eventTriggers,
|
||||
active: values.active,
|
||||
payloadTemplate: values.payloadTemplate,
|
||||
secret: values.secret,
|
||||
eventTypeId: eventType.id,
|
||||
});
|
||||
};
|
||||
|
||||
const NewWebhookButton = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="new_webhook"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => setCreateModalOpen(true)}>
|
||||
{t("new_webhook")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
{team && webhooks && !isLoading && (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
<>
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="mt-4 mb-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
lastItem={webhooks.length === index + 1}
|
||||
onEditWebhook={() => {
|
||||
setEditModalOpen(true);
|
||||
setWebhookToEdit(webhook);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<NewWebhookButton />
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={TbWebhook}
|
||||
headline={t("create_your_first_webhook")}
|
||||
description={t("create_your_first_team_webhook_description", { appName: APP_NAME })}
|
||||
buttonRaw={<NewWebhookButton />}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New webhook dialog */}
|
||||
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
|
||||
<DialogContent title={t("create_webhook")} description={t("create_webhook_team_event_type")}>
|
||||
<WebhookForm
|
||||
onSubmit={onCreateWebhook}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Edit webhook dialog */}
|
||||
<Dialog open={editModalOpen} onOpenChange={(isOpen) => !isOpen && setEditModalOpen(false)}>
|
||||
<DialogContent title={t("edit_webhook")}>
|
||||
<WebhookForm
|
||||
webhook={webhookToEdit}
|
||||
apps={installedApps?.items.map((app) => app.slug)}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
onSubmit={(values: WebhookFormSubmitData) => {
|
||||
if (subscriberUrlReserved(values.subscriberUrl, webhookToEdit?.id || "")) {
|
||||
showToast(t("webhook_subscriber_url_reserved"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.changeSecret) {
|
||||
values.secret = values.newSecret.length ? values.newSecret : null;
|
||||
}
|
||||
|
||||
if (!values.payloadTemplate) {
|
||||
values.payloadTemplate = null;
|
||||
}
|
||||
|
||||
editWebhookMutation.mutate({
|
||||
id: webhookToEdit?.id || "",
|
||||
subscriberUrl: values.subscriberUrl,
|
||||
eventTriggers: values.eventTriggers,
|
||||
active: values.active,
|
||||
payloadTemplate: values.payloadTemplate,
|
||||
secret: values.secret,
|
||||
eventTypeId: webhookToEdit?.eventTypeId || undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Loader } from "react-feather";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { TbWebhook } from "react-icons/tb";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -37,9 +37,9 @@ import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
eventType: EventTypeSetupInfered["eventType"];
|
||||
currentUserMembership: EventTypeSetupInfered["currentUserMembership"];
|
||||
team: EventTypeSetupInfered["team"];
|
||||
eventType: EventTypeSetupProps["eventType"];
|
||||
currentUserMembership: EventTypeSetupProps["currentUserMembership"];
|
||||
team: EventTypeSetupProps["team"];
|
||||
disableBorder?: boolean;
|
||||
enabledAppsNumber: number;
|
||||
installedAppsNumber: number;
|
||||
@@ -67,7 +67,7 @@ function getNavigation(props: {
|
||||
name: "availability",
|
||||
href: `/event-types/${eventType.id}?tabName=availability`,
|
||||
icon: Icon.FiCalendar,
|
||||
info: `Working Hours`, // TODO: Get this from props
|
||||
info: `default_schedule_name`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "event_limit_tab_title",
|
||||
@@ -150,13 +150,20 @@ function EventTypeSingleLayout({
|
||||
enabledWorkflowsNumber,
|
||||
});
|
||||
// If there is a team put this navigation item within the tabs
|
||||
if (team)
|
||||
if (team) {
|
||||
navigation.splice(2, 0, {
|
||||
name: "scheduling_type",
|
||||
href: `/event-types/${eventType.id}?tabName=team`,
|
||||
icon: Icon.FiUsers,
|
||||
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
||||
});
|
||||
navigation.push({
|
||||
name: "webhooks",
|
||||
href: `/event-types/${eventType.id}?tabName=webhooks`,
|
||||
icon: TbWebhook,
|
||||
info: `${eventType.webhooks.filter((webhook) => webhook.active).length} ${t("active")}`,
|
||||
});
|
||||
}
|
||||
return navigation;
|
||||
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
||||
|
||||
@@ -294,7 +301,7 @@ function EventTypeSingleLayout({
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<ClientSuspense fallback={<Loader />}>
|
||||
<ClientSuspense fallback={<Icon.FiLoader />}>
|
||||
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
||||
<div className="hidden xl:block">
|
||||
<VerticalTabs
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function RecurringEventController({
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -89,7 +89,7 @@ export default function RecurringEventController({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("for_a_maximum_of")}</p>
|
||||
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -105,7 +105,7 @@ export default function RecurringEventController({
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="mr-2 text-neutral-900">
|
||||
<p className="text-neutral-900 ltr:mr-2 rtl:ml-2">
|
||||
{t("events", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { UnitTypeLongPlural } from "dayjs";
|
||||
import { Trans } from "next-i18next";
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import z from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Alert, Input, Label, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
type RequiresConfirmationControllerProps = {
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
requiresConfirmation: boolean;
|
||||
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
|
||||
seatsEnabled: boolean;
|
||||
};
|
||||
|
||||
export default function RequiresConfirmationController({
|
||||
metadata,
|
||||
requiresConfirmation,
|
||||
onRequiresConfirmation,
|
||||
seatsEnabled,
|
||||
}: RequiresConfirmationControllerProps) {
|
||||
const { t } = useLocale();
|
||||
const [requiresConfirmationSetup, setRequiresConfirmationSetup] = useState(
|
||||
metadata?.requiresConfirmationThreshold
|
||||
);
|
||||
const defaultRequiresConfirmationSetup = { time: 30, unit: "minutes" as UnitTypeLongPlural };
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresConfirmation) {
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
}
|
||||
}, [requiresConfirmation]);
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className={!seatsEnabled ? "w-full" : ""}>
|
||||
{seatsEnabled ? (
|
||||
<Alert severity="warning" title="Seats option doesn't support confirmation requirement" />
|
||||
) : (
|
||||
<Controller
|
||||
name="requiresConfirmation"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("requires_confirmation")}
|
||||
description={t("requires_confirmation_description")}
|
||||
checked={requiresConfirmation}
|
||||
onCheckedChange={(val) => {
|
||||
formMethods.setValue("requiresConfirmation", val);
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<div className="-ml-2 flex flex-col flex-wrap justify-start gap-y-2 space-y-2">
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
id="always"
|
||||
value="always"
|
||||
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor="always" className="!m-0 flex items-center">
|
||||
{t("always_requires_confirmation")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
id="notice"
|
||||
value="notice"
|
||||
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor="notice" className="!m-0 flex items-center">
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold.time", val);
|
||||
}}
|
||||
className="!m-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<select
|
||||
onChange={(evt) => {
|
||||
const val = evt.target.value as UnitTypeLongPlural;
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: val,
|
||||
});
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold.unit", val);
|
||||
}}
|
||||
className="ml-2 block h-9 rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
|
||||
defaultValue={
|
||||
metadata?.requiresConfirmationThreshold?.unit ||
|
||||
defaultRequiresConfirmationSetup.unit
|
||||
}>
|
||||
<option value="minutes">{t("minute_timeUnit")}</option>
|
||||
<option value="hours">{t("hour_timeUnit")}</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,21 +4,20 @@ import { FormEvent, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, ImageUploader, showToast, TextArea } from "@calcom/ui";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
|
||||
interface IUserProfile {
|
||||
user?: User;
|
||||
}
|
||||
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||
|
||||
type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
interface IUserProfileProps {
|
||||
user: IOnboardingPageProps["user"];
|
||||
}
|
||||
|
||||
const UserProfile = (props: IUserProfile) => {
|
||||
const UserProfile = (props: IUserProfileProps) => {
|
||||
const { user } = props;
|
||||
const { t } = useLocale();
|
||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||
@@ -100,7 +99,14 @@ const UserProfile = (props: IUserProfile) => {
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && <AvatarSSR user={user} alt="Profile picture" className="h-16 w-16" />}
|
||||
{user && (
|
||||
<Avatar
|
||||
alt={user.username || "user avatar"}
|
||||
gravatarFallbackMd5={user.emailMd5}
|
||||
size="lg"
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
type="hidden"
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, TimezoneSelect } from "@calcom/ui";
|
||||
|
||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||
|
||||
interface IUserSettingsProps {
|
||||
user: User;
|
||||
user: IOnboardingPageProps["user"];
|
||||
nextStep: () => void;
|
||||
}
|
||||
|
||||
@@ -23,11 +24,9 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
control,
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
name: user?.name || "",
|
||||
username: user?.username || "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
@@ -41,39 +40,19 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: onSuccess,
|
||||
});
|
||||
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
|
||||
const paymentRequired = stripeCustomer?.isPremium ? !stripeCustomer?.paidForPremium : false;
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (paymentRequired) {
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
name: data.name,
|
||||
timeZone: selectedTimeZone,
|
||||
});
|
||||
});
|
||||
|
||||
const [currentUsername, setCurrentUsername] = useState(user.username || "");
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-6">
|
||||
{/* Username textfield */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="username"
|
||||
render={({ field: { value, ref, onChange } }) => (
|
||||
<UsernameAvailability
|
||||
readonly={true}
|
||||
currentUsername={currentUsername}
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={value}
|
||||
usernameRef={ref}
|
||||
setInputUsernameValue={onChange}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<UsernameAvailabilityField user={user} />
|
||||
|
||||
{/* Full name textfield */}
|
||||
<div className="w-full">
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { StepCard, Steps } from "../../..";
|
||||
import { StepCard, Steps } from "@calcom/ui";
|
||||
|
||||
export default function WizardLayout({
|
||||
children,
|
||||
@@ -0,0 +1,4 @@
|
||||
We don't have many layouts currently however, if they are relevant to a features - please put them in their relvant component folder
|
||||
in `web/components` if you believe they will be reused in other apps please place them in `packages/features/[feature]`
|
||||
|
||||
Thanks Sean 🔥
|
||||
@@ -1,134 +0,0 @@
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
|
||||
import React, { FC } from "react";
|
||||
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
interface OptionTypeBase {
|
||||
label: string;
|
||||
value: EventTypeCustomInputType;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: SubmitHandler<IFormInput>;
|
||||
onCancel: () => void;
|
||||
selectedCustomInput?: EventTypeCustomInput;
|
||||
}
|
||||
|
||||
type IFormInput = EventTypeCustomInput;
|
||||
|
||||
const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const inputOptions: OptionTypeBase[] = [
|
||||
{ value: EventTypeCustomInputType.TEXT, label: t("text") },
|
||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||
];
|
||||
const { selectedCustomInput } = props;
|
||||
const defaultValues = selectedCustomInput || { type: inputOptions[0].value };
|
||||
const { register, control, handleSubmit } = useForm<IFormInput>({
|
||||
defaultValues,
|
||||
});
|
||||
const selectedInputType = useWatch({ name: "type", control });
|
||||
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value);
|
||||
|
||||
const onCancel = () => {
|
||||
props.onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(props.onSubmit)}>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
{t("input_type")}
|
||||
</label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
id="type"
|
||||
defaultValue={selectedInputOption}
|
||||
options={inputOptions}
|
||||
isSearchable={false}
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
|
||||
onChange={(option) => option && field.onChange(option.value)}
|
||||
value={selectedInputOption}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
|
||||
{t("label")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(selectedInputType === EventTypeCustomInputType.TEXT ||
|
||||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
|
||||
<div className="mb-2">
|
||||
<label htmlFor="placeholder" className="block text-sm font-medium text-gray-700">
|
||||
{t("placeholder")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="required"
|
||||
type="checkbox"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 ltr:mr-2 rtl:ml-2"
|
||||
defaultChecked={selectedCustomInput?.required ?? true}
|
||||
{...register("required")}
|
||||
/>
|
||||
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||
{t("is_required")}
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
id="eventTypeId"
|
||||
value={selectedCustomInput?.eventTypeId || -1}
|
||||
{...register("eventTypeId", { valueAsNumber: true })}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
id="id"
|
||||
value={selectedCustomInput?.id || -1}
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 flex space-x-2 sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInputTypeForm;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Attendee, Booking, User } from "@prisma/client";
|
||||
import type { FC } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { JsonLd } from "react-schemaorg";
|
||||
import type { EventReservation, Person, ReservationStatusType } from "schema-dts";
|
||||
|
||||
type EventSchemaUser = Pick<User, "name" | "email">;
|
||||
type EventSchemaAttendee = Pick<Attendee, "name" | "email">;
|
||||
|
||||
interface EventReservationSchemaInterface {
|
||||
reservationId: Booking["uid"];
|
||||
eventName: Booking["title"];
|
||||
startTime: Booking["startTime"];
|
||||
endTime: Booking["endTime"];
|
||||
organizer: EventSchemaUser | null;
|
||||
attendees: EventSchemaAttendee[];
|
||||
location: Booking["location"];
|
||||
description: Booking["description"];
|
||||
status: Booking["status"];
|
||||
}
|
||||
|
||||
const EventReservationSchema: FC<EventReservationSchemaInterface> = ({
|
||||
reservationId,
|
||||
eventName,
|
||||
startTime,
|
||||
endTime,
|
||||
organizer,
|
||||
attendees,
|
||||
location,
|
||||
description,
|
||||
status,
|
||||
}) => {
|
||||
const reservationStatus = useMemo<ReservationStatusType>(() => {
|
||||
switch (status) {
|
||||
case "ACCEPTED":
|
||||
return "ReservationConfirmed";
|
||||
case "REJECTED":
|
||||
case "CANCELLED":
|
||||
return "ReservationCancelled";
|
||||
case "PENDING":
|
||||
return "ReservationPending";
|
||||
default:
|
||||
return "ReservationHold";
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<JsonLd<EventReservation>
|
||||
item={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "EventReservation",
|
||||
reservationId,
|
||||
reservationStatus,
|
||||
reservationFor: {
|
||||
"@type": "Event",
|
||||
name: eventName,
|
||||
startDate: startTime.toString(),
|
||||
endDate: endTime.toString(),
|
||||
organizer: organizer
|
||||
? ({ "@type": "Person", name: organizer.name, email: organizer.email } as Person)
|
||||
: undefined,
|
||||
attendee: attendees?.map(
|
||||
(person) => ({ "@type": "Person", name: person.name, email: person.email } as Person)
|
||||
),
|
||||
location: location || undefined,
|
||||
description: description || undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventReservationSchema;
|
||||
@@ -82,7 +82,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
|
||||
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
|
||||
@@ -177,7 +177,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||
onClick={handleSetup}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
@@ -186,13 +186,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
|
||||
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ShieldCheckIcon } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui";
|
||||
|
||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ShieldCheckIcon className="h-6 w-6 text-white" />
|
||||
<Icon.ShieldCheckIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="font-cal text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
|
||||
@@ -62,7 +62,7 @@ export function CalendarSwitch(props: {
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="flex space-x-2 py-1">
|
||||
<div className="flex space-x-2 py-1 rtl:space-x-reverse">
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
|
||||
@@ -72,11 +72,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={t("disable_2fa")}
|
||||
description={t("disable_2fa_recommendation")}
|
||||
type="creation"
|
||||
useOwnActionButtons>
|
||||
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
||||
<Form form={form} handleSubmit={handleDisable}>
|
||||
<div className="mb-4">
|
||||
<PasswordField
|
||||
@@ -94,7 +90,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
|
||||
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
|
||||
@@ -130,11 +130,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
title={t("enable_2fa")}
|
||||
description={setupDescriptions[step]}
|
||||
type="creation"
|
||||
useOwnActionButtons>
|
||||
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
@@ -174,7 +170,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||
onClick={handleSetup}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
@@ -183,13 +179,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
className="ltr:ml-2 ltr:mr-2 rtl:ml-2"
|
||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
|
||||
<Button type="submit" className="ltr:ml-2 ltr:mr-2 rtl:ml-2" disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { objectKeys } from "@calcom/lib/objectKeys";
|
||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert, Button, Icon, showToast, TextField } from "@calcom/ui";
|
||||
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import SettingInputContainer from "@components/ui/SettingInputContainer";
|
||||
|
||||
interface Props {
|
||||
team: TeamWithMembers | null | undefined;
|
||||
}
|
||||
|
||||
export default function TeamSettings(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const team = props.team;
|
||||
const hasLogo = !!team?.logo;
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const mutation = trpc.viewer.teams.update.useMutation({
|
||||
onError: (err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
setHasErrors(false);
|
||||
},
|
||||
});
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
|
||||
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
function updateTeamData() {
|
||||
if (!team) return;
|
||||
const variables = {
|
||||
name: nameRef.current?.value,
|
||||
slug: teamUrlRef.current?.value,
|
||||
bio: descriptionRef.current?.value,
|
||||
hideBranding: hideBrandingRef.current?.checked,
|
||||
};
|
||||
// remove unchanged variables
|
||||
objectKeys(variables).forEach((key) => {
|
||||
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
|
||||
});
|
||||
mutation.mutate({ id: team.id, ...variables });
|
||||
}
|
||||
|
||||
function updateLogo(newLogo: string) {
|
||||
if (!team) return;
|
||||
logoRef.current.value = newLogo;
|
||||
mutation.mutate({ id: team.id, logo: newLogo });
|
||||
}
|
||||
|
||||
const removeLogo = () => updateLogo("");
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
||||
<div className="">
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
<form
|
||||
className="divide-y divide-gray-200 lg:col-span-9"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
updateTeamData();
|
||||
}}>
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<SettingInputContainer
|
||||
Icon={Icon.FiLink}
|
||||
label="Team URL"
|
||||
htmlFor="team-url"
|
||||
Input={
|
||||
<TextField
|
||||
name="" // typescript requires name but we don't want component to render name label
|
||||
id="team-url"
|
||||
addOnLeading={
|
||||
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/team/
|
||||
</span>
|
||||
}
|
||||
ref={teamUrlRef}
|
||||
defaultValue={team?.slug as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingInputContainer
|
||||
Icon={Icon.FiHash}
|
||||
label="Team Name"
|
||||
htmlFor="name"
|
||||
Input={
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder={t("your_team_name")}
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
|
||||
defaultValue={team?.name as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<hr />
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={Icon.FiInfo}
|
||||
label={t("about")}
|
||||
htmlFor="about"
|
||||
Input={
|
||||
<>
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
id="about"
|
||||
name="about"
|
||||
rows={3}
|
||||
defaultValue={team?.bio as string}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 text-sm"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={Icon.FiImage}
|
||||
label="Logo"
|
||||
htmlFor="avatar"
|
||||
Input={
|
||||
<>
|
||||
<div className="mt-1 flex">
|
||||
<input
|
||||
ref={logoRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 text-sm"
|
||||
defaultValue={team?.logo ?? undefined}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="logo"
|
||||
id="logo-upload"
|
||||
buttonMsg={hasLogo ? t("edit_logo") : t("upload_a_logo")}
|
||||
handleAvatarChange={updateLogo}
|
||||
imageSrc={team?.logo ?? undefined}
|
||||
/>
|
||||
{hasLogo && (
|
||||
<Button
|
||||
onClick={removeLogo}
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="ml-1 py-1 text-xs">
|
||||
{t("remove_logo")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="hide-branding"
|
||||
name="hide-branding"
|
||||
type="checkbox"
|
||||
ref={hideBrandingRef}
|
||||
defaultChecked={team?.hideBranding}
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit" color="primary">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ConfirmationDialogContent, Dialog, DialogTrigger, Icon, showToast } from "@calcom/ui";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||
import LinkIconButton from "@components/ui/LinkIconButton";
|
||||
|
||||
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
|
||||
const permalink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${props.team?.slug}`;
|
||||
|
||||
const deleteTeamMutation = trpc.viewer.teams.delete.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
router.push(`/settings/teams`);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
const acceptOrLeaveMutation = trpc.viewer.teams.acceptOrLeave.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.teams.list.invalidate();
|
||||
router.push(`/settings/teams`);
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam() {
|
||||
if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id });
|
||||
}
|
||||
function leaveTeam() {
|
||||
if (props.team?.id)
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
accept: false,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-2">
|
||||
{(props.role === MembershipRole.OWNER || props.role === MembershipRole.ADMIN) && (
|
||||
<CreateEventTypeButton
|
||||
isIndividualTeam
|
||||
canAddEvents={true}
|
||||
options={[
|
||||
{
|
||||
teamId: props.team?.id,
|
||||
name: props.team?.name,
|
||||
slug: props.team?.slug,
|
||||
image: props.team?.logo,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Link href={permalink} passHref={true}>
|
||||
<a target="_blank">
|
||||
<LinkIconButton Icon={Icon.FiExternalLink}>{t("preview")}</LinkIconButton>
|
||||
</a>
|
||||
</Link>
|
||||
<LinkIconButton
|
||||
Icon={Icon.FiLink}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_link_team")}
|
||||
</LinkIconButton>
|
||||
{props.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
Icon={Icon.FiTrash}>
|
||||
{t("disband_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
Icon={Icon.FiLogOut}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={leaveTeam}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
{props.team?.id && props.role !== MembershipRole.MEMBER && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<div className="mt-5 hidden space-y-1 sm:block">
|
||||
<LinkIconButton Icon={Icon.FiClock}>View Availability</LinkIconButton>
|
||||
<p className="mt-2 text-sm text-gray-500">See your team members availability at a glance.</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { LOGO } from "@calcom/lib/constants";
|
||||
import { APP_NAME, LOGO } from "@calcom/lib/constants";
|
||||
import { Credits, HeadSeo } from "@calcom/ui";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -21,8 +20,9 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
<HeadSeo title={props.title} description={props.description} />
|
||||
{props.showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mb-auto h-4" src={LOGO} alt="Cal.com Logo" />
|
||||
<img className="mb-auto h-4" src={LOGO} alt={`${APP_NAME} Logo`} />
|
||||
)}
|
||||
|
||||
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
|
||||
{props.heading && <h2 className="font-cal text-center text-3xl text-neutral-900">{props.heading}</h2>}
|
||||
</div>
|
||||
@@ -35,7 +35,10 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
<div className="border-1 mx-2 rounded-md border-gray-200 bg-white px-4 py-10 sm:px-10">
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="mt-8 text-center text-sm text-neutral-600">{props.footerText}</div>
|
||||
<div className="mt-8 text-center text-sm text-neutral-600">
|
||||
{props.footerText}
|
||||
<Credits />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={idx} className="-mr-2 inline-block">
|
||||
<li key={idx} className="-ltr:mr-2 inline-block rtl:ml-2">
|
||||
{item.href ? (
|
||||
<Link href={item.href}>
|
||||
<a>{avatar}</a>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { debounce, noop } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
import { RefCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getPremiumPlanMode, getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -11,22 +11,10 @@ import { User } from "@calcom/prisma/client";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
StarIconSolid,
|
||||
} from "@calcom/ui";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogHeader, Icon, Input, Label } from "@calcom/ui";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
NORMAL = "NORMAL",
|
||||
UPGRADE = "UPGRADE",
|
||||
DOWNGRADE = "DOWNGRADE",
|
||||
}
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
@@ -37,43 +25,21 @@ interface ICustomUsernameProps {
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
user: Pick<
|
||||
User,
|
||||
| "username"
|
||||
| "name"
|
||||
| "email"
|
||||
| "bio"
|
||||
| "avatar"
|
||||
| "timeZone"
|
||||
| "weekStart"
|
||||
| "hideBranding"
|
||||
| "theme"
|
||||
| "plan"
|
||||
| "brandColor"
|
||||
| "darkBrandColor"
|
||||
| "timeFormat"
|
||||
| "metadata"
|
||||
>;
|
||||
user: Pick<User, "username" | "metadata">;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const obtainNewUsernameChangeCondition = ({
|
||||
userIsPremium,
|
||||
isNewUsernamePremium,
|
||||
stripeCustomer,
|
||||
}: {
|
||||
userIsPremium: boolean;
|
||||
isNewUsernamePremium: boolean;
|
||||
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
|
||||
}) => {
|
||||
if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) {
|
||||
if (!userIsPremium && isNewUsernamePremium) {
|
||||
return UsernameChangeStatusEnum.UPGRADE;
|
||||
}
|
||||
|
||||
if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") {
|
||||
return UsernameChangeStatusEnum.DOWNGRADE;
|
||||
}
|
||||
return UsernameChangeStatusEnum.NORMAL;
|
||||
};
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
@@ -106,7 +72,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
setIsInputUsernamePremium(data.premium);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
[]
|
||||
[currentUsername]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -137,7 +103,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
});
|
||||
|
||||
// when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for
|
||||
const paymentRequired = !currentUsername && stripeCustomer?.isPremium && !stripeCustomer?.paidForPremium;
|
||||
const paymentRequired = !currentUsername && stripeCustomer?.isPremium;
|
||||
|
||||
const usernameChangeCondition = obtainNewUsernameChangeCondition({
|
||||
userIsPremium: isCurrentUsernamePremium,
|
||||
@@ -194,7 +160,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
};
|
||||
|
||||
const saveUsername = () => {
|
||||
if (usernameChangeCondition === UsernameChangeStatusEnum.NORMAL) {
|
||||
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
|
||||
updateUsername.mutate({
|
||||
username: inputUsernameValue,
|
||||
});
|
||||
@@ -264,7 +230,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
isInputUsernamePremium ? "text-orange-400" : "",
|
||||
usernameIsAvailable ? "" : ""
|
||||
)}>
|
||||
{isInputUsernamePremium ? <StarIconSolid className="mt-[2px] w-6" /> : <></>}
|
||||
{isInputUsernamePremium ? <Icon.StarIconSolid className="mt-[2px] w-6" /> : <></>}
|
||||
{!isInputUsernamePremium && usernameIsAvailable ? <Icon.FiCheck className="mt-2 w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
@@ -279,14 +245,6 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
{paymentMsg}
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
{usernameIsAvailable && (
|
||||
<p className={classNames("mt-1 text-xs text-gray-900")}>
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && (
|
||||
<>{t("premium_to_standard_username_description")}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent>
|
||||
<div className="flex flex-row">
|
||||
@@ -295,13 +253,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
</div>
|
||||
<div className="mb-4 w-full px-4 pt-1">
|
||||
<DialogHeader title={t("confirm_username_change_dialog_title")} />
|
||||
{usernameChangeCondition && usernameChangeCondition !== UsernameChangeStatusEnum.NORMAL && (
|
||||
<p className="-mt-4 mb-4 text-sm text-gray-800">
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE &&
|
||||
t("change_username_standard_to_premium")}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE &&
|
||||
t("change_username_premium_to_standard")}
|
||||
</p>
|
||||
{usernameChangeCondition && usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||
<p className="mb-4 text-sm text-gray-800">{t("change_username_standard_to_premium")}</p>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
|
||||
@@ -323,8 +276,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
{/* redirect to checkout */}
|
||||
{(usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE ||
|
||||
usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE) && (
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
@@ -336,7 +288,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
</Button>
|
||||
)}
|
||||
{/* Normal save */}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.NORMAL && (
|
||||
{usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
@@ -347,10 +299,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
{t("save")}
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -7,7 +7,17 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogHeader, Icon, Input, Label } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
@@ -77,10 +87,9 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
const ActionButtons = () => {
|
||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||
<div className="flex flex-row">
|
||||
<div className="rlt:space-x-reverse mt-px flex flex-row space-x-2 ltr:ml-2 ltr:mr-2 rtl:ml-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
className="mx-2"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid="update-username-btn">
|
||||
{t("update")}
|
||||
@@ -88,7 +97,6 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="mx-2"
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
@@ -110,30 +118,24 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
</div>
|
||||
<div className="mt-2 flex rounded-md">
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
<TextField
|
||||
ref={usernameRef}
|
||||
name="username"
|
||||
value={inputUsernameValue}
|
||||
addOnLeading={
|
||||
<>{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/</>
|
||||
}
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mb-0 mt-0 h-6 rounded-md rounded-l-none",
|
||||
"mb-0 mt-0 h-6 rounded-md ltr:rounded-l-none rtl:rounded-r-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
)}
|
||||
defaultValue={currentUsername}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
setInputUsernameValue(event.target.value);
|
||||
@@ -141,18 +143,18 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
data-testid="username-input"
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-0 flex flex-row">
|
||||
<div className="absolute right-[2px] top-6 flex flex-row">
|
||||
<span className={classNames("mx-2 py-2")}>
|
||||
{usernameIsAvailable ? <Icon.FiCheck className="mt-[2px] w-6" /> : <></>}
|
||||
{usernameIsAvailable ? <Icon.FiCheck className="w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:inline">
|
||||
<div className="mt-5 hidden md:inline">
|
||||
<ActionButtons />
|
||||
</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">{t("username_already_taken")}</p>}
|
||||
|
||||
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
|
||||
<div className="mt-2 flex justify-end md:hidden">
|
||||
@@ -194,10 +196,8 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
|
||||
import { PremiumTextfield } from "./PremiumTextfield";
|
||||
import { UsernameTextfield } from "./UsernameTextfield";
|
||||
|
||||
export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : PremiumTextfield;
|
||||
|
||||
interface UsernameAvailabilityFieldProps {
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
user: Pick<User, "username" | "metadata">;
|
||||
}
|
||||
export const UsernameAvailabilityField = ({
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
user,
|
||||
}: UsernameAvailabilityFieldProps) => {
|
||||
const [currentUsername, setCurrentUsername] = useState(user.username ?? "");
|
||||
const formMethods = useForm({
|
||||
defaultValues: {
|
||||
username: currentUsername,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="username"
|
||||
render={({ field: { ref, onChange, value } }) => {
|
||||
return (
|
||||
<UsernameAvailability
|
||||
currentUsername={currentUsername}
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={value}
|
||||
usernameRef={ref}
|
||||
setInputUsernameValue={onChange}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -109,7 +109,7 @@ function Select<
|
||||
IndicatorSeparator: () => null,
|
||||
Input: InputComponent,
|
||||
}}
|
||||
className={classNames("text-sm", className)}
|
||||
className={classNames("border-0 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DefaultSeoProps, NextSeoProps } from "next-seo";
|
||||
|
||||
import { SEO_IMG_DEFAULT, SEO_IMG_OGIMG } from "@calcom/lib/constants";
|
||||
import { APP_NAME, SEO_IMG_DEFAULT, SEO_IMG_OGIMG } from "@calcom/lib/constants";
|
||||
|
||||
export type HeadSeoProps = {
|
||||
title: string;
|
||||
@@ -27,7 +27,7 @@ export const seoConfig: {
|
||||
defaultNextSeo: DefaultSeoProps;
|
||||
} = {
|
||||
headSeo: {
|
||||
siteName: "Cal.com",
|
||||
siteName: APP_NAME,
|
||||
},
|
||||
defaultNextSeo: {
|
||||
twitter: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import parser from "accept-language-parser";
|
||||
import { IncomingMessage } from "http";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Maybe } from "@calcom/trpc/server";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
@@ -17,6 +16,7 @@ export function getLocaleFromHeaders(req: IncomingMessage): string {
|
||||
}
|
||||
|
||||
export const getOrSetUserLocaleFromHeaders = async (req: IncomingMessage): Promise<string> => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const session = await getSession({ req });
|
||||
const preferredLocale = getLocaleFromHeaders(req);
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { UserPlan } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* TODO: It should be exposed at a single place.
|
||||
*/
|
||||
export function useExposePlanGlobally(plan: UserPlan) {
|
||||
// Don't wait for component to mount. Do it ASAP. Delaying it would delay UI Configuration.
|
||||
if (typeof window !== "undefined") {
|
||||
// This variable is used by embed-iframe to determine if we should allow UI configuration
|
||||
window.CalComPlan = plan;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useRouter } from "next/router";
|
||||
|
||||
export default function usePublicPage() {
|
||||
const router = useRouter();
|
||||
const isPublicPage = ["/[user]", "/success", "/cancel", "/reschedule"].find((route) =>
|
||||
const isPublicPage = ["/[user]", "/booking", "/cancel", "/reschedule"].find((route) =>
|
||||
router.pathname.startsWith(route)
|
||||
);
|
||||
return isPublicPage;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function useRouterQuery<T extends string>(name: T) {
|
||||
const router = useRouter();
|
||||
const existingQueryParams = router.asPath.split("?")[1];
|
||||
|
||||
const urlParams = new URLSearchParams(existingQueryParams);
|
||||
const query = Object.fromEntries(urlParams);
|
||||
|
||||
const setQuery = (newValue: string | number | null | undefined) => {
|
||||
router.replace({ query: { ...router.query, [name]: newValue } }, undefined, { shallow: true });
|
||||
router.replace({ query: { ...router.query, ...query, [name]: newValue } }, undefined, { shallow: true });
|
||||
};
|
||||
|
||||
return { [name]: query[name], setQuery } as {
|
||||
[K in T]: string | undefined;
|
||||
} & { setQuery: typeof setQuery };
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
export function isBrandingHidden<TUser extends Pick<User, "hideBranding" | "plan">>(user: TUser) {
|
||||
return user.hideBranding && user.plan !== "FREE";
|
||||
export function isBrandingHidden(hideBrandingSetting: boolean, belongsToActiveTeam: boolean) {
|
||||
return belongsToActiveTeam && hideBrandingSetting;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,29 @@ export type EmbedProps = {
|
||||
export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
||||
return async (context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<EmbedProps>> => {
|
||||
const ssrResponse = await getServerSideProps(context);
|
||||
const embed = context.query.embed;
|
||||
|
||||
if ("redirect" in ssrResponse) {
|
||||
// Use a dummy URL https://base as the fallback base URL so that URL parsing works for relative URLs as well.
|
||||
const destinationUrlObj = new URL(ssrResponse.redirect.destination, "https://base");
|
||||
|
||||
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
|
||||
const newDestinationUrl =
|
||||
destinationUrlObj.pathname +
|
||||
"/embed?" +
|
||||
destinationUrlObj.searchParams.toString() +
|
||||
"&embed=" +
|
||||
embed;
|
||||
|
||||
return {
|
||||
...ssrResponse,
|
||||
redirect: {
|
||||
...ssrResponse.redirect,
|
||||
destination: newDestinationUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!("props" in ssrResponse)) {
|
||||
return ssrResponse;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,21 @@ const nextConfig = {
|
||||
source: "/router",
|
||||
destination: "/apps/routing-forms/router",
|
||||
},
|
||||
{
|
||||
source: "/success/:path*",
|
||||
has: [
|
||||
{
|
||||
type: "query",
|
||||
key: "uid",
|
||||
value: "(?<uid>.*)",
|
||||
},
|
||||
],
|
||||
destination: "/booking/:uid/:path*",
|
||||
},
|
||||
{
|
||||
source: "/cancel/:path*",
|
||||
destination: "/booking/:path*",
|
||||
},
|
||||
/* TODO: have these files being served from another deployment or CDN {
|
||||
source: "/embed/embed.js",
|
||||
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
|
||||
@@ -212,6 +227,11 @@ const nextConfig = {
|
||||
destination: "/404",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/booking/direct/:action/:email/:bookingUid/:oldToken",
|
||||
destination: "/api/link?action=:action&email=:email&bookingUid=:bookingUid&oldToken=:oldToken",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -38,7 +38,7 @@
|
||||
"@calcom/tsconfig": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@daily-co/daily-js": "^0.26.0",
|
||||
"@formkit/auto-animate": "^1.0.0-beta.1",
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@glidejs/glide": "^3.5.2",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/error-message": "^2.0.0",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^4.3.9",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
"@vercel/og": "^0.0.19",
|
||||
"@vercel/og": "^0.0.21",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
"async": "^3.2.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -107,19 +107,21 @@
|
||||
"react-live-chat-loader": "^2.7.3",
|
||||
"react-multi-email": "^0.5.3",
|
||||
"react-phone-number-input": "^3.2.7",
|
||||
"react-schemaorg": "^2.0.0",
|
||||
"react-select": "^5.4.0",
|
||||
"react-timezone-select": "^1.3.2",
|
||||
"react-use-intercom": "1.5.1",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.7",
|
||||
"rrule": "^2.7.1",
|
||||
"schema-dts": "^1.1.0",
|
||||
"short-uuid": "^4.2.0",
|
||||
"stripe": "^9.16.0",
|
||||
"superjson": "1.9.1",
|
||||
"tailwindcss-radix": "^2.6.0",
|
||||
"uuid": "^8.3.2",
|
||||
"web3": "^1.7.5",
|
||||
"zod": "^3.19.1"
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.6",
|
||||
|
||||
+10
-8
@@ -1,15 +1,13 @@
|
||||
import { GetStaticPropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { Icon, HeadSeo } from "@calcom/ui";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
import { ssgInit } from "@server/lib/ssg";
|
||||
|
||||
export default function Custom404() {
|
||||
@@ -38,7 +36,7 @@ export default function Custom404() {
|
||||
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
|
||||
}, [username]);
|
||||
|
||||
const isSuccessPage = router.asPath.startsWith("/success");
|
||||
const isSuccessPage = router.asPath.startsWith("/booking");
|
||||
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
|
||||
const isSignup = router.asPath.startsWith("/signup");
|
||||
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
|
||||
@@ -64,7 +62,7 @@ export default function Custom404() {
|
||||
<h1 className="font-cal mt-2 text-3xl font-extrabold text-gray-900">
|
||||
{t("signup_requires")}
|
||||
</h1>
|
||||
<p className="mt-4">{t("signup_requires_description")}</p>
|
||||
<p className="mt-4">{t("signup_requires_description", { companyName: COMPANY_NAME })}</p>
|
||||
</div>
|
||||
<div className="mt-12">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
@@ -191,7 +189,11 @@ export default function Custom404() {
|
||||
</span>
|
||||
) : isCalcom ? (
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t("the_username")} <strong className="text-blue-500">cal.com{username}</strong>{" "}
|
||||
{t("the_username")}{" "}
|
||||
<strong className="text-blue-500">
|
||||
{new URL(WEBSITE_URL).hostname}
|
||||
{username}
|
||||
</strong>{" "}
|
||||
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
||||
</a>
|
||||
) : (
|
||||
|
||||
+35
-16
@@ -1,30 +1,49 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Icon, showToast } from "@calcom/ui";
|
||||
|
||||
export default function Error500() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<Head>
|
||||
<title>Something unexpected occurred | Cal.com</title>
|
||||
<title>Something unexpected occurred | {APP_NAME}</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="m-auto text-center">
|
||||
<h1 className="font-cal text-[250px] text-gray-900">
|
||||
5
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src="/error.svg" className="-mt-10 inline w-60" alt="0" />
|
||||
}
|
||||
0
|
||||
</h1>
|
||||
<h2 className="mb-2 -mt-16 text-3xl text-gray-600">It's not you, it's us.</h2>
|
||||
<p className="mb-4 max-w-2xl text-gray-500">
|
||||
<div className="rtl: m-auto rounded-md bg-white p-10 text-right ltr:text-left">
|
||||
<h1 className="font-cal text-6xl text-black">500</h1>
|
||||
<h2 className="mt-6 text-2xl font-medium text-black">It's not you, it's us.</h2>
|
||||
<p className="mt-4 mb-6 max-w-2xl text-sm text-gray-600">
|
||||
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
|
||||
away for you.
|
||||
</p>
|
||||
<Button href={`${WEBSITE_URL}/support`}>Contact support</Button>
|
||||
{router.query.error && (
|
||||
<div className="mb-8 flex flex-col">
|
||||
<p className="mb-4 max-w-2xl text-sm text-gray-600">
|
||||
Please provide the following text when contacting support to better help you:
|
||||
</p>
|
||||
<pre className="w-full max-w-2xl whitespace-normal break-words rounded-md bg-gray-200 p-4 text-gray-900">
|
||||
{router.query.error}
|
||||
<br />
|
||||
<Button
|
||||
color="secondary"
|
||||
className="mt-2 border-0 font-sans font-normal hover:bg-gray-300"
|
||||
StartIcon={Icon.FiCopy}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(router.query.error as string);
|
||||
showToast("Link copied!", "success");
|
||||
}}>
|
||||
{t("copy")}
|
||||
</Button>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
|
||||
<Button color="secondary" href="javascript:history.back()" className="ml-2">
|
||||
Go back
|
||||
</Button>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import defaultEvents, {
|
||||
@@ -26,9 +27,8 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
|
||||
import prisma from "@calcom/prisma";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { BadgeCheckIcon, EventTypeDescriptionLazy as EventTypeDescription, Icon } from "@calcom/ui";
|
||||
import { Icon, HeadSeo } from "@calcom/ui";
|
||||
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
@@ -37,7 +37,6 @@ import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
const HeadSeo = dynamic(() => import("@components/seo/head-seo"));
|
||||
export default function User(props: inferSSRProps<typeof getServerSideProps> & EmbedProps) {
|
||||
const { users, profile, eventTypes, isDynamicGroup, dynamicNames, dynamicUsernames, isSingleUser } = props;
|
||||
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||
@@ -59,7 +58,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
{eventTypes.map((type, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="dark:bg-darkgray-100 dark:border-darkgray-200 group relative rounded-sm border border-neutral-200 bg-white dark:hover:border-neutral-600">
|
||||
className="dark:bg-darkgray-100 group relative border-b border-neutral-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-neutral-700 dark:hover:border-neutral-600">
|
||||
<Icon.FiArrowRight className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||
<Link href={getUsernameSlugLink({ users: props.users, slug: type.slug })}>
|
||||
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
|
||||
@@ -69,7 +68,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
</div>
|
||||
<div className="mt-1 self-center">
|
||||
<AvatarGroup
|
||||
border="border-2 border-white"
|
||||
border="border-2 border-white dark:border-darkgray-100"
|
||||
truncateAfter={4}
|
||||
className="flex flex-shrink-0"
|
||||
size={10}
|
||||
@@ -92,7 +91,6 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const query = { ...router.query };
|
||||
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
||||
useExposePlanGlobally("PRO");
|
||||
const nameOrUsername = user.name || user.username || "";
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
@@ -140,7 +138,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
<h1 className="font-cal mb-1 text-3xl text-neutral-900 dark:text-white">
|
||||
{nameOrUsername}
|
||||
{user.verified && (
|
||||
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
|
||||
<Icon.BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
|
||||
)}
|
||||
</h1>
|
||||
<p className="dark:text-darkgray-600 text-s text-neutral-500">{user.bio}</p>
|
||||
@@ -272,7 +270,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
darkBrandColor: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
plan: true,
|
||||
away: true,
|
||||
verified: true,
|
||||
allowDynamicBooking: true,
|
||||
|
||||
@@ -4,14 +4,15 @@ import { JSONObject } from "superjson/dist/types";
|
||||
import { z } from "zod";
|
||||
|
||||
import { privacyFilteredLocations, LocationObject } from "@calcom/app-store/locations";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
@@ -71,7 +72,6 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
id: true,
|
||||
username: true,
|
||||
away: true,
|
||||
plan: true,
|
||||
name: true,
|
||||
hideBranding: true,
|
||||
timeZone: true,
|
||||
@@ -110,6 +110,11 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,10 +126,10 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
"strikethrough",
|
||||
]);
|
||||
|
||||
if (!user || !user.eventTypes) return { notFound: true };
|
||||
if (!user || !user.eventTypes.length) return { notFound: true };
|
||||
|
||||
const [eventType]: (typeof user.eventTypes[number] & {
|
||||
users: Pick<User, "name" | "username" | "hideBranding" | "plan" | "timeZone">[];
|
||||
users: Pick<User, "name" | "username" | "hideBranding" | "timeZone">[];
|
||||
})[] = [
|
||||
{
|
||||
...user.eventTypes[0],
|
||||
@@ -133,7 +138,6 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
hideBranding: user.hideBranding,
|
||||
plan: user.plan,
|
||||
timeZone: user.timeZone,
|
||||
},
|
||||
],
|
||||
@@ -150,6 +154,14 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
locations: privacyFilteredLocations(locations),
|
||||
descriptionAsSafeHTML: eventType.description ? md.render(eventType.description) : null,
|
||||
});
|
||||
// Check if the user you are logging into has any active teams
|
||||
const hasActiveTeam =
|
||||
user.teams.filter((m) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) return true;
|
||||
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
|
||||
if (metadata.success && metadata.data?.subscriptionId) return true;
|
||||
return false;
|
||||
}).length > 0;
|
||||
|
||||
return {
|
||||
props: {
|
||||
@@ -167,6 +179,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
away: user?.away,
|
||||
isDynamic: false,
|
||||
trpcState: ssg.dehydrate(),
|
||||
isBrandingHidden: isBrandingHidden(user.hideBranding, hasActiveTeam),
|
||||
},
|
||||
revalidate: 10, // seconds
|
||||
};
|
||||
@@ -212,7 +225,6 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,7 +236,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
||||
|
||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: privacyFilteredLocations(locations),
|
||||
users: users.map((user) => {
|
||||
@@ -232,7 +244,6 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
hideBranding: user.hideBranding,
|
||||
plan: user.plan,
|
||||
timeZone: user.timeZone,
|
||||
};
|
||||
}),
|
||||
@@ -262,6 +273,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
||||
isDynamic: true,
|
||||
away: false,
|
||||
trpcState: ssg.dehydrate(),
|
||||
isBrandingHidden: false, // I think we should always show branding for dynamic groups - saves us checking every single user
|
||||
},
|
||||
revalidate: 10, // seconds
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { bookEventTypeSelect } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
@@ -113,7 +114,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
};
|
||||
|
||||
@@ -126,6 +127,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
schedulingType: null,
|
||||
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
||||
users: users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
import superjson from "superjson";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
@@ -18,7 +19,6 @@ import { seoConfig } from "@lib/config/next-seo.config";
|
||||
|
||||
import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
|
||||
import "../styles/fonts.css";
|
||||
import "../styles/globals.css";
|
||||
|
||||
function MyApp(props: AppProps) {
|
||||
@@ -31,13 +31,15 @@ function MyApp(props: AppProps) {
|
||||
}
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<AppProviders {...props}>
|
||||
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
||||
<I18nLanguageHandler />
|
||||
<Script
|
||||
id="page-status"
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
<Head>
|
||||
<script dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
</Head>
|
||||
{getLayout(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
|
||||
import Script from "next/script";
|
||||
|
||||
import { getDirFromLang } from "@calcom/lib/i18n";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps;
|
||||
|
||||
function toRunBeforeReactOnClient() {
|
||||
const calEmbedMode =
|
||||
location.search.includes("embed=") ||
|
||||
/* Iframe Name */
|
||||
window.name.includes("cal-embed");
|
||||
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
|
||||
/* Iframe Name */
|
||||
window.name.includes("cal-embed");
|
||||
|
||||
window.isEmbed = () => {
|
||||
// Once an embed mode always an embed mode
|
||||
@@ -47,7 +49,7 @@ class MyDocument extends Document<Props> {
|
||||
|
||||
render() {
|
||||
const { locale } = this.props.__NEXT_DATA__;
|
||||
const dir = locale === "ar" || locale === "he" ? "rtl" : "ltr";
|
||||
const dir = getDirFromLang(locale);
|
||||
return (
|
||||
<Html lang={locale} dir={dir}>
|
||||
<Head>
|
||||
@@ -70,7 +72,9 @@ class MyDocument extends Document<Props> {
|
||||
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
|
||||
Persist the embed mode in sessionStorage because query param might get lost during browsing.
|
||||
*/}
|
||||
<script
|
||||
<Script
|
||||
id="run-before-client"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(${toRunBeforeReactOnClient.toString()})()`,
|
||||
}}
|
||||
|
||||
@@ -13,13 +13,14 @@ import path from "path";
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
|
||||
import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { ErrorCode, verifyPassword, isPasswordValid } from "@calcom/lib/auth";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { ErrorCode, isPasswordValid, verifyPassword } from "@calcom/lib/auth";
|
||||
import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||
import rateLimit from "@calcom/lib/rateLimit";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
|
||||
import { randomString } from "@lib/random";
|
||||
@@ -63,6 +64,11 @@ const providers: Provider[] = [
|
||||
password: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
teams: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,6 +122,14 @@ const providers: Provider[] = [
|
||||
intervalInMs: 60 * 1000, // 1 minute
|
||||
});
|
||||
await limiter.check(10, user.email); // 10 requests per minute
|
||||
// Check if the user you are logging into has any active teams
|
||||
const hasActiveTeams =
|
||||
user.teams.filter((m) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) return true;
|
||||
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
|
||||
if (metadata.success && metadata.data?.subscriptionId) return true;
|
||||
return false;
|
||||
}).length > 0;
|
||||
|
||||
// authentication success- but does it meet the minimum password requirements?
|
||||
if (user.role === "ADMIN" && !isPasswordValid(credentials.password, false, true)) {
|
||||
@@ -124,7 +138,8 @@ const providers: Provider[] = [
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: "USER",
|
||||
role: "INACTIVE_ADMIN",
|
||||
belongsToActiveTeam: hasActiveTeams,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,6 +149,7 @@ const providers: Provider[] = [
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
belongsToActiveTeam: hasActiveTeams,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -204,9 +220,9 @@ if (true) {
|
||||
});
|
||||
const emailTemplate = Handlebars.compile(emailFile);
|
||||
transporter.sendMail({
|
||||
from: `${process.env.EMAIL_FROM}` || "Cal.com",
|
||||
from: `${process.env.EMAIL_FROM}` || APP_NAME,
|
||||
to: identifier,
|
||||
subject: "Your sign-in link for Cal.com",
|
||||
subject: "Your sign-in link for " + APP_NAME,
|
||||
html: emailTemplate({
|
||||
base_url: WEBAPP_URL,
|
||||
signin_url: url,
|
||||
@@ -272,6 +288,7 @@ export default NextAuth({
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
impersonatedByUID: user?.impersonatedByUID,
|
||||
belongsToActiveTeam: user?.belongsToActiveTeam,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,6 +324,7 @@ export default NextAuth({
|
||||
email: existingUser.email,
|
||||
role: existingUser.role,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,6 +342,7 @@ export default NextAuth({
|
||||
username: token.username as string,
|
||||
role: token.role as UserPermissionRole,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
},
|
||||
};
|
||||
return calendsoSession;
|
||||
|
||||
@@ -46,7 +46,6 @@ async function handler(req: NextApiRequest) {
|
||||
name: parsedQuery.data.full_name,
|
||||
emailVerified: new Date(),
|
||||
locale: "en", // TODO: We should revisit this
|
||||
plan: "PRO",
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||
if (process.env.CRON_API_KEY !== apiKey) {
|
||||
@@ -18,35 +13,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* We should add and extra check for non-paying customers in Stripe so we can
|
||||
* downgrade them here.
|
||||
* Remove this endpoint
|
||||
*/
|
||||
|
||||
await prisma.user.updateMany({
|
||||
data: {
|
||||
plan: "FREE",
|
||||
},
|
||||
where: {
|
||||
plan: "TRIAL",
|
||||
OR: [
|
||||
/**
|
||||
* If the user doesn't have a trial end date,
|
||||
* use the default 14 day trial from creation.
|
||||
*/
|
||||
{
|
||||
createdDate: {
|
||||
lt: dayjs().subtract(TRIAL_LIMIT_DAYS, "day").toDate(),
|
||||
},
|
||||
trialEndsAt: null,
|
||||
},
|
||||
/** If it does, then honor the trial end date. */
|
||||
{
|
||||
trialEndsAt: {
|
||||
lt: dayjs().toDate(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetcher } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
const getAccessLinkSchema = z.union([
|
||||
z.object({
|
||||
download_link: z.string(),
|
||||
expires: z.number(),
|
||||
}),
|
||||
z.object({}),
|
||||
]);
|
||||
|
||||
const requestQuery = z.object({
|
||||
recordingId: z.string(),
|
||||
});
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<z.infer<typeof getAccessLinkSchema> | void>
|
||||
) {
|
||||
const { recordingId } = requestQuery.parse(req.query);
|
||||
const session = await getSession({ req });
|
||||
|
||||
// Check if user belong to active team
|
||||
if (!session?.user?.belongsToActiveTeam) {
|
||||
return res.status(403);
|
||||
}
|
||||
try {
|
||||
const response = await fetcher(`/recordings/${recordingId}/access-link`).then(getAccessLinkSchema.parse);
|
||||
|
||||
if ("download_link" in response && response.download_link) {
|
||||
return res.status(200).json(response);
|
||||
}
|
||||
|
||||
return res.status(400);
|
||||
} catch (err) {
|
||||
res.status(500);
|
||||
}
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -13,10 +13,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
title: "30min between Pro Example and pro@example.com",
|
||||
description: null,
|
||||
additionalNotes: "asdasdas",
|
||||
customInputs: {
|
||||
"Custom input 01": "sadasdasdsadasd",
|
||||
"Custom input 02": "asdasdasd",
|
||||
},
|
||||
startTime: "2022-06-03T09:00:00-06:00",
|
||||
endTime: "2022-06-03T09:30:00-06:00",
|
||||
organizer: {
|
||||
@@ -32,26 +28,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
timeZone: "America/Chihuahua",
|
||||
language,
|
||||
},
|
||||
{
|
||||
email: "pro@example.com",
|
||||
name: "pro@example.com",
|
||||
timeZone: "America/Chihuahua",
|
||||
language,
|
||||
},
|
||||
{
|
||||
email: "pro@example.com",
|
||||
name: "pro@example.com",
|
||||
timeZone: "America/Chihuahua",
|
||||
language,
|
||||
},
|
||||
],
|
||||
location: "Zoom video",
|
||||
destinationCalendar: null,
|
||||
hideCalendarNotes: false,
|
||||
uid: "bwPWLpjYrx4rZf6MCZdKgE",
|
||||
uid: "xxyPr4cg2xx4XoS2KeMEQy",
|
||||
metadata: {},
|
||||
cancellationReason: "It got late",
|
||||
paymentInfo: { id: "pi_12312", link: "https://cal.com", reason: "no reason" },
|
||||
recurringEvent: null,
|
||||
};
|
||||
|
||||
@@ -60,7 +42,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
|
||||
res.write(
|
||||
renderEmail("AttendeeScheduledEmail", {
|
||||
renderEmail("OrganizerRequestEmail", {
|
||||
attendee: evt.attendees[0],
|
||||
calEvent: evt,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { UserPermissionRole } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { TRPCError } from "@calcom/trpc/server";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer";
|
||||
|
||||
enum DirectAction {
|
||||
ACCEPT = "accept",
|
||||
REJECT = "reject",
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
action: z.nativeEnum(DirectAction),
|
||||
token: z.string(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
const decryptedSchema = z.object({
|
||||
bookingUid: z.string(),
|
||||
userId: z.number().int(),
|
||||
});
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
|
||||
const { action, token, reason } = querySchema.parse(req.query);
|
||||
const { bookingUid, userId } = decryptedSchema.parse(
|
||||
JSON.parse(symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || ""))
|
||||
);
|
||||
|
||||
/** We shape the session as required by tRPC router */
|
||||
async function sessionGetter() {
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
username: "" /* Not used in this context */,
|
||||
role: UserPermissionRole.USER,
|
||||
},
|
||||
hasValidLicense: true,
|
||||
expires: "" /* Not used in this context */,
|
||||
};
|
||||
}
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({
|
||||
where: { uid: bookingUid },
|
||||
});
|
||||
|
||||
try {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
const caller = viewerRouter.createCaller(ctx);
|
||||
await caller.bookings.confirm({
|
||||
bookingId: booking.id,
|
||||
recurringEventId: booking.recurringEventId || undefined,
|
||||
confirmed: action === DirectAction.ACCEPT,
|
||||
reason,
|
||||
});
|
||||
} catch (e) {
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = e.message;
|
||||
res.redirect(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.redirect(`/booking/${bookingUid}`);
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
@@ -53,6 +53,8 @@ export default async function handler(req: NextApiRequest) {
|
||||
interFontMedium,
|
||||
]);
|
||||
const ogConfig = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{ name: "inter", data: interFontData, weight: 400 },
|
||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||
@@ -71,7 +73,7 @@ export default async function handler(req: NextApiRequest) {
|
||||
meetingImage: searchParams.get("meetingImage"),
|
||||
imageType,
|
||||
});
|
||||
return new ImageResponse(
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
title={title}
|
||||
@@ -80,7 +82,9 @@ export default async function handler(req: NextApiRequest) {
|
||||
/>
|
||||
),
|
||||
ogConfig
|
||||
);
|
||||
) as { body: Buffer };
|
||||
|
||||
return new Response(img.body, { status: 200 });
|
||||
}
|
||||
case "app": {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
@@ -89,7 +93,11 @@ export default async function handler(req: NextApiRequest) {
|
||||
slug: searchParams.get("slug"),
|
||||
imageType,
|
||||
});
|
||||
return new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig) as {
|
||||
body: Buffer;
|
||||
};
|
||||
|
||||
return new Response(img.body, { status: 200 });
|
||||
}
|
||||
|
||||
case "generic": {
|
||||
@@ -99,7 +107,11 @@ export default async function handler(req: NextApiRequest) {
|
||||
imageType,
|
||||
});
|
||||
|
||||
return new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig) as {
|
||||
body: Buffer;
|
||||
};
|
||||
|
||||
return new Response(img.body, { status: 200 });
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -47,7 +47,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
select: {
|
||||
username: true,
|
||||
id: true,
|
||||
plan: true,
|
||||
createdDate: true,
|
||||
},
|
||||
});
|
||||
@@ -81,7 +80,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US")
|
||||
: "No info"
|
||||
}</li>
|
||||
<li><b>Plan:</b> ${user.plan}</li>
|
||||
<li><b>Account created:</b> ${new Date(user.createdDate).toLocaleDateString("en-US")}</li>
|
||||
</ul>
|
||||
`,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import slugify from "@lib/slugify";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req: req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
res.status(401).json({ message: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
createdDate: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (req.method === "POST") {
|
||||
const slug = slugify(req.body.name);
|
||||
|
||||
const nameCollisions = await prisma.team.count({
|
||||
where: {
|
||||
OR: [{ name: req.body.name }, { slug: slug }],
|
||||
},
|
||||
});
|
||||
|
||||
if (nameCollisions > 0) {
|
||||
return res.status(409).json({ errorCode: "TeamNameCollision", message: "Team name already taken." });
|
||||
}
|
||||
|
||||
const createTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: req.body.name,
|
||||
slug: slug,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
teamId: createTeam.id,
|
||||
userId: session.user.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
closeComUpsertTeamUser(createTeam, ownerUser, MembershipRole.OWNER);
|
||||
|
||||
return res.status(201).json({ message: "Team created" });
|
||||
}
|
||||
|
||||
res.status(404).json({ message: "Team not found" });
|
||||
}
|
||||
@@ -1,86 +1,72 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// const username = req.url?.substring(1, req.url.lastIndexOf("/"));
|
||||
const username = req.query.username as string;
|
||||
const teamname = req.query.teamname as string;
|
||||
let identity;
|
||||
let linksToThisRoute = false;
|
||||
const querySchema = z
|
||||
.object({
|
||||
username: z.string(),
|
||||
teamname: z.string(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname } = querySchema.parse(req.query);
|
||||
|
||||
if (username) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: username,
|
||||
},
|
||||
select: {
|
||||
avatar: true,
|
||||
email: true,
|
||||
},
|
||||
where: { username },
|
||||
select: { avatar: true, email: true },
|
||||
});
|
||||
identity = {
|
||||
return {
|
||||
name: username,
|
||||
email: user?.email,
|
||||
avatar: user?.avatar,
|
||||
};
|
||||
linksToThisRoute =
|
||||
identity.avatar === `${CAL_URL}/${username}/avatar.png` ||
|
||||
identity.avatar === `${WEBAPP_URL}/${username}/avatar.png`;
|
||||
} else if (teamname) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
slug: teamname,
|
||||
},
|
||||
select: {
|
||||
logo: true,
|
||||
},
|
||||
});
|
||||
identity = {
|
||||
name: teamname,
|
||||
shouldDefaultBeNameBased: true,
|
||||
avatar: team?.logo,
|
||||
};
|
||||
linksToThisRoute =
|
||||
identity.avatar === `${CAL_URL}/team/${teamname}/avatar.png` ||
|
||||
identity.avatar === `${WEBAPP_URL}/team/${teamname}/avatar.png`;
|
||||
}
|
||||
|
||||
const emailMd5 = crypto
|
||||
.createHash("md5")
|
||||
.update((identity?.email as string) || "guest@example.com")
|
||||
.digest("hex");
|
||||
const img = identity?.avatar;
|
||||
// If image isn't set or links to this route itself, use default avatar
|
||||
if (!img || linksToThisRoute) {
|
||||
let defaultSrc = defaultAvatarSrc({ md5: emailMd5 });
|
||||
if (identity?.shouldDefaultBeNameBased) {
|
||||
defaultSrc = getPlaceholderAvatar(null, identity.name);
|
||||
}
|
||||
res.writeHead(302, {
|
||||
Location: defaultSrc,
|
||||
if (teamname) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { slug: teamname },
|
||||
select: { logo: true },
|
||||
});
|
||||
|
||||
res.end();
|
||||
} else if (!img.includes("data:image")) {
|
||||
res.writeHead(302, {
|
||||
Location: img,
|
||||
});
|
||||
res.end();
|
||||
} else {
|
||||
const decoded = img
|
||||
.toString()
|
||||
.replace("data:image/png;base64,", "")
|
||||
.replace("data:image/jpeg;base64,", "");
|
||||
const imageResp = Buffer.from(decoded, "base64");
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Length": imageResp.length,
|
||||
});
|
||||
res.end(imageResp);
|
||||
return {
|
||||
name: teamname,
|
||||
email: null,
|
||||
avatar: team?.logo || getPlaceholderAvatar(null, teamname),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const identity = await getIdentityData(req);
|
||||
const img = identity?.avatar;
|
||||
// If image isn't set or links to this route itself, use default avatar
|
||||
if (!img) {
|
||||
res.writeHead(302, {
|
||||
Location: defaultAvatarSrc({
|
||||
md5: crypto
|
||||
.createHash("md5")
|
||||
.update(identity?.email || "guest@example.com")
|
||||
.digest("hex"),
|
||||
}),
|
||||
});
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (!img.includes("data:image")) {
|
||||
res.writeHead(302, { Location: img });
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
|
||||
const imageResp = Buffer.from(decoded, "base64");
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Length": imageResp.length,
|
||||
});
|
||||
res.end(imageResp);
|
||||
}
|
||||
|
||||
@@ -47,13 +47,7 @@ export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticP
|
||||
}
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const appStore = await getAppRegistry();
|
||||
const paths = appStore.reduce((categories, app) => {
|
||||
if (!categories.includes(app.category)) {
|
||||
categories.push(app.category);
|
||||
}
|
||||
return categories;
|
||||
}, [] as string[]);
|
||||
const paths = Object.keys(AppCategories);
|
||||
|
||||
return {
|
||||
paths: paths.map((category) => ({ params: { category } })),
|
||||
|
||||
@@ -44,7 +44,9 @@ export default function Apps({ categories }: InferGetStaticPropsType<typeof getS
|
||||
export const getStaticProps = async () => {
|
||||
const appStore = await getAppRegistry();
|
||||
const categories = appStore.reduce((c, app) => {
|
||||
c[app.category] = c[app.category] ? c[app.category] + 1 : 1;
|
||||
for (const category of app.categories) {
|
||||
c[category] = c[category] ? c[category] + 1 : 1;
|
||||
}
|
||||
return c;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
|
||||
@@ -1,26 +1,67 @@
|
||||
import { InferGetStaticPropsType, NextPageContext } from "next";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { ChangeEventHandler, useState } from "react";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AppCategories } from "@calcom/prisma/client";
|
||||
import { AllApps, AppsLayout, AppStoreCategories, TrendingAppsSlider } from "@calcom/ui";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { AllApps, AppStoreCategories, Icon, TextField, TrendingAppsSlider } from "@calcom/ui";
|
||||
|
||||
import AppsLayout from "@components/apps/layouts/AppsLayout";
|
||||
|
||||
import { ssgInit } from "@server/lib/ssg";
|
||||
|
||||
export default function Apps({ appStore, categories }: InferGetStaticPropsType<typeof getServerSideProps>) {
|
||||
function AppsSearch({
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TextField
|
||||
className="!border-gray-100 bg-gray-100 !pl-0 focus:!ring-offset-0"
|
||||
addOnLeading={<Icon.FiSearch className="h-4 w-4 text-gray-500" />}
|
||||
addOnClassname="!border-gray-100"
|
||||
containerClassName={classNames("focus:!ring-offset-0", className)}
|
||||
type="search"
|
||||
autoComplete="false"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Apps({ categories, appStore }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const [searchText, setSearchText] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<AppsLayout isPublic heading={t("app_store")} subtitle={t("app_store_description")}>
|
||||
<AppStoreCategories categories={categories} />
|
||||
<TrendingAppsSlider items={appStore} />
|
||||
<AllApps apps={appStore} />
|
||||
<AppsLayout
|
||||
isPublic
|
||||
heading={t("app_store")}
|
||||
subtitle={t("app_store_description")}
|
||||
actions={(className) => (
|
||||
<AppsSearch className={className} onChange={(e) => setSearchText(e.target.value)} />
|
||||
)}
|
||||
emptyStore={!appStore.length}>
|
||||
{!searchText && (
|
||||
<>
|
||||
<AppStoreCategories categories={categories} />
|
||||
<TrendingAppsSlider items={appStore} />
|
||||
</>
|
||||
)}
|
||||
<AllApps
|
||||
apps={appStore}
|
||||
searchText={searchText}
|
||||
categories={categories.map((category) => category.name)}
|
||||
/>
|
||||
</AppsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: NextPageContext) => {
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssg = await ssgInit(context);
|
||||
|
||||
const session = await getSession(context);
|
||||
@@ -43,7 +84,6 @@ export const getServerSideProps = async (context: NextPageContext) => {
|
||||
}, {} as Record<string, number>);
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssg.dehydrate(),
|
||||
categories: Object.entries(categories)
|
||||
.map(([name, count]): { name: AppCategories; count: number } => ({
|
||||
name: name as AppCategories,
|
||||
@@ -53,6 +93,7 @@ export const getServerSideProps = async (context: NextPageContext) => {
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
trpcState: ssg.dehydrate(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import z from "zod";
|
||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { App } from "@calcom/types/App";
|
||||
@@ -11,19 +12,18 @@ import { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProp
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
DisconnectIntegration,
|
||||
EmptyScreen,
|
||||
Icon,
|
||||
InstalledAppsLayout,
|
||||
List,
|
||||
ShellSubHeading,
|
||||
SkeletonLoader,
|
||||
AppSkeletonLoader as SkeletonLoader,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||
import IntegrationListItem from "@components/apps/IntegrationListItem";
|
||||
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
||||
|
||||
function ConnectOrDisconnectIntegrationButton(props: {
|
||||
credentialIds: number[];
|
||||
@@ -32,7 +32,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
||||
installed?: boolean;
|
||||
invalidCredentialIds?: number[];
|
||||
}) {
|
||||
const { type, credentialIds, isGlobal, installed, invalidCredentialIds } = props;
|
||||
const { type, credentialIds, isGlobal, installed } = props;
|
||||
const { t } = useLocale();
|
||||
const [credentialId] = credentialIds;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user