Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
43fb410a28 | ||
|
|
b8bbfe8a3f | ||
|
|
1f74fc394a | ||
|
|
9a12b5a0ac | ||
|
|
2ce2c1074b | ||
|
|
0ef80da260 | ||
|
|
1e50488cfe | ||
|
|
ae9f66c9ef | ||
|
|
798a4b0090 | ||
|
|
dcd9ff72b7 | ||
|
|
0b5a0186bc | ||
|
|
d9846dca4c | ||
|
|
2fd606c38e | ||
|
|
c066a0cba9 | ||
|
|
568cc22c60 | ||
|
|
b3243c53dc | ||
|
|
80ffcf8b58 | ||
|
+1 |
77bf14a797 | ||
|
|
b188368e8f | ||
|
|
e35631aee3 | ||
|
|
733a06a672 | ||
|
|
2de3e7fc59 | ||
|
|
3137a188a0 | ||
|
|
a2a4ff934f | ||
|
|
eda1d5f393 | ||
|
|
93a14b190e | ||
|
|
2a6dc340df |
No files matched your search
+10
-3
@@ -7,14 +7,13 @@
|
|||||||
# - E-MAIL SETTINGS
|
# - E-MAIL SETTINGS
|
||||||
|
|
||||||
# - LICENSE *************************************************************************************************
|
# - LICENSE *************************************************************************************************
|
||||||
# Set this value to 'agree' to accept our license:
|
# https://github.com/calendso/calendso/blob/main/LICENSE
|
||||||
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
|
|
||||||
#
|
#
|
||||||
# Summary of terms:
|
# Summary of terms:
|
||||||
# - The codebase has to stay open source, whether it was modified or not
|
# - The codebase has to stay open source, whether it was modified or not
|
||||||
# - You can not repackage or sell the codebase
|
# - You can not repackage or sell the codebase
|
||||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
# - 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.
|
# To enable enterprise-only features, fill your license key in here.
|
||||||
# @see https://console.cal.com
|
# @see https://console.cal.com
|
||||||
CALCOM_LICENSE_KEY=
|
CALCOM_LICENSE_KEY=
|
||||||
@@ -86,6 +85,9 @@ SENDGRID_EMAIL=
|
|||||||
TWILIO_SID=
|
TWILIO_SID=
|
||||||
TWILIO_TOKEN=
|
TWILIO_TOKEN=
|
||||||
TWILIO_MESSAGING_SID=
|
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
|
# 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
|
# Set it to "1" if you need to run E2E tests locally
|
||||||
@@ -140,3 +142,8 @@ SENDGRID_SYNC_API_KEY=
|
|||||||
# Sentry
|
# Sentry
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
SENTRY_IGNORE_API_RESOLUTION_ERROR=
|
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:
|
automerge:
|
||||||
- apps/web/public/static/locales/**/*.json
|
- 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v4
|
- 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:
|
with:
|
||||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
@@ -11,6 +11,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
|
ref: main # Always checkout main even for tagged releases
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
- run: git push origin +main:production
|
- run: git push origin +main:production
|
||||||
@@ -292,7 +292,7 @@ You can deploy Cal on [Railway](https://railway.app/) using the button above. Th
|
|||||||
|
|
||||||
Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan.
|
Currently Vercel Pro Plan is required to be able to Deploy this application with Vercel, due to limitations on the number of serverless functions on the free plan.
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY,NEXT_PUBLIC_LICENSE_CONSENT&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F)
|
||||||
|
|
||||||
<!-- ROADMAP -->
|
<!-- ROADMAP -->
|
||||||
|
|
||||||
@@ -419,14 +419,18 @@ following
|
|||||||
2. Click ‘Get a Twilio phone number’
|
2. Click ‘Get a Twilio phone number’
|
||||||
3. Copy Account SID to your .env file into the TWILIO_SID field
|
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
|
4. Copy Auth Token to your .env file into the TWILIO_TOKEN field
|
||||||
5. Create a messaging service (Develop -> Messaging -> Services)
|
5. Copy your Twilio phone number to your .env file into the TWILIO_PHONE_NUMBER field
|
||||||
6. Choose any name for the messaging service
|
6. Add your own sender id to the .env file into the NEXT_PUBLIC_SENDER_ID field (fallback is Cal)
|
||||||
7. Click 'Add Senders'
|
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||||
8. Choose phone number as sender type
|
8. Choose any name for the messaging service
|
||||||
9. Add the listed phone number
|
9. Click 'Add Senders'
|
||||||
10. Leave all other fields as they are
|
10. Choose phone number as sender type
|
||||||
11. Complete setup and click ‘View my new Messaging Service’
|
11. Add the listed phone number
|
||||||
12. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
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 -->
|
<!-- 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",
|
"generator": "secret",
|
||||||
"required": "true"
|
"required": "true"
|
||||||
},
|
},
|
||||||
"NEXT_PUBLIC_LICENSE_CONSENT": {
|
|
||||||
"description": "License consent",
|
|
||||||
"value": "true",
|
|
||||||
"required": "true"
|
|
||||||
},
|
|
||||||
"CRON_API_KEY": {
|
"CRON_API_KEY": {
|
||||||
"description": "ApiKey for cronjobs",
|
"description": "ApiKey for cronjobs",
|
||||||
"value": ""
|
"value": ""
|
||||||
|
|||||||
+1
-1
Submodule apps/api updated: 3d84ce68c9...604d937661.
+1
-1
Submodule apps/console updated: 9174a0c261...2d577dd9ed.
@@ -4,6 +4,7 @@ module.exports = {
|
|||||||
stories: [
|
stories: [
|
||||||
"../intro.stories.mdx",
|
"../intro.stories.mdx",
|
||||||
"../../../packages/ui/components/**/*.stories.mdx",
|
"../../../packages/ui/components/**/*.stories.mdx",
|
||||||
|
"../../../packages/features/**/*.stories.mdx",
|
||||||
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
|
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
|
||||||
],
|
],
|
||||||
addons: [
|
addons: [
|
||||||
@@ -50,6 +51,21 @@ module.exports = {
|
|||||||
vm: false,
|
vm: false,
|
||||||
zlib: 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;
|
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 = () => {
|
window.getEmbedNamespace = () => {
|
||||||
const url = new URL(document.URL);
|
const url = new URL(document.URL);
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 {
|
.sbdocs {
|
||||||
font-family: 'Inter var' !important;
|
font-family: 'Inter var' !important;
|
||||||
padding: 0!important;
|
padding: 0!important;
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
|
|
||||||
/** Docs table **/
|
/** Docs table **/
|
||||||
.custom-args-wrapper{
|
.custom-args-wrapper{
|
||||||
max-height: 600px;
|
max-height: 400px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -187,3 +189,7 @@
|
|||||||
padding: 24px !important;
|
padding: 24px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-story {
|
||||||
|
padding: 24px !important;
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
declare module "@wojtekmaj/react-daterange-picker/dist/entry.nostyle" {
|
|
||||||
import { CalendarProps } from "react-calendar";
|
|
||||||
export type DateRangePickerCalendarProps = Omit<
|
|
||||||
CalendarProps,
|
|
||||||
"calendarClassName" | "onChange" | "value"
|
|
||||||
> & {
|
|
||||||
calendarClassName?: string;
|
|
||||||
onChange: (value: [Date, Date]) => void;
|
|
||||||
value: [Date, Date];
|
|
||||||
clearIcon: JSX.Element | null;
|
|
||||||
calendarIcon: JSX.Element | null;
|
|
||||||
rangeDivider: JSX.Element | null;
|
|
||||||
};
|
|
||||||
export default function DateRangePicker(props: DateRangePickerCalendarProps): JSX.Element;
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import { NextRouter, useRouter } from "next/router";
|
|||||||
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
||||||
import { components, ControlProps } from "react-select";
|
import { components, ControlProps } from "react-select";
|
||||||
|
|
||||||
|
import { APP_NAME, EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -20,8 +21,6 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@lib/config/constants";
|
|
||||||
|
|
||||||
import ColorPicker from "@components/ui/colorpicker";
|
import ColorPicker from "@components/ui/colorpicker";
|
||||||
import Select from "@components/ui/form/Select";
|
import Select from "@components/ui/form/Select";
|
||||||
|
|
||||||
@@ -45,6 +44,7 @@ type PreviewState = {
|
|||||||
palette: {
|
palette: {
|
||||||
brandColor: string;
|
brandColor: string;
|
||||||
};
|
};
|
||||||
|
hideEventTypeDetails: boolean;
|
||||||
};
|
};
|
||||||
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
const queryParamsForDialog = ["embedType", "embedTabName", "embedUrl"];
|
||||||
|
|
||||||
@@ -126,10 +126,12 @@ const getEmbedUIInstructionString = ({
|
|||||||
apiName,
|
apiName,
|
||||||
theme,
|
theme,
|
||||||
brandColor,
|
brandColor,
|
||||||
|
hideEventTypeDetails,
|
||||||
}: {
|
}: {
|
||||||
apiName: string;
|
apiName: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
brandColor: string;
|
brandColor: string;
|
||||||
|
hideEventTypeDetails: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
theme = theme !== "auto" ? theme : undefined;
|
theme = theme !== "auto" ? theme : undefined;
|
||||||
return getInstructionString({
|
return getInstructionString({
|
||||||
@@ -142,6 +144,7 @@ const getEmbedUIInstructionString = ({
|
|||||||
brandColor,
|
brandColor,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hideEventTypeDetails: hideEventTypeDetails,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -254,18 +257,21 @@ const getEmbedTypeSpecificString = ({
|
|||||||
apiName: string;
|
apiName: string;
|
||||||
theme: PreviewState["theme"];
|
theme: PreviewState["theme"];
|
||||||
brandColor: string;
|
brandColor: string;
|
||||||
|
hideEventTypeDetails: boolean;
|
||||||
};
|
};
|
||||||
if (embedFramework === "react") {
|
if (embedFramework === "react") {
|
||||||
uiInstructionStringArg = {
|
uiInstructionStringArg = {
|
||||||
apiName: "cal",
|
apiName: "cal",
|
||||||
theme: previewState.theme,
|
theme: previewState.theme,
|
||||||
brandColor: previewState.palette.brandColor,
|
brandColor: previewState.palette.brandColor,
|
||||||
|
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
uiInstructionStringArg = {
|
uiInstructionStringArg = {
|
||||||
apiName: "Cal",
|
apiName: "Cal",
|
||||||
theme: previewState.theme,
|
theme: previewState.theme,
|
||||||
brandColor: previewState.palette.brandColor,
|
brandColor: previewState.palette.brandColor,
|
||||||
|
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!frameworkCodes[embedType]) {
|
if (!frameworkCodes[embedType]) {
|
||||||
@@ -498,7 +504,9 @@ const tabs = [
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<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>
|
</div>
|
||||||
<TextArea
|
<TextArea
|
||||||
data-testid="embed-code"
|
data-testid="embed-code"
|
||||||
@@ -619,13 +627,13 @@ const ChooseEmbedTypesDialogContent = () => {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<DialogContent type="creation" useOwnActionButtons size="lg">
|
<DialogContent type="creation" size="lg">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||||
{t("how_you_want_add_cal_site")}
|
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<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>
|
</div>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
@@ -688,6 +696,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
theme: Theme.auto,
|
theme: Theme.auto,
|
||||||
floatingPopup: {},
|
floatingPopup: {},
|
||||||
elementClick: {},
|
elementClick: {},
|
||||||
|
hideEventTypeDetails: false,
|
||||||
palette: {
|
palette: {
|
||||||
brandColor: "#000000",
|
brandColor: "#000000",
|
||||||
},
|
},
|
||||||
@@ -752,6 +761,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
name: "ui",
|
name: "ui",
|
||||||
arg: {
|
arg: {
|
||||||
theme: previewState.theme,
|
theme: previewState.theme,
|
||||||
|
hideEventTypeDetails: previewState.hideEventTypeDetails,
|
||||||
styles: {
|
styles: {
|
||||||
branding: {
|
branding: {
|
||||||
...previewState.palette,
|
...previewState.palette,
|
||||||
@@ -797,7 +807,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<div className="flex w-1/3 flex-col bg-gray-50 p-8">
|
<div className="flex w-1/3 flex-col bg-gray-50 p-8">
|
||||||
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||||
@@ -810,7 +820,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
{embed.title}
|
{embed.title}
|
||||||
</h3>
|
</h3>
|
||||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
|
<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" : "")}>
|
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={isEmbedCustomizationOpen}
|
open={isEmbedCustomizationOpen}
|
||||||
@@ -837,7 +847,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
<div className="text-sm">Embed Window Sizing</div>
|
<div className="text-sm">Embed Window Sizing</div>
|
||||||
<div className="justify-left flex items-center">
|
<div className="justify-left flex items-center">
|
||||||
<TextField
|
<TextField
|
||||||
name="width"
|
|
||||||
labelProps={{ className: "hidden" }}
|
labelProps={{ className: "hidden" }}
|
||||||
required
|
required
|
||||||
value={previewState.inline.width}
|
value={previewState.inline.width}
|
||||||
@@ -859,7 +868,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
<span className="p-2">×</span>
|
<span className="p-2">×</span>
|
||||||
<TextField
|
<TextField
|
||||||
labelProps={{ className: "hidden" }}
|
labelProps={{ className: "hidden" }}
|
||||||
name="height"
|
|
||||||
value={previewState.inline.height}
|
value={previewState.inline.height}
|
||||||
required
|
required
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -887,7 +895,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
<div className="mb-2 text-sm">Button Text</div>
|
<div className="mb-2 text-sm">Button Text</div>
|
||||||
{/* Default Values should come from preview iframe */}
|
{/* Default Values should come from preview iframe */}
|
||||||
<TextField
|
<TextField
|
||||||
name="buttonText"
|
|
||||||
labelProps={{ className: "hidden" }}
|
labelProps={{ className: "hidden" }}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPreviewState((previewState) => {
|
setPreviewState((previewState) => {
|
||||||
@@ -1003,6 +1010,20 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="mt-6 text-sm">
|
<div className="mt-6 text-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-start space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={previewState.hideEventTypeDetails}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setPreviewState((previewState) => {
|
||||||
|
return {
|
||||||
|
...previewState,
|
||||||
|
hideEventTypeDetails: checked,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="text-sm">{t("hide_eventtype_details")}</div>
|
||||||
|
</div>
|
||||||
<Label className="">
|
<Label className="">
|
||||||
<div className="mb-2">Theme</div>
|
<div className="mb-2">Theme</div>
|
||||||
<Select
|
<Select
|
||||||
@@ -1095,9 +1116,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
{t("copy_code")}
|
{t("copy_code")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<DialogClose asChild>
|
<DialogClose />
|
||||||
<Button color="secondary">{t("Close")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -156,12 +156,8 @@ export default function ImageUploader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<DialogClose asChild>
|
<DialogClose onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</DialogClose>
|
||||||
<Button onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</Button>
|
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||||
</DialogClose>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -61,12 +61,10 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
|||||||
<Select
|
<Select
|
||||||
name="additionalCalendar"
|
name="additionalCalendar"
|
||||||
placeholder={
|
placeholder={
|
||||||
<div className="flex justify-start text-black">
|
<Button StartIcon={Icon.FiPlus} color="secondary">
|
||||||
<Icon.FiPlus className="-ml-1 h-4 w-4 ltr:mr-2 rtl:ml-2 rtl:-mr-1" />
|
{t("add")}
|
||||||
<p>{t("add")}</p>
|
</Button>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
className="min-w-44"
|
|
||||||
options={options}
|
options={options}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -80,11 +78,16 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
|||||||
width: "max-content",
|
width: "max-content",
|
||||||
right: "0",
|
right: "0",
|
||||||
}),
|
}),
|
||||||
|
indicatorSeparator: (defaultStyles) => ({
|
||||||
|
...defaultStyles,
|
||||||
|
display: "none",
|
||||||
|
}),
|
||||||
control: (defaultStyles) => ({
|
control: (defaultStyles) => ({
|
||||||
...defaultStyles,
|
...defaultStyles,
|
||||||
padding: "0",
|
padding: "0",
|
||||||
border: "0",
|
border: "0",
|
||||||
borderRadius: "inherit",
|
borderRadius: "6px",
|
||||||
|
minHeight: "auto",
|
||||||
}),
|
}),
|
||||||
dropdownIndicator: (defaultStyles) => ({
|
dropdownIndicator: (defaultStyles) => ({
|
||||||
...defaultStyles,
|
...defaultStyles,
|
||||||
|
|||||||
@@ -4,20 +4,14 @@ import React, { useState } from "react";
|
|||||||
|
|
||||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
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 LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||||
import classNames from "@calcom/lib/classNames";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { App as AppType } from "@calcom/types/App";
|
import { App as AppType } from "@calcom/types/App";
|
||||||
import {
|
import { Button, Icon, Shell, showToast, SkeletonButton, SkeletonText } from "@calcom/ui";
|
||||||
Button,
|
|
||||||
DisconnectIntegration,
|
|
||||||
Icon,
|
|
||||||
Shell,
|
|
||||||
showToast,
|
|
||||||
SkeletonButton,
|
|
||||||
SkeletonText,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
|
|
||||||
import HeadSeo from "@components/seo/head-seo";
|
import HeadSeo from "@components/seo/head-seo";
|
||||||
|
|
||||||
@@ -48,7 +42,7 @@ const Component = ({
|
|||||||
|
|
||||||
const mutation = useAddAppMutation(null, {
|
const mutation = useAddAppMutation(null, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.setupPending) return;
|
if (data?.setupPending) return;
|
||||||
showToast(t("app_successfully_installed"), "success");
|
showToast(t("app_successfully_installed"), "success");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -72,7 +66,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 (
|
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">
|
<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 +197,7 @@ const Component = ({
|
|||||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||||
<span>
|
<span>
|
||||||
{price === 0 ? (
|
{price === 0 ? (
|
||||||
"Free"
|
t("free_to_use_apps")
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{Intl.NumberFormat("en-US", {
|
{Intl.NumberFormat("en-US", {
|
||||||
@@ -279,8 +275,10 @@ const Component = ({
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<hr className="my-8" />
|
<hr className="my-8" />
|
||||||
<span className="leading-1 block text-xs text-gray-500">{t("every_app_published")}</span>
|
<span className="leading-1 block text-xs text-gray-500">
|
||||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
{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")}
|
<Icon.FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import Link from "next/link";
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
DisconnectIntegration,
|
|
||||||
EmptyScreen,
|
EmptyScreen,
|
||||||
Icon,
|
Icon,
|
||||||
List,
|
List,
|
||||||
@@ -159,6 +159,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
if (!data.connectedCalendars.length) {
|
if (!data.connectedCalendars.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
<List className="flex flex-col gap-6" noBorderTreatment>
|
||||||
{data.connectedCalendars.map((item) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
@@ -168,7 +169,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
slug={item.integration.slug}
|
slug={item.integration.slug}
|
||||||
title={item.integration.title}
|
title={item.integration.title}
|
||||||
logo={item.integration.logo}
|
logo={item.integration.logo}
|
||||||
description={item.primary?.externalId || "No external Id"}
|
description={item.primary?.email ?? item.integration.description}
|
||||||
separate={true}
|
separate={true}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex w-32 justify-end">
|
<div className="flex w-32 justify-end">
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function IntegrationListItem(props: {
|
|||||||
expanded={!!props.children}
|
expanded={!!props.children}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
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" : ""
|
highlight ? "bg-yellow-100" : ""
|
||||||
)}>
|
)}>
|
||||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-bookinglight font-medium">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
@@ -101,7 +101,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Current there is no way to disable Next.js Links */}
|
||||||
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { SchedulingType } from "@prisma/client";
|
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 { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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 { UserAvatars } from "@components/booking/UserAvatars";
|
||||||
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
import EventTypeDescriptionSafeHTML from "@components/eventtype/EventTypeDescriptionSafeHTML";
|
||||||
@@ -39,7 +42,34 @@ interface Props {
|
|||||||
|
|
||||||
const BookingDescription: FC<Props> = (props) => {
|
const BookingDescription: FC<Props> = (props) => {
|
||||||
const { profile, eventType, isBookingPage = false, children } = props;
|
const { profile, eventType, isBookingPage = false, children } = props;
|
||||||
|
const { date: bookingDate } = useRouterQuery("date");
|
||||||
const { t } = useLocale();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<UserAvatars
|
<UserAvatars
|
||||||
@@ -75,29 +105,61 @@ const BookingDescription: FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{eventType?.requiresConfirmation && (
|
{requiresConfirmation && (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex items-center",
|
"items-top flex",
|
||||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] inline-block h-4 w-4 " />
|
||||||
</div>
|
</div>
|
||||||
{t("requires_confirmation")}
|
{requiresConfirmationText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AvailableEventLocations
|
<AvailableEventLocations
|
||||||
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
locations={eventType.locations as AvailabilityPageProps["eventType"]["locations"]}
|
||||||
/>
|
/>
|
||||||
<p
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"text-sm font-medium",
|
"flex flex-nowrap text-sm font-medium",
|
||||||
isBookingPage && "dark:text-darkgray-600 text-gray-600"
|
isBookingPage && "dark:text-darkgray-600 text-gray-600"
|
||||||
)}>
|
)}>
|
||||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
<Icon.FiClock
|
||||||
{eventType.length} {t("minutes")}
|
className={classNames(
|
||||||
</p>
|
"min-h-4 min-w-4 mr-[10px] ml-[2px] inline-block",
|
||||||
|
isBookingPage && "mt-[2px]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{eventType.metadata?.multipleDuration !== undefined ? (
|
||||||
|
!isBookingPage ? (
|
||||||
|
<ul className="-mt-1 flex flex-wrap gap-1">
|
||||||
|
{eventType.metadata.multipleDuration.map((dur, idx) => (
|
||||||
|
<li key={idx}>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
size="lg"
|
||||||
|
className={classNames(
|
||||||
|
duration === dur.toString()
|
||||||
|
? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast"
|
||||||
|
: "hover:bg-darkgray-200 dark:hover:bg-darkmodebrand hover:text-darkgray-900 dark:hover:text-darkmodebrandcontrast dark:bg-darkgray-200 bg-gray-200 text-gray-900 dark:text-white",
|
||||||
|
"cursor-pointer"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setDuration(dur);
|
||||||
|
}}>
|
||||||
|
{dur} {t("minute_timeUnit")}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
`${duration} ${t("minutes")}`
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
`${eventType.length} ${t("minutes")}`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
import { ActionType, TableActions } from "@calcom/ui";
|
||||||
|
|
||||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
|
|
||||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
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];
|
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("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
|
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||||
cancel all remaining bookings or just that booking instance. */
|
cancel all remaining bookings or just that booking instance. */
|
||||||
href: `/success?uid=${booking.uid}&cancel=true${
|
href: `/booking/${booking.uid}?cancel=true${
|
||||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||||
}`,
|
}`,
|
||||||
icon: Icon.FiX,
|
icon: Icon.FiX,
|
||||||
@@ -158,6 +158,10 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPast && isPending && !isConfirmed) {
|
||||||
|
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
|
||||||
|
}
|
||||||
|
|
||||||
const RequestSentMessage = () => {
|
const RequestSentMessage = () => {
|
||||||
return (
|
return (
|
||||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||||
@@ -195,11 +199,9 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
|
|
||||||
const onClickTableData = () => {
|
const onClickTableData = () => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${booking.uid}`,
|
||||||
query: {
|
query: {
|
||||||
uid: booking.uid,
|
|
||||||
allRemainingBookings: isTabRecurring,
|
allRemainingBookings: isTabRecurring,
|
||||||
listingStatus: booking.listingStatus,
|
|
||||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -237,9 +239,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose>
|
<DialogClose />
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={mutation.isLoading}
|
disabled={mutation.isLoading}
|
||||||
@@ -252,7 +252,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
<td
|
||||||
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
|
||||||
onClick={onClickTableData}>
|
onClick={onClickTableData}>
|
||||||
@@ -279,9 +279,9 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
{booking.eventType.team.name}
|
{booking.eventType.team.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
{booking.paid && (
|
||||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="green">
|
||||||
{t("pending_payment")}
|
{t("paid")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{recurringDates !== undefined && (
|
{recurringDates !== undefined && (
|
||||||
@@ -342,12 +342,14 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
<span> </span>
|
<span> </span>
|
||||||
|
|
||||||
{!!booking?.eventType?.price && !booking.paid && (
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
<Badge className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex" variant="orange">
|
||||||
|
{t("pending_payment")}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{booking.description && (
|
{booking.description && (
|
||||||
<div
|
<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}>
|
title={booking.description}>
|
||||||
"{booking.description}"
|
"{booking.description}"
|
||||||
</div>
|
</div>
|
||||||
@@ -530,13 +532,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;
|
export default BookingListItem;
|
||||||
@@ -37,7 +37,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
<div>
|
<div className="mt-8">
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
<Icon.FiX className="h-6 w-6 text-red-600" />
|
<Icon.FiX className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
@@ -89,11 +89,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.status >= 200 && res.status < 300) {
|
if (res.status >= 200 && res.status < 300) {
|
||||||
await router.push(
|
await router.replace(router.asPath);
|
||||||
`/cancel/success?name=${props.profile.name}&title=${booking?.title}&eventPage=${
|
|
||||||
profile.slug
|
|
||||||
}&team=${team ? 1 : 0}&allRemainingBookings=${allRemainingBookings}`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const UserAvatars = ({
|
|||||||
users,
|
users,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
profile: { image: string | null; name?: string | null };
|
profile: { image: string | null; name?: string | null; username?: string | null };
|
||||||
showMembers: boolean;
|
showMembers: boolean;
|
||||||
users: { username: string | null; name?: string | null }[];
|
users: { username: string | null; name?: string | null }[];
|
||||||
} & Pick<AvatarGroupProps, "size" | "truncateAfter">) => {
|
} & Pick<AvatarGroupProps, "size" | "truncateAfter">) => {
|
||||||
@@ -17,15 +17,26 @@ export const UserAvatars = ({
|
|||||||
border="border-2 dark:border-darkgray-100 border-white"
|
border="border-2 dark:border-darkgray-100 border-white"
|
||||||
items={
|
items={
|
||||||
[
|
[
|
||||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
{
|
||||||
|
image: profile.image,
|
||||||
|
alt: profile.name,
|
||||||
|
title: profile.name,
|
||||||
|
href: profile.username ? `${CAL_URL}/${profile.username}` : undefined,
|
||||||
|
},
|
||||||
...(showMembers
|
...(showMembers
|
||||||
? users.map((user) => ({
|
? users.map((user) => ({
|
||||||
title: user.name,
|
title: user.name,
|
||||||
image: `${CAL_URL}/${user.username}/avatar.png`,
|
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||||
alt: user.name || undefined,
|
alt: user.name || undefined,
|
||||||
|
href: user.username ? `${CAL_URL}/${user.username}` : undefined,
|
||||||
}))
|
}))
|
||||||
: []),
|
: []),
|
||||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
].filter((item) => !!item.image) as {
|
||||||
|
image: string;
|
||||||
|
alt?: string;
|
||||||
|
title?: string;
|
||||||
|
href?: string;
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
size={props.size}
|
size={props.size}
|
||||||
truncateAfter={props.truncateAfter}
|
truncateAfter={props.truncateAfter}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { EventType } from "@prisma/client";
|
import { EventType } from "@prisma/client";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { TFunction } from "next-i18next";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
import { useReducer, useEffect, useMemo, useState } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
@@ -14,12 +13,13 @@ import dayjs, { Dayjs } from "@calcom/dayjs";
|
|||||||
import {
|
import {
|
||||||
useEmbedNonStylesConfig,
|
useEmbedNonStylesConfig,
|
||||||
useEmbedStyles,
|
useEmbedStyles,
|
||||||
|
useEmbedUiConfig,
|
||||||
useIsBackgroundTransparent,
|
useIsBackgroundTransparent,
|
||||||
useIsEmbed,
|
useIsEmbed,
|
||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
|
import DatePicker from "@calcom/features/calendars/DatePicker";
|
||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
@@ -28,12 +28,10 @@ import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
|||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Icon, DatePicker } from "@calcom/ui";
|
import { Icon } from "@calcom/ui";
|
||||||
|
|
||||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||||
// import { timeZone } from "@lib/clock";
|
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
|
||||||
|
|
||||||
import Gates, { Gate, GateState } from "@components/Gates";
|
import Gates, { Gate, GateState } from "@components/Gates";
|
||||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||||
@@ -46,23 +44,6 @@ import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
|||||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||||
|
|
||||||
// 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 = ({
|
const useSlots = ({
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
eventTypeSlug,
|
eventTypeSlug,
|
||||||
@@ -70,6 +51,7 @@ const useSlots = ({
|
|||||||
endTime,
|
endTime,
|
||||||
usernameList,
|
usernameList,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
}: {
|
}: {
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
eventTypeSlug: string;
|
eventTypeSlug: string;
|
||||||
@@ -77,6 +59,7 @@ const useSlots = ({
|
|||||||
endTime?: Dayjs;
|
endTime?: Dayjs;
|
||||||
usernameList: string[];
|
usernameList: string[];
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
|
duration?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
||||||
{
|
{
|
||||||
@@ -86,6 +69,7 @@ const useSlots = ({
|
|||||||
startTime: startTime?.toISOString() || "",
|
startTime: startTime?.toISOString() || "",
|
||||||
endTime: endTime?.toISOString() || "",
|
endTime: endTime?.toISOString() || "",
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!startTime && !!endTime,
|
enabled: !!startTime && !!endTime,
|
||||||
@@ -126,6 +110,7 @@ const SlotPicker = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||||
|
const { duration } = useRouterQuery("duration");
|
||||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -151,7 +136,7 @@ const SlotPicker = ({
|
|||||||
setSelectedDate(dayjs.tz(date, timeZone));
|
setSelectedDate(dayjs.tz(date, timeZone));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [router.isReady, month, date, timeZone]);
|
}, [router.isReady, month, date, duration, timeZone]);
|
||||||
|
|
||||||
const { i18n, isLocaleReady } = useLocale();
|
const { i18n, isLocaleReady } = useLocale();
|
||||||
const { slots: _1 } = useSlots({
|
const { slots: _1 } = useSlots({
|
||||||
@@ -161,14 +146,19 @@ const SlotPicker = ({
|
|||||||
startTime: selectedDate?.startOf("day"),
|
startTime: selectedDate?.startOf("day"),
|
||||||
endTime: selectedDate?.endOf("day"),
|
endTime: selectedDate?.endOf("day"),
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
});
|
});
|
||||||
const { slots: _2, isLoading } = useSlots({
|
const { slots: _2, isLoading } = useSlots({
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
usernameList: users,
|
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"),
|
endTime: browsingDate?.endOf("month"),
|
||||||
timeZone,
|
timeZone,
|
||||||
|
duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
||||||
@@ -229,14 +219,14 @@ function TimezoneDropdown({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
<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">
|
<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="text-sm font-medium">
|
<p className="flex items-center text-sm font-medium">
|
||||||
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
|
<Icon.FiGlobe className="min-h-4 min-w-4 mr-[10px] ml-[2px] -mt-[2px] inline-block" />
|
||||||
{timeZone}
|
{timeZone}
|
||||||
{isTimeOptionsOpen ? (
|
{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>
|
</p>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
@@ -258,23 +248,6 @@ const dateQuerySchema = z.object({
|
|||||||
timeZone: z.string().optional().default(""),
|
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;
|
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||||
|
|
||||||
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||||
@@ -285,6 +258,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
useTheme(profile.theme);
|
useTheme(profile.theme);
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||||
|
//TODO: Plan to remove shouldAlignCentrallyInEmbed config
|
||||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||||
@@ -309,9 +283,6 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
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 [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
@@ -324,7 +295,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [telemetry]);
|
}, [telemetry]);
|
||||||
|
const embedUiConfig = useEmbedUiConfig();
|
||||||
// get dynamic user list here
|
// get dynamic user list here
|
||||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||||
|
|
||||||
@@ -337,6 +308,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||||
|
|
||||||
|
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||||
|
|
||||||
// Define conditional gates here
|
// Define conditional gates here
|
||||||
const gates = [
|
const gates = [
|
||||||
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
// Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress`
|
||||||
@@ -370,7 +343,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
<div>
|
<div>
|
||||||
<main
|
<main
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex-col md:mx-4 lg:flex",
|
"flex flex-col md:mx-4",
|
||||||
shouldAlignCentrally ? "items-center" : "items-start",
|
shouldAlignCentrally ? "items-center" : "items-start",
|
||||||
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
||||||
)}>
|
)}>
|
||||||
@@ -385,60 +358,55 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
isEmbed && "mx-auto"
|
isEmbed && "mx-auto"
|
||||||
)}>
|
)}>
|
||||||
<div className="overflow-hidden md:flex">
|
<div className="overflow-hidden md:flex">
|
||||||
<div
|
{showEventTypeDetails && (
|
||||||
className={classNames(
|
<div
|
||||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
className={classNames(
|
||||||
"min-w-full md:w-[230px] md:min-w-[230px]",
|
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
"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 && (
|
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||||
<div className="flex items-start text-sm font-medium">
|
{!rescheduleUid && eventType.recurringEvent && (
|
||||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
<div className="flex items-start text-sm font-medium">
|
||||||
<div>
|
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
<div>
|
||||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||||
</p>
|
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||||
<input
|
</p>
|
||||||
type="number"
|
<input
|
||||||
min="1"
|
type="number"
|
||||||
max={eventType.recurringEvent.count}
|
min="1"
|
||||||
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"
|
max={eventType.recurringEvent.count}
|
||||||
defaultValue={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"
|
||||||
onChange={(event) => {
|
defaultValue={eventType.recurringEvent.count}
|
||||||
setRecurringEventCount(parseInt(event?.target.value));
|
onChange={(event) => {
|
||||||
}}
|
setRecurringEventCount(parseInt(event?.target.value));
|
||||||
/>
|
}}
|
||||||
<p className="inline">
|
/>
|
||||||
{t("occurrence", {
|
<p className="inline">
|
||||||
count: recurringEventCount,
|
{t("occurrence", {
|
||||||
})}
|
count: recurringEventCount,
|
||||||
</p>
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{stripeAppData.price > 0 && (
|
||||||
{stripeAppData.price > 0 && (
|
<p className="-ml-2 px-2 text-sm font-medium">
|
||||||
<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" />
|
||||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
<IntlProvider locale="en">
|
||||||
<IntlProvider locale="en">
|
<FormattedNumber
|
||||||
<FormattedNumber
|
value={stripeAppData.price / 100.0}
|
||||||
value={stripeAppData.price / 100.0}
|
style="currency"
|
||||||
style="currency"
|
currency={stripeAppData.currency.toUpperCase()}
|
||||||
currency={stripeAppData.currency.toUpperCase()}
|
/>
|
||||||
/>
|
</IntlProvider>
|
||||||
</IntlProvider>
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
{timezoneDropdown}
|
||||||
{timezoneDropdown}
|
</BookingDescription>
|
||||||
</BookingDescription>
|
|
||||||
|
|
||||||
{!isEmbed && (
|
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||||
<div className="mt-auto hidden md:block">
|
|
||||||
<GoBackToPreviousPage t={t} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
||||||
@@ -451,7 +419,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)*/}
|
)*/}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<SlotPicker
|
<SlotPicker
|
||||||
weekStart={
|
weekStart={
|
||||||
typeof profile.weekStart === "string"
|
typeof profile.weekStart === "string"
|
||||||
@@ -477,7 +446,8 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ import { LocationObject, LocationType } from "@calcom/core/location";
|
|||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import {
|
import {
|
||||||
useEmbedNonStylesConfig,
|
useEmbedNonStylesConfig,
|
||||||
|
useEmbedUiConfig,
|
||||||
useIsBackgroundTransparent,
|
useIsBackgroundTransparent,
|
||||||
useIsEmbed,
|
useIsEmbed,
|
||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
@@ -38,11 +40,12 @@ import { HttpError } from "@calcom/lib/http-error";
|
|||||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { AddressInput, Button, EmailInput, Form, Icon, PhoneInput, Tooltip } from "@calcom/ui";
|
import { AddressInput, Button, EmailInput, Form, Icon, PhoneInput, Tooltip } from "@calcom/ui";
|
||||||
|
import { Group, RadioField } from "@calcom/ui";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { ensureArray } from "@lib/ensureArray";
|
import { ensureArray } from "@lib/ensureArray";
|
||||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||||
@@ -85,10 +88,9 @@ const BookingPage = ({
|
|||||||
...restProps
|
...restProps
|
||||||
}: BookingPageProps) => {
|
}: BookingPageProps) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
// Get user so we can determine 12/24 hour format preferences
|
const { duration: queryDuration } = useRouterQuery("duration");
|
||||||
const query = useMeQuery();
|
|
||||||
const user = query.data;
|
|
||||||
const isEmbed = useIsEmbed(restProps.isEmbed);
|
const isEmbed = useIsEmbed(restProps.isEmbed);
|
||||||
|
const embedUiConfig = useEmbedUiConfig();
|
||||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -103,6 +105,11 @@ const BookingPage = ({
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
const stripeAppData = getStripeAppData(eventType);
|
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(() => {
|
useEffect(() => {
|
||||||
if (top !== window) {
|
if (top !== window) {
|
||||||
@@ -131,12 +138,12 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return router.push({
|
return router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${uid}`,
|
||||||
query: {
|
query: {
|
||||||
uid,
|
|
||||||
isSuccessBookingPage: true,
|
isSuccessBookingPage: true,
|
||||||
email: bookingForm.getValues("email"),
|
email: bookingForm.getValues("email"),
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
|
formerTime: booking?.startTime.toString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -147,12 +154,12 @@ const BookingPage = ({
|
|||||||
const { uid } = responseData[0] || {};
|
const { uid } = responseData[0] || {};
|
||||||
|
|
||||||
return router.push({
|
return router.push({
|
||||||
pathname: "/success",
|
pathname: `/booking/${uid}`,
|
||||||
query: {
|
query: {
|
||||||
uid,
|
|
||||||
allRemainingBookings: true,
|
allRemainingBookings: true,
|
||||||
email: bookingForm.getValues("email"),
|
email: bookingForm.getValues("email"),
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
|
formerTime: booking?.startTime.toString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -237,7 +244,7 @@ const BookingPage = ({
|
|||||||
const bookingFormSchema = z
|
const bookingFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
email: z.string().email(),
|
email: z.string().trim().email(),
|
||||||
phone: z
|
phone: z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
@@ -294,6 +301,39 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookEvent = (booking: BookingFormValues) => {
|
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(
|
telemetry.event(
|
||||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||||
{ isTeamBooking: document.URL.includes("team/") }
|
{ 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) {
|
if (recurringDates.length) {
|
||||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||||
const recurringEventId = uuidv4();
|
const recurringEventId = uuidv4();
|
||||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||||
...booking,
|
...booking,
|
||||||
start: dayjs(recurringDate).format(),
|
start: dayjs(recurringDate).format(),
|
||||||
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
end: dayjs(recurringDate).add(duration, "minute").format(),
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
recurringEventId,
|
recurringEventId,
|
||||||
@@ -334,10 +390,7 @@ const BookingPage = ({
|
|||||||
attendeeAddress: booking.attendeeAddress,
|
attendeeAddress: booking.attendeeAddress,
|
||||||
}),
|
}),
|
||||||
metadata,
|
metadata,
|
||||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
customInputs: bookingCustomInputs,
|
||||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
|
||||||
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
|
||||||
})),
|
|
||||||
hasHashedBookingLink,
|
hasHashedBookingLink,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
smsReminderNumber:
|
smsReminderNumber:
|
||||||
@@ -351,7 +404,7 @@ const BookingPage = ({
|
|||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
...booking,
|
...booking,
|
||||||
start: dayjs(date).format(),
|
start: dayjs(date).format(),
|
||||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
end: dayjs(date).add(duration, "minute").format(),
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
timeZone: timeZone(),
|
timeZone: timeZone(),
|
||||||
@@ -365,10 +418,7 @@ const BookingPage = ({
|
|||||||
attendeeAddress: booking.attendeeAddress,
|
attendeeAddress: booking.attendeeAddress,
|
||||||
}),
|
}),
|
||||||
metadata,
|
metadata,
|
||||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
customInputs: bookingCustomInputs,
|
||||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
|
||||||
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
|
||||||
})),
|
|
||||||
hasHashedBookingLink,
|
hasHashedBookingLink,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
smsReminderNumber:
|
smsReminderNumber:
|
||||||
@@ -403,6 +453,7 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const showEventTypeDetails = (isEmbed && !embedUiConfig.hideEventTypeDetails) || !isEmbed;
|
||||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||||
|
|
||||||
// Define conditional gates here
|
// Define conditional gates here
|
||||||
@@ -426,7 +477,7 @@ const BookingPage = ({
|
|||||||
eventTypeTitle: eventType.title,
|
eventTypeTitle: eventType.title,
|
||||||
profileName: profile.name,
|
profileName: profile.name,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
| Cal.com
|
| {APP_NAME}
|
||||||
</title>
|
</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
@@ -436,7 +487,7 @@ const BookingPage = ({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
shouldAlignCentrally ? "mx-auto" : "",
|
shouldAlignCentrally ? "mx-auto" : "",
|
||||||
isEmbed ? "" : "sm:my-24",
|
isEmbed ? "" : "sm:my-24",
|
||||||
"my-0 max-w-3xl "
|
"my-0 max-w-3xl"
|
||||||
)}>
|
)}>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -445,95 +496,97 @@ const BookingPage = ({
|
|||||||
"dark:border-darkgray-300 rounded-md sm:border"
|
"dark:border-darkgray-300 rounded-md sm:border"
|
||||||
)}>
|
)}>
|
||||||
<div className="sm:flex">
|
<div className="sm:flex">
|
||||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
{showEventTypeDetails && (
|
||||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
<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">
|
||||||
{stripeAppData.price > 0 && (
|
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
{stripeAppData.price > 0 && (
|
||||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||||
<IntlProvider locale="en">
|
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||||
<FormattedNumber
|
<IntlProvider locale="en">
|
||||||
value={stripeAppData.price / 100.0}
|
<FormattedNumber
|
||||||
style="currency"
|
value={stripeAppData.price / 100.0}
|
||||||
currency={stripeAppData.currency.toUpperCase()}
|
style="currency"
|
||||||
/>
|
currency={stripeAppData.currency.toUpperCase()}
|
||||||
</IntlProvider>
|
/>
|
||||||
</p>
|
</IntlProvider>
|
||||||
)}
|
|
||||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
|
||||||
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
|
||||||
<Icon.FiRefreshCw className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
|
||||||
<p className="-ml-2 inline-block items-center px-2">
|
|
||||||
{getEveryFreqFor({
|
|
||||||
t,
|
|
||||||
recurringEvent: eventType.recurringEvent,
|
|
||||||
recurringCount: recurringEventCount,
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
)}
|
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||||
<div className="text-bookinghighlight flex items-start text-sm">
|
<div className="items-start text-sm font-medium text-gray-600 dark:text-white">
|
||||||
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
<Icon.FiRefreshCw className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||||
<div className="text-sm font-medium">
|
<p className="-ml-2 inline-block items-center px-2">
|
||||||
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
{getEveryFreqFor({
|
||||||
{!rescheduleUid &&
|
t,
|
||||||
eventType.recurringEvent?.freq &&
|
recurringEvent: eventType.recurringEvent,
|
||||||
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
recurringCount: recurringEventCount,
|
||||||
return <p key={key}>{timeFormatted}</p>;
|
})}
|
||||||
})}
|
</p>
|
||||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
</div>
|
||||||
<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 && (
|
|
||||||
<div className="text-bookinghighlight flex items-start text-sm">
|
<div className="text-bookinghighlight flex items-start text-sm">
|
||||||
<Icon.FiUser
|
<Icon.FiCalendar className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
||||||
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
|
<div className="text-sm font-medium">
|
||||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
{(rescheduleUid || !eventType.recurringEvent?.freq) && `${parseDate(date, i18n)}`}
|
||||||
? "text-rose-600"
|
{!rescheduleUid &&
|
||||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
eventType.recurringEvent?.freq &&
|
||||||
? "text-yellow-500"
|
recurringStrings.slice(0, 5).map((timeFormatted, key) => {
|
||||||
: "text-bookinghighlight"
|
return <p key={key}>{timeFormatted}</p>;
|
||||||
}`}
|
})}
|
||||||
/>
|
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||||
<p
|
<div className="flex">
|
||||||
className={`${
|
<Tooltip
|
||||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
content={recurringStrings.slice(5).map((timeFormatted, key) => (
|
||||||
? "text-rose-600"
|
<p key={key}>{timeFormatted}</p>
|
||||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
))}>
|
||||||
? "text-yellow-500"
|
<p className="dark:text-darkgray-600 text-sm">
|
||||||
: "text-bookinghighlight"
|
+ {t("plus_more", { count: recurringStrings.length - 5 })}
|
||||||
} mb-2 font-medium`}>
|
</p>
|
||||||
{booking
|
</Tooltip>
|
||||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
</div>
|
||||||
: eventType.seatsPerTimeSlot}{" "}
|
)}
|
||||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{booking?.startTime && rescheduleUid && (
|
||||||
</BookingDescription>
|
<div>
|
||||||
</div>
|
<p className="mt-8 mb-2 text-sm " data-testid="former_time_p">
|
||||||
<div className="p-6 sm:w-1/2">
|
{t("former_time")}
|
||||||
|
</p>
|
||||||
|
<p className="line-through ">
|
||||||
|
<Icon.FiCalendar className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||||
|
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!eventType.seatsPerTimeSlot && (
|
||||||
|
<div className="text-bookinghighlight flex items-start text-sm">
|
||||||
|
<Icon.FiUser
|
||||||
|
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
|
||||||
|
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||||
|
? "text-rose-600"
|
||||||
|
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-bookinghighlight"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={`${
|
||||||
|
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||||
|
? "text-rose-600"
|
||||||
|
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-bookinghighlight"
|
||||||
|
} mb-2 font-medium`}>
|
||||||
|
{booking
|
||||||
|
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||||
|
: eventType.seatsPerTimeSlot}{" "}
|
||||||
|
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</BookingDescription>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={classNames("p-6", showEventTypeDetails ? "sm:w-1/2" : "w-full")}>
|
||||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
@@ -676,8 +729,11 @@ const BookingPage = ({
|
|||||||
{input.type !== EventTypeCustomInputType.BOOL && (
|
{input.type !== EventTypeCustomInputType.BOOL && (
|
||||||
<label
|
<label
|
||||||
htmlFor={"custom_" + input.id}
|
htmlFor={"custom_" + input.id}
|
||||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
className={classNames(
|
||||||
{input.label}
|
"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>
|
</label>
|
||||||
)}
|
)}
|
||||||
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
||||||
@@ -741,6 +797,48 @@ const BookingPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{input.options && input.type === EventTypeCustomInputType.RADIO && (
|
||||||
|
<div className="flex">
|
||||||
|
<Group
|
||||||
|
required={input.required}
|
||||||
|
onValueChange={(e) => {
|
||||||
|
bookingForm.setValue(`customInputs.${input.id}`, e);
|
||||||
|
}}>
|
||||||
|
<>
|
||||||
|
{input.options.map((option, i) => (
|
||||||
|
<RadioField
|
||||||
|
label={option.label}
|
||||||
|
key={`option.${i}.radio`}
|
||||||
|
value={option.label}
|
||||||
|
id={`option.${i}.radio`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
{bookingForm.formState.errors.customInputs?.[input.id] && (
|
||||||
|
<div className="mt-px flex items-center text-xs text-red-700 ">
|
||||||
|
<p>{t("required")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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="mr-2 h-3 w-3" />
|
||||||
|
<p>{t("invalid_number")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!eventType.disableGuests && guestToggle && (
|
{!eventType.disableGuests && guestToggle && (
|
||||||
@@ -839,7 +937,7 @@ const BookingPage = ({
|
|||||||
) : (
|
) : (
|
||||||
<textarea
|
<textarea
|
||||||
{...bookingForm.register("notes")}
|
{...bookingForm.register("notes")}
|
||||||
required={!!eventType.metadata.additionalNotesRequired}
|
required={!!eventType.metadata?.additionalNotesRequired}
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { Button, Dialog, DialogContent, Form, Icon, PhoneInput } from "@calcom/ui";
|
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, Form, Icon, PhoneInput } from "@calcom/ui";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
@@ -212,13 +212,13 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog}>
|
<Dialog open={isOpenDialog}>
|
||||||
<DialogContent asChild>
|
<DialogContent>
|
||||||
<div className="inline-block transform rounded-sm bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
<div className="flex flex-row space-x-3">
|
||||||
<div className="mb-4 sm:flex sm:items-start">
|
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||||
<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" />
|
||||||
<Icon.FiMapPin className="text-primary-600 h-6 w-6" />
|
</div>
|
||||||
</div>
|
<div className="w-full">
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||||
{t("edit_location")}
|
{t("edit_location")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -226,148 +226,152 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left" />
|
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
||||||
</div>
|
|
||||||
{booking && (
|
|
||||||
<>
|
|
||||||
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
|
|
||||||
<p className="mb-2 ml-1 text-sm text-black">
|
|
||||||
{getHumanReadableLocationValue(booking.location, t)}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Form
|
|
||||||
form={locationFormMethods}
|
|
||||||
handleSubmit={async (values) => {
|
|
||||||
const { locationType: newLocation, displayLocationPublicly } = values;
|
|
||||||
|
|
||||||
let details = {};
|
{booking && (
|
||||||
if (newLocation === LocationType.InPerson) {
|
<>
|
||||||
details = {
|
<p className="mt-6 mb-2 ml-1 text-sm font-bold text-black">{t("current_location")}:</p>
|
||||||
address: values.locationAddress,
|
<p className="mb-2 ml-1 text-sm text-black">
|
||||||
};
|
{getHumanReadableLocationValue(booking.location, t)}
|
||||||
}
|
</p>
|
||||||
const eventLocationType = getEventLocationType(newLocation);
|
</>
|
||||||
|
)}
|
||||||
|
<Form
|
||||||
|
form={locationFormMethods}
|
||||||
|
handleSubmit={async (values) => {
|
||||||
|
const { locationType: newLocation, displayLocationPublicly } = values;
|
||||||
|
|
||||||
// TODO: There can be a property that tells if it is to be saved in `link`
|
let details = {};
|
||||||
if (
|
if (newLocation === LocationType.InPerson) {
|
||||||
newLocation === LocationType.Link ||
|
details = {
|
||||||
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
address: values.locationAddress,
|
||||||
) {
|
};
|
||||||
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");
|
|
||||||
} else if (location.label === "in person") {
|
|
||||||
location.options.filter((l) => l.value !== "attendeeInPerson");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return (
|
const eventLocationType = getEventLocationType(newLocation);
|
||||||
<Controller
|
|
||||||
name="locationType"
|
// TODO: There can be a property that tells if it is to be saved in `link`
|
||||||
control={locationFormMethods.control}
|
if (
|
||||||
render={() => (
|
newLocation === LocationType.Link ||
|
||||||
<Select<{ label: string; value: string; icon?: string }>
|
(!eventLocationType?.default && eventLocationType?.linkType === "static")
|
||||||
maxMenuHeight={150}
|
) {
|
||||||
name="location"
|
details = { link: values.locationLink };
|
||||||
defaultValue={selection}
|
}
|
||||||
options={locationOptions}
|
|
||||||
components={{
|
if (newLocation === LocationType.UserPhone) {
|
||||||
Option: (props) => (
|
details = { hostPhoneNumber: values.locationPhoneNumber };
|
||||||
<components.Option {...props}>
|
}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{props.data.icon && (
|
if (eventLocationType?.organizerInputType) {
|
||||||
<img src={props.data.icon} alt="cover" className="h-3.5 w-3.5" />
|
details = {
|
||||||
)}
|
...details,
|
||||||
<span
|
displayLocationPublicly,
|
||||||
className={classNames(
|
};
|
||||||
"text-sm font-medium",
|
}
|
||||||
props.isSelected ? "text-white" : "text-gray-900"
|
|
||||||
)}>
|
saveLocation(newLocation, details);
|
||||||
{props.data.label}
|
setShowLocationModal(false);
|
||||||
</span>
|
setSelectedLocation?.(undefined);
|
||||||
</div>
|
locationFormMethods.unregister([
|
||||||
</components.Option>
|
"locationType",
|
||||||
),
|
"locationLink",
|
||||||
}}
|
"locationAddress",
|
||||||
formatOptionLabel={(e) => (
|
"locationPhoneNumber",
|
||||||
<div className="flex items-center gap-3">
|
]);
|
||||||
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
}}>
|
||||||
<span>{e.label}</span>
|
<QueryCell
|
||||||
</div>
|
query={locationsQuery}
|
||||||
)}
|
success={({ data: locationOptions }) => {
|
||||||
formatGroupLabel={(e) => (
|
if (!locationOptions.length) return null;
|
||||||
<p className="text-xs font-medium text-gray-600">{e.label}</p>
|
if (booking) {
|
||||||
)}
|
locationOptions.forEach((location) => {
|
||||||
isSearchable
|
if (location.label === "phone") {
|
||||||
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
location.options.filter((l) => l.value !== "phone");
|
||||||
onChange={(val) => {
|
} else if (location.label === "in person") {
|
||||||
if (val) {
|
location.options.filter((l) => l.value !== "attendeeInPerson");
|
||||||
locationFormMethods.setValue("locationType", val.value);
|
}
|
||||||
locationFormMethods.unregister([
|
});
|
||||||
"locationLink",
|
}
|
||||||
"locationAddress",
|
return (
|
||||||
"locationPhoneNumber",
|
<Controller
|
||||||
]);
|
name="locationType"
|
||||||
locationFormMethods.clearErrors([
|
control={locationFormMethods.control}
|
||||||
"locationLink",
|
render={() => (
|
||||||
"locationPhoneNumber",
|
<Select<{ label: string; value: string; icon?: string }>
|
||||||
"locationAddress",
|
maxMenuHeight={300}
|
||||||
]);
|
name="location"
|
||||||
setSelectedLocation?.(val);
|
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" />
|
||||||
/>
|
)}
|
||||||
{selectedLocation && LocationOptions}
|
<span
|
||||||
<div className="mt-4 flex justify-end space-x-2">
|
className={classNames(
|
||||||
<Button
|
"text-sm font-medium",
|
||||||
onClick={() => {
|
props.isSelected ? "text-white" : "text-gray-900"
|
||||||
setShowLocationModal(false);
|
)}>
|
||||||
setSelectedLocation?.(undefined);
|
{props.data.label}
|
||||||
setEditingLocationType?.("");
|
</span>
|
||||||
locationFormMethods.unregister("locationType");
|
</div>
|
||||||
|
</components.Option>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
formatOptionLabel={(e) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{e.icon && <img src={e.icon} alt="app-icon" className="h-5 w-5" />}
|
||||||
|
<span>{e.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
formatGroupLabel={(e) => (
|
||||||
|
<p className="text-xs font-medium text-gray-600">{e.label}</p>
|
||||||
|
)}
|
||||||
|
isSearchable
|
||||||
|
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 text-sm"
|
||||||
|
onChange={(val) => {
|
||||||
|
if (val) {
|
||||||
|
locationFormMethods.setValue("locationType", val.value);
|
||||||
|
locationFormMethods.unregister([
|
||||||
|
"locationLink",
|
||||||
|
"locationAddress",
|
||||||
|
"locationPhoneNumber",
|
||||||
|
]);
|
||||||
|
locationFormMethods.clearErrors([
|
||||||
|
"locationLink",
|
||||||
|
"locationPhoneNumber",
|
||||||
|
"locationAddress",
|
||||||
|
]);
|
||||||
|
setSelectedLocation?.(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
type="button"
|
/>
|
||||||
color="secondary">
|
{selectedLocation && LocationOptions}
|
||||||
{t("cancel")}
|
<DialogFooter>
|
||||||
</Button>
|
<div className="mt-4 flex justify-end space-x-2">
|
||||||
<Button type="submit">{t("update")}</Button>
|
<Button
|
||||||
</div>
|
onClick={() => {
|
||||||
</Form>
|
setShowLocationModal(false);
|
||||||
|
setSelectedLocation?.(undefined);
|
||||||
|
setEditingLocationType?.("");
|
||||||
|
locationFormMethods.unregister("locationType");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
color="secondary">
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit">{t("update")}</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -47,8 +47,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<DialogHeader title={t("send_reschedule_request")} />
|
<DialogHeader title={t("send_reschedule_request")} />
|
||||||
|
<p className="text-sm text-gray-500">{t("reschedule_modal_description")}</p>
|
||||||
<p className="-mt-8 text-sm text-gray-500">{t("reschedule_modal_description")}</p>
|
|
||||||
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
||||||
{t("reason_for_reschedule_request")}
|
{t("reason_for_reschedule_request")}
|
||||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||||
@@ -62,9 +61,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose />
|
||||||
<Button color="secondary">{t("cancel")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
<Button
|
||||||
data-testid="send_request"
|
data-testid="send_request"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ type AvailabilityOption = {
|
|||||||
|
|
||||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||||
const { label, isDefault } = props.data;
|
const { label, isDefault } = props.data;
|
||||||
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<components.Option {...props}>
|
<components.Option {...props}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
<Badge variant="blue" className="ml-2">
|
<Badge variant="blue" className="ml-2">
|
||||||
Default
|
{t("default")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</components.Option>
|
</components.Option>
|
||||||
@@ -34,12 +35,13 @@ const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
|||||||
|
|
||||||
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||||
const { label, isDefault } = props.data;
|
const { label, isDefault } = props.data;
|
||||||
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<components.SingleValue {...props}>
|
<components.SingleValue {...props}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
<Badge variant="blue" className="ml-2">
|
<Badge variant="blue" className="ml-2">
|
||||||
Default
|
{t("default")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</components.SingleValue>
|
</components.SingleValue>
|
||||||
@@ -57,6 +59,8 @@ const AvailabilitySelect = ({
|
|||||||
onChange: (value: AvailabilityOption | null) => void;
|
onChange: (value: AvailabilityOption | null) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <SelectSkeletonLoader />;
|
return <SelectSkeletonLoader />;
|
||||||
}
|
}
|
||||||
@@ -77,6 +81,7 @@ const AvailabilitySelect = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
placeholder={t("select")}
|
||||||
options={options}
|
options={options}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
|
|||||||
@@ -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 { 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 "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button, Icon, Label, Select, TextField } from "@calcom/ui";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
interface OptionTypeBase {
|
interface OptionTypeBase {
|
||||||
label: string;
|
label: string;
|
||||||
value: EventTypeCustomInputType;
|
value: EventTypeCustomInputType;
|
||||||
|
options?: { label: string; type: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSubmit: SubmitHandler<IFormInput>;
|
onSubmit: (output: CustomInputParsed) => void;
|
||||||
onCancel: () => 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 CustomInputTypeForm: FC<Props> = (props) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -26,10 +36,23 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||||
|
{
|
||||||
|
value: EventTypeCustomInputType.RADIO,
|
||||||
|
label: t("radio"),
|
||||||
|
},
|
||||||
|
{ value: EventTypeCustomInputType.PHONE, label: t("phone_number") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { selectedCustomInput } = props;
|
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,
|
defaultValues,
|
||||||
});
|
});
|
||||||
const selectedInputType = useWatch({ name: "type", control });
|
const selectedInputType = useWatch({ name: "type", control });
|
||||||
@@ -40,7 +63,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(props.onSubmit)} className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||||
{t("input_type")}
|
{t("input_type")}
|
||||||
@@ -83,6 +106,10 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
{...register("placeholder")}
|
{...register("placeholder")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{selectedInputType === EventTypeCustomInputType.RADIO && (
|
||||||
|
<RadioInputHandler control={control} register={register} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex h-5 items-center">
|
<div className="flex h-5 items-center">
|
||||||
<input
|
<input
|
||||||
id="required"
|
id="required"
|
||||||
@@ -111,10 +138,75 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
|||||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2">
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">{t("save")}</Button>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
props.onSubmit(getValues());
|
||||||
|
}}>
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
</div>
|
</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;
|
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,22 @@
|
|||||||
import { EventTypeCustomInput } from "@prisma/client/";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import type { CustomInputParsed, EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
import 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 } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CustomInputItem,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
Icon,
|
Icon,
|
||||||
Label,
|
Label,
|
||||||
SettingsToggle,
|
SettingsToggle,
|
||||||
@@ -26,6 +27,8 @@ import {
|
|||||||
|
|
||||||
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
||||||
|
|
||||||
|
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||||
|
|
||||||
const generateHashedLink = (id: number) => {
|
const generateHashedLink = (id: number) => {
|
||||||
const translator = short();
|
const translator = short();
|
||||||
const seed = `${id}:${new Date().getTime()}`;
|
const seed = `${id}:${new Date().getTime()}`;
|
||||||
@@ -41,11 +44,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
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) || []
|
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 [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||||
|
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||||
|
|
||||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||||
@@ -99,7 +103,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
<TextField
|
<TextField
|
||||||
label={t("event_name")}
|
label={t("event_name")}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("meeting_with_user")}
|
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName")}
|
||||||
addOnSuffix={
|
addOnSuffix={
|
||||||
@@ -123,15 +127,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
onCheckedChange={(e) => {
|
onCheckedChange={(e) => {
|
||||||
if (e && customInputs.length === 0) {
|
if (e && customInputs.length === 0) {
|
||||||
// Push a placeholders
|
// Push a placeholders
|
||||||
setSelectedCustomInput(undefined);
|
|
||||||
setSelectedCustomInputModalOpen(true);
|
setSelectedCustomInputModalOpen(true);
|
||||||
} else if (!e) {
|
} else if (!e) {
|
||||||
setCustomInputs([]);
|
|
||||||
formMethods.setValue("customInputs", []);
|
formMethods.setValue("customInputs", []);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<ul className="my-4">
|
<ul className="my-4 rounded-md border">
|
||||||
{customInputs.map((customInput: EventTypeCustomInput, idx: number) => (
|
{customInputs.map((customInput, idx) => (
|
||||||
<CustomInputItem
|
<CustomInputItem
|
||||||
key={idx}
|
key={idx}
|
||||||
question={customInput.label}
|
question={customInput.label}
|
||||||
@@ -154,24 +156,17 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
setSelectedCustomInput(undefined);
|
setSelectedCustomInput(undefined);
|
||||||
setSelectedCustomInputModalOpen(true);
|
setSelectedCustomInputModalOpen(true);
|
||||||
}}>
|
}}>
|
||||||
Add an input
|
{t("add_input")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<Controller
|
<RequiresConfirmationController
|
||||||
name="requiresConfirmation"
|
seatsEnabled={seatsEnabled}
|
||||||
defaultValue={eventType.requiresConfirmation}
|
metadata={eventType.metadata}
|
||||||
render={({ field: { value, onChange } }) => (
|
requiresConfirmation={requiresConfirmation}
|
||||||
<SettingsToggle
|
onRequiresConfirmation={setRequiresConfirmation}
|
||||||
title={t("requires_confirmation")}
|
|
||||||
description={t("requires_confirmation_description")}
|
|
||||||
checked={value}
|
|
||||||
onCheckedChange={(e) => onChange(e)}
|
|
||||||
disabled={seatsEnabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<hr />
|
<hr />
|
||||||
<Controller
|
<Controller
|
||||||
@@ -254,7 +249,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
data-testid="hashedLinkCheck"
|
data-testid="hashedLinkCheck"
|
||||||
title={t("private_link")}
|
title={t("private_link")}
|
||||||
description={t("private_link_description")}
|
description={t("private_link_description", { appName: APP_NAME })}
|
||||||
checked={hashedLinkVisible}
|
checked={hashedLinkVisible}
|
||||||
onCheckedChange={(e) => {
|
onCheckedChange={(e) => {
|
||||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
|
||||||
@@ -307,6 +302,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
if (e) {
|
if (e) {
|
||||||
formMethods.setValue("disableGuests", true);
|
formMethods.setValue("disableGuests", true);
|
||||||
formMethods.setValue("requiresConfirmation", false);
|
formMethods.setValue("requiresConfirmation", false);
|
||||||
|
setRequiresConfirmation(false);
|
||||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||||
} else {
|
} else {
|
||||||
formMethods.setValue("seatsPerTimeSlot", null);
|
formMethods.setValue("seatsPerTimeSlot", null);
|
||||||
@@ -352,15 +348,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
title={t("custom_event_name")}
|
title={t("custom_event_name")}
|
||||||
description={t("custom_event_name_description")}
|
description={t("custom_event_name_description")}
|
||||||
type="creation"
|
type="creation">
|
||||||
actionText="Create"
|
|
||||||
// Set event name back to what it was on close
|
|
||||||
actionOnClose={() => formMethods.setValue("eventName", eventType.eventName ?? "")}
|
|
||||||
actionOnClick={() => setShowEventNameTip(false)}>
|
|
||||||
<TextField
|
<TextField
|
||||||
label={t("event_name")}
|
label={t("event_name")}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("meeting_with_user")}
|
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName")}
|
||||||
/>
|
/>
|
||||||
@@ -370,6 +362,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
|
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
|
||||||
<p>{`{LOCATION} = ${t("event_location")}`}</p>
|
<p>{`{LOCATION} = ${t("event_location")}`}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
||||||
|
{t("create")}
|
||||||
|
</Button>
|
||||||
|
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")} />
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
@@ -383,29 +381,40 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||||||
type="creation"
|
type="creation"
|
||||||
Icon={Icon.FiPlus}
|
Icon={Icon.FiPlus}
|
||||||
title={t("add_new_custom_input_field")}
|
title={t("add_new_custom_input_field")}
|
||||||
useOwnActionButtons
|
|
||||||
description={t("this_input_will_shown_booking_this_event")}>
|
description={t("this_input_will_shown_booking_this_event")}>
|
||||||
<CustomInputTypeForm
|
<CustomInputTypeForm
|
||||||
selectedCustomInput={selectedCustomInput}
|
selectedCustomInput={selectedCustomInput}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
const customInput: EventTypeCustomInput = {
|
const customInput: CustomInputParsed = {
|
||||||
id: -1,
|
id: -1,
|
||||||
eventTypeId: -1,
|
eventTypeId: -1,
|
||||||
label: values.label,
|
label: values.label,
|
||||||
placeholder: values.placeholder,
|
placeholder: values.placeholder,
|
||||||
required: values.required,
|
required: values.required,
|
||||||
type: values.type,
|
type: values.type,
|
||||||
|
options: values.options,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (selectedCustomInput) {
|
if (selectedCustomInput) {
|
||||||
selectedCustomInput.label = customInput.label;
|
selectedCustomInput.label = customInput.label;
|
||||||
selectedCustomInput.placeholder = customInput.placeholder;
|
selectedCustomInput.placeholder = customInput.placeholder;
|
||||||
selectedCustomInput.required = customInput.required;
|
selectedCustomInput.required = customInput.required;
|
||||||
selectedCustomInput.type = customInput.type;
|
selectedCustomInput.type = customInput.type;
|
||||||
|
selectedCustomInput.options = customInput.options || undefined;
|
||||||
|
// Update by id
|
||||||
|
const inputIndex = customInputs.findIndex((input) => input.id === values.id);
|
||||||
|
customInputs[inputIndex] = selectedCustomInput;
|
||||||
|
setCustomInputs(customInputs);
|
||||||
|
formMethods.setValue("customInputs", customInputs);
|
||||||
} else {
|
} else {
|
||||||
setCustomInputs(customInputs.concat(customInput));
|
const concatted = customInputs.concat({
|
||||||
formMethods.setValue("customInputs", customInputs.concat(customInput));
|
...customInput,
|
||||||
|
options: customInput.options,
|
||||||
|
});
|
||||||
|
console.log(concatted);
|
||||||
|
setCustomInputs(concatted);
|
||||||
|
formMethods.setValue("customInputs", concatted);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedCustomInputModalOpen(false);
|
setSelectedCustomInputModalOpen(false);
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { isValidPhoneNumber } from "libphonenumber-js";
|
|||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||||
|
import { MultiValue } from "react-select";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { 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";
|
import { slugify } from "@lib/slugify";
|
||||||
|
|
||||||
@@ -30,6 +31,22 @@ export const EventSetupTab = (
|
|||||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||||
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
|
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"]) => {
|
const openLocationModal = (type: EventLocationType["type"]) => {
|
||||||
setSelectedLocation(locationOptions.find((option) => option.value === type));
|
setSelectedLocation(locationOptions.find((option) => option.value === type));
|
||||||
@@ -113,6 +130,7 @@ export const EventSetupTab = (
|
|||||||
{validLocations.length === 0 && (
|
{validLocations.length === 0 && (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Select
|
<Select
|
||||||
|
placeholder={t("select")}
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
@@ -195,7 +213,7 @@ export const EventSetupTab = (
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
label={t("Title")}
|
label={t("title")}
|
||||||
defaultValue={eventType.title}
|
defaultValue={eventType.title}
|
||||||
{...formMethods.register("title")}
|
{...formMethods.register("title")}
|
||||||
/>
|
/>
|
||||||
@@ -219,17 +237,91 @@ export const EventSetupTab = (
|
|||||||
setValueAs: (v) => slugify(v),
|
setValueAs: (v) => slugify(v),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<TextField
|
{multipleDuration ? (
|
||||||
required
|
<div className="space-y-4">
|
||||||
name="length"
|
<div>
|
||||||
type="number"
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
label={t("duration")}
|
{t("available_durations")}
|
||||||
addOnSuffix={<>{t("minutes")}</>}
|
</Skeleton>
|
||||||
defaultValue={eventType.length ?? 15}
|
<Select
|
||||||
onChange={(e) => {
|
isMulti
|
||||||
formMethods.setValue("length", Number(e.target.value));
|
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>
|
<div>
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
{t("location")}
|
{t("location")}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Avatar, Button, CheckedTeamSelect, Icon, Label, Select } from "@calcom/ui";
|
import { Avatar, Button, Icon, Label, Select } from "@calcom/ui";
|
||||||
|
|
||||||
interface IMemberToValue {
|
interface IMemberToValue {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { EventTypeSetupInfered, 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<EventTypeSetupInfered, "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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,8 +2,8 @@ import { TFunction } from "next-i18next";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
import { EventTypeSetupInfered, FormValues } from "pages/event-types/[type]";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Loader } from "react-feather";
|
|
||||||
import { UseFormReturn } from "react-hook-form";
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
import { TbWebhook } from "react-icons/tb";
|
||||||
|
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
@@ -67,7 +67,7 @@ function getNavigation(props: {
|
|||||||
name: "availability",
|
name: "availability",
|
||||||
href: `/event-types/${eventType.id}?tabName=availability`,
|
href: `/event-types/${eventType.id}?tabName=availability`,
|
||||||
icon: Icon.FiCalendar,
|
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",
|
name: "event_limit_tab_title",
|
||||||
@@ -150,13 +150,20 @@ function EventTypeSingleLayout({
|
|||||||
enabledWorkflowsNumber,
|
enabledWorkflowsNumber,
|
||||||
});
|
});
|
||||||
// If there is a team put this navigation item within the tabs
|
// If there is a team put this navigation item within the tabs
|
||||||
if (team)
|
if (team) {
|
||||||
navigation.splice(2, 0, {
|
navigation.splice(2, 0, {
|
||||||
name: "scheduling_type",
|
name: "scheduling_type",
|
||||||
href: `/event-types/${eventType.id}?tabName=team`,
|
href: `/event-types/${eventType.id}?tabName=team`,
|
||||||
icon: Icon.FiUsers,
|
icon: Icon.FiUsers,
|
||||||
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
||||||
});
|
});
|
||||||
|
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;
|
return navigation;
|
||||||
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
}, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
||||||
|
|
||||||
@@ -294,7 +301,7 @@ function EventTypeSingleLayout({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<ClientSuspense fallback={<Loader />}>
|
<ClientSuspense fallback={<Icon.FiLoader />}>
|
||||||
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
||||||
<div className="hidden xl:block">
|
<div className="hidden xl:block">
|
||||||
<VerticalTabs
|
<VerticalTabs
|
||||||
|
|||||||
@@ -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 { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { User } from "@calcom/prisma/client";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, ImageUploader, showToast, TextArea } from "@calcom/ui";
|
import { Button, ImageUploader, showToast, TextArea } from "@calcom/ui";
|
||||||
|
import { Avatar } from "@calcom/ui";
|
||||||
|
|
||||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||||
|
|
||||||
interface IUserProfile {
|
|
||||||
user?: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormData = {
|
type FormData = {
|
||||||
bio: string;
|
bio: string;
|
||||||
};
|
};
|
||||||
|
interface IUserProfileProps {
|
||||||
|
user: IOnboardingPageProps["user"];
|
||||||
|
}
|
||||||
|
|
||||||
const UserProfile = (props: IUserProfile) => {
|
const UserProfile = (props: IUserProfileProps) => {
|
||||||
const { user } = props;
|
const { user } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||||
@@ -100,7 +99,14 @@ const UserProfile = (props: IUserProfile) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
<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
|
<input
|
||||||
ref={avatarRef}
|
ref={avatarRef}
|
||||||
type="hidden"
|
type="hidden"
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { User } from "@calcom/prisma/client";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, TimezoneSelect } from "@calcom/ui";
|
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 {
|
interface IUserSettingsProps {
|
||||||
user: User;
|
user: IOnboardingPageProps["user"];
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,11 +24,9 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
control,
|
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: user?.name || "",
|
name: user?.name || "",
|
||||||
username: user?.username || "",
|
|
||||||
},
|
},
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
});
|
});
|
||||||
@@ -41,39 +40,19 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: onSuccess,
|
onSuccess: onSuccess,
|
||||||
});
|
});
|
||||||
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
|
|
||||||
const paymentRequired = stripeCustomer?.isPremium ? !stripeCustomer?.paidForPremium : false;
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
const onSubmit = handleSubmit((data) => {
|
||||||
if (paymentRequired) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
timeZone: selectedTimeZone,
|
timeZone: selectedTimeZone,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const [currentUsername, setCurrentUsername] = useState(user.username || "");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Username textfield */}
|
{/* Username textfield */}
|
||||||
<Controller
|
<UsernameAvailabilityField user={user} />
|
||||||
control={control}
|
|
||||||
name="username"
|
|
||||||
render={({ field: { value, ref, onChange } }) => (
|
|
||||||
<UsernameAvailability
|
|
||||||
readonly={true}
|
|
||||||
currentUsername={currentUsername}
|
|
||||||
setCurrentUsername={setCurrentUsername}
|
|
||||||
inputUsernameValue={value}
|
|
||||||
usernameRef={ref}
|
|
||||||
setInputUsernameValue={onChange}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Full name textfield */}
|
{/* Full name textfield */}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
MeetingImageProps,
|
MeetingImageProps,
|
||||||
} from "@calcom/lib/OgImages";
|
} from "@calcom/lib/OgImages";
|
||||||
import { getBrowserInfo } from "@calcom/lib/browser/browser.utils";
|
import { getBrowserInfo } from "@calcom/lib/browser/browser.utils";
|
||||||
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { truncateOnWord } from "@calcom/lib/text";
|
import { truncateOnWord } from "@calcom/lib/text";
|
||||||
|
|
||||||
import { getSeoImage, seoConfig } from "@lib/config/next-seo.config";
|
import { getSeoImage, seoConfig } from "@lib/config/next-seo.config";
|
||||||
@@ -79,7 +80,7 @@ export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
|
|||||||
const image = getSeoImage("ogImage") + constructGenericImage({ title, description });
|
const image = getSeoImage("ogImage") + constructGenericImage({ title, description });
|
||||||
const truncatedDescription = truncateOnWord(description, 158);
|
const truncatedDescription = truncateOnWord(description, 158);
|
||||||
|
|
||||||
const pageTitle = title + " | Cal.com";
|
const pageTitle = title + " | " + APP_NAME;
|
||||||
let seoObject = buildSeoMeta({
|
let seoObject = buildSeoMeta({
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
image,
|
image,
|
||||||
|
|||||||
@@ -72,11 +72,7 @@ const DisableTwoFactorAuthModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent title={t("disable_2fa")} description={t("disable_2fa_recommendation")} type="creation">
|
||||||
title={t("disable_2fa")}
|
|
||||||
description={t("disable_2fa_recommendation")}
|
|
||||||
type="creation"
|
|
||||||
useOwnActionButtons>
|
|
||||||
<Form form={form} handleSubmit={handleDisable}>
|
<Form form={form} handleSubmit={handleDisable}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
|||||||
@@ -130,11 +130,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
|
||||||
title={t("enable_2fa")}
|
|
||||||
description={setupDescriptions[step]}
|
|
||||||
type="creation"
|
|
||||||
useOwnActionButtons>
|
|
||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<form onSubmit={handleSetup}>
|
<form onSubmit={handleSetup}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { LOGO } from "@calcom/lib/constants";
|
import { APP_NAME, LOGO } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
@@ -21,7 +21,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
|||||||
<HeadSeo title={props.title} description={props.description} />
|
<HeadSeo title={props.title} description={props.description} />
|
||||||
{props.showLogo && (
|
{props.showLogo && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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")}>
|
<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>}
|
{props.heading && <h2 className="font-cal text-center text-3xl text-neutral-900">{props.heading}</h2>}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
@@ -12,6 +13,7 @@ export type AvatarGroupProps = {
|
|||||||
image: string;
|
image: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
href?: string;
|
||||||
}[];
|
}[];
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
@@ -21,15 +23,25 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
|||||||
<ul className={classNames(props.className)}>
|
<ul className={classNames(props.className)}>
|
||||||
{props.items.slice(0, props.truncateAfter).map((item, idx) => {
|
{props.items.slice(0, props.truncateAfter).map((item, idx) => {
|
||||||
if (item.image != null) {
|
if (item.image != null) {
|
||||||
|
const avatar = (
|
||||||
|
<AvatarSSR
|
||||||
|
className={props.border}
|
||||||
|
imageSrc={item.image}
|
||||||
|
title={item.title}
|
||||||
|
alt={item.alt || ""}
|
||||||
|
size={props.size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={idx} className="-mr-2 inline-block">
|
<li key={idx} className="-mr-2 inline-block">
|
||||||
<AvatarSSR
|
{item.href ? (
|
||||||
className={props.border}
|
<Link href={item.href}>
|
||||||
imageSrc={item.image}
|
<a>{avatar}</a>
|
||||||
title={item.title}
|
</Link>
|
||||||
alt={item.alt || ""}
|
) : (
|
||||||
size={props.size}
|
avatar
|
||||||
/>
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type AvatarProps = (
|
|||||||
className?: string;
|
className?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
href?: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export function AvatarSSR(props: AvatarProps) {
|
|||||||
const avatar = imgSrc ? <img alt={alt} className={className} src={imgSrc} /> : null;
|
const avatar = imgSrc ? <img alt={alt} className={className} src={imgSrc} /> : null;
|
||||||
return title ? (
|
return title ? (
|
||||||
<Tooltip.Tooltip delayDuration={300}>
|
<Tooltip.Tooltip delayDuration={300}>
|
||||||
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
|
<Tooltip.TooltipTrigger asChild>{avatar}</Tooltip.TooltipTrigger>
|
||||||
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white">
|
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white">
|
||||||
<Tooltip.Arrow />
|
<Tooltip.Arrow />
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { debounce, noop } from "lodash";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { RefCallback, useEffect, useMemo, useState } from "react";
|
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 { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -24,9 +24,7 @@ import {
|
|||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
export enum UsernameChangeStatusEnum {
|
export enum UsernameChangeStatusEnum {
|
||||||
NORMAL = "NORMAL",
|
|
||||||
UPGRADE = "UPGRADE",
|
UPGRADE = "UPGRADE",
|
||||||
DOWNGRADE = "DOWNGRADE",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICustomUsernameProps {
|
interface ICustomUsernameProps {
|
||||||
@@ -37,43 +35,21 @@ interface ICustomUsernameProps {
|
|||||||
setInputUsernameValue: (value: string) => void;
|
setInputUsernameValue: (value: string) => void;
|
||||||
onSuccessMutation?: () => void;
|
onSuccessMutation?: () => void;
|
||||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||||
user: Pick<
|
user: Pick<User, "username" | "metadata">;
|
||||||
User,
|
|
||||||
| "username"
|
|
||||||
| "name"
|
|
||||||
| "email"
|
|
||||||
| "bio"
|
|
||||||
| "avatar"
|
|
||||||
| "timeZone"
|
|
||||||
| "weekStart"
|
|
||||||
| "hideBranding"
|
|
||||||
| "theme"
|
|
||||||
| "plan"
|
|
||||||
| "brandColor"
|
|
||||||
| "darkBrandColor"
|
|
||||||
| "timeFormat"
|
|
||||||
| "metadata"
|
|
||||||
>;
|
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const obtainNewUsernameChangeCondition = ({
|
const obtainNewUsernameChangeCondition = ({
|
||||||
userIsPremium,
|
userIsPremium,
|
||||||
isNewUsernamePremium,
|
isNewUsernamePremium,
|
||||||
stripeCustomer,
|
|
||||||
}: {
|
}: {
|
||||||
userIsPremium: boolean;
|
userIsPremium: boolean;
|
||||||
isNewUsernamePremium: boolean;
|
isNewUsernamePremium: boolean;
|
||||||
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
|
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
|
||||||
}) => {
|
}) => {
|
||||||
if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) {
|
if (!userIsPremium && isNewUsernamePremium) {
|
||||||
return UsernameChangeStatusEnum.UPGRADE;
|
return UsernameChangeStatusEnum.UPGRADE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") {
|
|
||||||
return UsernameChangeStatusEnum.DOWNGRADE;
|
|
||||||
}
|
|
||||||
return UsernameChangeStatusEnum.NORMAL;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||||
@@ -106,7 +82,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
setIsInputUsernamePremium(data.premium);
|
setIsInputUsernamePremium(data.premium);
|
||||||
setUsernameIsAvailable(data.available);
|
setUsernameIsAvailable(data.available);
|
||||||
}, 150),
|
}, 150),
|
||||||
[]
|
[currentUsername]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -137,7 +113,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
|
// 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({
|
const usernameChangeCondition = obtainNewUsernameChangeCondition({
|
||||||
userIsPremium: isCurrentUsernamePremium,
|
userIsPremium: isCurrentUsernamePremium,
|
||||||
@@ -194,7 +170,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveUsername = () => {
|
const saveUsername = () => {
|
||||||
if (usernameChangeCondition === UsernameChangeStatusEnum.NORMAL) {
|
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
|
||||||
updateUsername.mutate({
|
updateUsername.mutate({
|
||||||
username: inputUsernameValue,
|
username: inputUsernameValue,
|
||||||
});
|
});
|
||||||
@@ -279,14 +255,6 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
{paymentMsg}
|
{paymentMsg}
|
||||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||||
|
|
||||||
{usernameIsAvailable && (
|
|
||||||
<p className={classNames("mt-1 text-xs text-gray-900")}>
|
|
||||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && (
|
|
||||||
<>{t("premium_to_standard_username_description")}</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog open={openDialogSaveUsername}>
|
<Dialog open={openDialogSaveUsername}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
@@ -295,13 +263,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-4 w-full px-4 pt-1">
|
<div className="mb-4 w-full px-4 pt-1">
|
||||||
<DialogHeader title={t("confirm_username_change_dialog_title")} />
|
<DialogHeader title={t("confirm_username_change_dialog_title")} />
|
||||||
{usernameChangeCondition && usernameChangeCondition !== UsernameChangeStatusEnum.NORMAL && (
|
{usernameChangeCondition && usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||||
<p className="-mt-4 mb-4 text-sm text-gray-800">
|
<p className="mb-4 text-sm text-gray-800">{t("change_username_standard_to_premium")}</p>
|
||||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE &&
|
|
||||||
t("change_username_standard_to_premium")}
|
|
||||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE &&
|
|
||||||
t("change_username_premium_to_standard")}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
|
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
|
||||||
@@ -323,8 +286,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
|
|
||||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||||
{/* redirect to checkout */}
|
{/* redirect to checkout */}
|
||||||
{(usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE ||
|
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||||
usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE) && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
loading={updateUsername.isLoading}
|
loading={updateUsername.isLoading}
|
||||||
@@ -336,7 +298,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Normal save */}
|
{/* Normal save */}
|
||||||
{usernameChangeCondition === UsernameChangeStatusEnum.NORMAL && (
|
{usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
loading={updateUsername.isLoading}
|
loading={updateUsername.isLoading}
|
||||||
@@ -347,10 +309,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
|||||||
{t("save")}
|
{t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DialogClose asChild>
|
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||||
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
{t("cancel")}
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||||||
<Input
|
<Input
|
||||||
ref={usernameRef}
|
ref={usernameRef}
|
||||||
name="username"
|
name="username"
|
||||||
|
value={inputUsernameValue}
|
||||||
autoComplete="none"
|
autoComplete="none"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="none"
|
autoCorrect="none"
|
||||||
@@ -133,7 +134,6 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||||
: ""
|
: ""
|
||||||
)}
|
)}
|
||||||
defaultValue={currentUsername}
|
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setInputUsernameValue(event.target.value);
|
setInputUsernameValue(event.target.value);
|
||||||
@@ -194,10 +194,8 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
|||||||
{t("save")}
|
{t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<DialogClose asChild>
|
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||||
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
{t("cancel")}
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</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 { 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 { PremiumTextfield } from "./PremiumTextfield";
|
||||||
import { UsernameTextfield } from "./UsernameTextfield";
|
import { UsernameTextfield } from "./UsernameTextfield";
|
||||||
|
|
||||||
export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : PremiumTextfield;
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// @see: https://github.com/wojtekmaj/react-daterange-picker/issues/91
|
|
||||||
import "@wojtekmaj/react-daterange-picker/dist/DateRangePicker.css";
|
|
||||||
import PrimitiveDateRangePicker from "@wojtekmaj/react-daterange-picker/dist/entry.nostyle";
|
|
||||||
import React from "react";
|
|
||||||
import "react-calendar/dist/Calendar.css";
|
|
||||||
|
|
||||||
import { Icon } from "@calcom/ui";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
onDatesChange?: ((arg: { startDate: Date; endDate: Date }) => void) | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DateRangePicker = ({ startDate, endDate, onDatesChange }: Props) => {
|
|
||||||
return (
|
|
||||||
<PrimitiveDateRangePicker
|
|
||||||
className="rounded-sm border-gray-300 text-sm"
|
|
||||||
clearIcon={null}
|
|
||||||
calendarIcon={<Icon.FiCalendar className="h-5 w-5 text-gray-500" />}
|
|
||||||
rangeDivider={<Icon.FiArrowRight className="h-4 w-4 text-gray-400 ltr:mr-2 rtl:ml-2" />}
|
|
||||||
value={[startDate, endDate]}
|
|
||||||
onChange={([startDate, endDate]) => {
|
|
||||||
if (typeof onDatesChange === "function") onDatesChange({ startDate, endDate });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DefaultSeoProps, NextSeoProps } from "next-seo";
|
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 = {
|
export type HeadSeoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -27,7 +27,7 @@ export const seoConfig: {
|
|||||||
defaultNextSeo: DefaultSeoProps;
|
defaultNextSeo: DefaultSeoProps;
|
||||||
} = {
|
} = {
|
||||||
headSeo: {
|
headSeo: {
|
||||||
siteName: "Cal.com",
|
siteName: APP_NAME,
|
||||||
},
|
},
|
||||||
defaultNextSeo: {
|
defaultNextSeo: {
|
||||||
twitter: {
|
twitter: {
|
||||||
|
|||||||
@@ -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() {
|
export default function usePublicPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isPublicPage = ["/[user]", "/success", "/cancel", "/reschedule"].find((route) =>
|
const isPublicPage = ["/[user]", "/booking", "/cancel", "/reschedule"].find((route) =>
|
||||||
router.pathname.startsWith(route)
|
router.pathname.startsWith(route)
|
||||||
);
|
);
|
||||||
return isPublicPage;
|
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(hideBrandingSetting: boolean, belongsToActiveTeam: boolean) {
|
||||||
|
return belongsToActiveTeam && hideBrandingSetting;
|
||||||
export function isBrandingHidden<TUser extends Pick<User, "hideBranding" | "plan">>(user: TUser) {
|
|
||||||
return user.hideBranding && user.plan !== "FREE";
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,29 @@ export type EmbedProps = {
|
|||||||
export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
||||||
return async (context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<EmbedProps>> => {
|
return async (context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<EmbedProps>> => {
|
||||||
const ssrResponse = await getServerSideProps(context);
|
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)) {
|
if (!("props" in ssrResponse)) {
|
||||||
return ssrResponse;
|
return ssrResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,21 @@ const nextConfig = {
|
|||||||
source: "/router",
|
source: "/router",
|
||||||
destination: "/apps/routing-forms/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 {
|
/* TODO: have these files being served from another deployment or CDN {
|
||||||
source: "/embed/embed.js",
|
source: "/embed/embed.js",
|
||||||
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
|
destination: process.env.NEXT_PUBLIC_EMBED_LIB_URL?,
|
||||||
@@ -212,6 +227,11 @@ const nextConfig = {
|
|||||||
destination: "/404",
|
destination: "/404",
|
||||||
permanent: false,
|
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") {
|
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "2.3.0",
|
"version": "2.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"@calcom/tsconfig": "*",
|
"@calcom/tsconfig": "*",
|
||||||
"@calcom/ui": "*",
|
"@calcom/ui": "*",
|
||||||
"@daily-co/daily-js": "^0.26.0",
|
"@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",
|
"@glidejs/glide": "^3.5.2",
|
||||||
"@heroicons/react": "^1.0.6",
|
"@heroicons/react": "^1.0.6",
|
||||||
"@hookform/error-message": "^2.0.0",
|
"@hookform/error-message": "^2.0.0",
|
||||||
@@ -61,8 +61,7 @@
|
|||||||
"@stripe/stripe-js": "^1.35.0",
|
"@stripe/stripe-js": "^1.35.0",
|
||||||
"@tanstack/react-query": "^4.3.9",
|
"@tanstack/react-query": "^4.3.9",
|
||||||
"@vercel/edge-functions-ui": "^0.2.1",
|
"@vercel/edge-functions-ui": "^0.2.1",
|
||||||
"@vercel/og": "^0.0.19",
|
"@vercel/og": "^0.0.21",
|
||||||
"@wojtekmaj/react-daterange-picker": "^3.3.1",
|
|
||||||
"accept-language-parser": "^1.5.0",
|
"accept-language-parser": "^1.5.0",
|
||||||
"async": "^3.2.4",
|
"async": "^3.2.4",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -108,19 +107,21 @@
|
|||||||
"react-live-chat-loader": "^2.7.3",
|
"react-live-chat-loader": "^2.7.3",
|
||||||
"react-multi-email": "^0.5.3",
|
"react-multi-email": "^0.5.3",
|
||||||
"react-phone-number-input": "^3.2.7",
|
"react-phone-number-input": "^3.2.7",
|
||||||
|
"react-schemaorg": "^2.0.0",
|
||||||
"react-select": "^5.4.0",
|
"react-select": "^5.4.0",
|
||||||
"react-timezone-select": "^1.3.2",
|
"react-timezone-select": "^1.3.2",
|
||||||
"react-use-intercom": "1.5.1",
|
"react-use-intercom": "1.5.1",
|
||||||
"react-virtualized-auto-sizer": "^1.0.6",
|
"react-virtualized-auto-sizer": "^1.0.6",
|
||||||
"react-window": "^1.8.7",
|
"react-window": "^1.8.7",
|
||||||
"rrule": "^2.7.1",
|
"rrule": "^2.7.1",
|
||||||
|
"schema-dts": "^1.1.0",
|
||||||
"short-uuid": "^4.2.0",
|
"short-uuid": "^4.2.0",
|
||||||
"stripe": "^9.16.0",
|
"stripe": "^9.16.0",
|
||||||
"superjson": "1.9.1",
|
"superjson": "1.9.1",
|
||||||
"tailwindcss-radix": "^2.6.0",
|
"tailwindcss-radix": "^2.6.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"web3": "^1.7.5",
|
"web3": "^1.7.5",
|
||||||
"zod": "^3.19.1"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.19.6",
|
"@babel/core": "^7.19.6",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { GetStaticPropsContext } from "next";
|
import { GetStaticPropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
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 { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { Icon } from "@calcom/ui";
|
import { Icon } from "@calcom/ui";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
@@ -38,7 +38,7 @@ export default function Custom404() {
|
|||||||
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
|
setUrl(`${WEBSITE_URL}/signup?username=${username.replace("/", "")}`);
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
const isSuccessPage = router.asPath.startsWith("/success");
|
const isSuccessPage = router.asPath.startsWith("/booking");
|
||||||
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
|
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
|
||||||
const isSignup = router.asPath.startsWith("/signup");
|
const isSignup = router.asPath.startsWith("/signup");
|
||||||
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
|
const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com";
|
||||||
@@ -64,7 +64,7 @@ export default function Custom404() {
|
|||||||
<h1 className="font-cal mt-2 text-3xl font-extrabold text-gray-900">
|
<h1 className="font-cal mt-2 text-3xl font-extrabold text-gray-900">
|
||||||
{t("signup_requires")}
|
{t("signup_requires")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4">{t("signup_requires_description")}</p>
|
<p className="mt-4">{t("signup_requires_description", { companyName: COMPANY_NAME })}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
@@ -191,7 +191,11 @@ export default function Custom404() {
|
|||||||
</span>
|
</span>
|
||||||
) : isCalcom ? (
|
) : isCalcom ? (
|
||||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
<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>.
|
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+35
-16
@@ -1,30 +1,49 @@
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { Button } from "@calcom/ui";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button, Icon, showToast } from "@calcom/ui";
|
||||||
|
|
||||||
export default function Error500() {
|
export default function Error500() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen bg-gray-100">
|
||||||
<Head>
|
<Head>
|
||||||
<title>Something unexpected occurred | Cal.com</title>
|
<title>Something unexpected occurred | {APP_NAME}</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<div className="m-auto text-center">
|
<div className="rtl: m-auto rounded-md bg-white p-10 text-right ltr:text-left">
|
||||||
<h1 className="font-cal text-[250px] text-gray-900">
|
<h1 className="font-cal text-6xl text-black">500</h1>
|
||||||
5
|
<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">
|
||||||
// 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">
|
|
||||||
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
|
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
|
||||||
away for you.
|
away for you.
|
||||||
</p>
|
</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">
|
<Button color="secondary" href="javascript:history.back()" className="ml-2">
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useEmbedStyles,
|
useEmbedStyles,
|
||||||
useIsEmbed,
|
useIsEmbed,
|
||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
|
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import defaultEvents, {
|
import defaultEvents, {
|
||||||
@@ -26,9 +27,8 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { BadgeCheckIcon, EventTypeDescriptionLazy as EventTypeDescription, Icon } from "@calcom/ui";
|
import { BadgeCheckIcon, Icon } from "@calcom/ui";
|
||||||
|
|
||||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import { EmbedProps } from "@lib/withEmbedSsr";
|
import { EmbedProps } from "@lib/withEmbedSsr";
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
{eventTypes.map((type, index) => (
|
{eventTypes.map((type, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
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" />
|
<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 })}>
|
<Link href={getUsernameSlugLink({ users: props.users, slug: type.slug })}>
|
||||||
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
|
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
|
||||||
@@ -69,7 +69,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 self-center">
|
<div className="mt-1 self-center">
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
border="border-2 border-white"
|
border="border-2 border-white dark:border-darkgray-100"
|
||||||
truncateAfter={4}
|
truncateAfter={4}
|
||||||
className="flex flex-shrink-0"
|
className="flex flex-shrink-0"
|
||||||
size={10}
|
size={10}
|
||||||
@@ -92,7 +92,6 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
const query = { ...router.query };
|
const query = { ...router.query };
|
||||||
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
||||||
useExposePlanGlobally("PRO");
|
|
||||||
const nameOrUsername = user.name || user.username || "";
|
const nameOrUsername = user.name || user.username || "";
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
@@ -272,7 +271,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
theme: true,
|
theme: true,
|
||||||
plan: true,
|
|
||||||
away: true,
|
away: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
allowDynamicBooking: true,
|
allowDynamicBooking: true,
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { JSONObject } from "superjson/dist/types";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { privacyFilteredLocations, LocationObject } from "@calcom/app-store/locations";
|
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 { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { User } from "@calcom/prisma/client";
|
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 { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import { EmbedProps } from "@lib/withEmbedSsr";
|
import { EmbedProps } from "@lib/withEmbedSsr";
|
||||||
|
|
||||||
@@ -71,7 +72,6 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
|||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
away: true,
|
away: true,
|
||||||
plan: true,
|
|
||||||
name: true,
|
name: true,
|
||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
timeZone: 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",
|
"strikethrough",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!user || !user.eventTypes) return { notFound: true };
|
if (!user || !user.eventTypes.length) return { notFound: true };
|
||||||
|
|
||||||
const [eventType]: (typeof user.eventTypes[number] & {
|
const [eventType]: (typeof user.eventTypes[number] & {
|
||||||
users: Pick<User, "name" | "username" | "hideBranding" | "plan" | "timeZone">[];
|
users: Pick<User, "name" | "username" | "hideBranding" | "timeZone">[];
|
||||||
})[] = [
|
})[] = [
|
||||||
{
|
{
|
||||||
...user.eventTypes[0],
|
...user.eventTypes[0],
|
||||||
@@ -133,7 +138,6 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
hideBranding: user.hideBranding,
|
hideBranding: user.hideBranding,
|
||||||
plan: user.plan,
|
|
||||||
timeZone: user.timeZone,
|
timeZone: user.timeZone,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -150,6 +154,14 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
|||||||
locations: privacyFilteredLocations(locations),
|
locations: privacyFilteredLocations(locations),
|
||||||
descriptionAsSafeHTML: eventType.description ? md.render(eventType.description) : null,
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
@@ -167,6 +179,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
|||||||
away: user?.away,
|
away: user?.away,
|
||||||
isDynamic: false,
|
isDynamic: false,
|
||||||
trpcState: ssg.dehydrate(),
|
trpcState: ssg.dehydrate(),
|
||||||
|
isBrandingHidden: isBrandingHidden(user.hideBranding, hasActiveTeam),
|
||||||
},
|
},
|
||||||
revalidate: 10, // seconds
|
revalidate: 10, // seconds
|
||||||
};
|
};
|
||||||
@@ -212,7 +225,6 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
theme: true,
|
theme: true,
|
||||||
plan: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,7 +236,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
|||||||
|
|
||||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||||
const eventTypeObject = Object.assign({}, eventType, {
|
const eventTypeObject = Object.assign({}, eventType, {
|
||||||
metadata: (eventType.metadata || {}) as JSONObject,
|
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||||
locations: privacyFilteredLocations(locations),
|
locations: privacyFilteredLocations(locations),
|
||||||
users: users.map((user) => {
|
users: users.map((user) => {
|
||||||
@@ -232,7 +244,6 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
hideBranding: user.hideBranding,
|
hideBranding: user.hideBranding,
|
||||||
plan: user.plan,
|
|
||||||
timeZone: user.timeZone,
|
timeZone: user.timeZone,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -262,6 +273,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
|||||||
isDynamic: true,
|
isDynamic: true,
|
||||||
away: false,
|
away: false,
|
||||||
trpcState: ssg.dehydrate(),
|
trpcState: ssg.dehydrate(),
|
||||||
|
isBrandingHidden: false, // I think we should always show branding for dynamic groups - saves us checking every single user
|
||||||
},
|
},
|
||||||
revalidate: 10, // seconds
|
revalidate: 10, // seconds
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { bookEventTypeSelect } from "@calcom/prisma";
|
import { bookEventTypeSelect } from "@calcom/prisma";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||||
@@ -113,7 +114,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...eventTypeRaw,
|
...eventTypeRaw,
|
||||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||||
schedulingType: null,
|
schedulingType: null,
|
||||||
|
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
||||||
users: users.map((u) => ({
|
users: users.map((u) => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DefaultSeo } from "next-seo";
|
import { DefaultSeo } from "next-seo";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import Script from "next/script";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import "@calcom/embed-core/src/embed-iframe";
|
import "@calcom/embed-core/src/embed-iframe";
|
||||||
@@ -36,8 +37,11 @@ function MyApp(props: AppProps) {
|
|||||||
<AppProviders {...props}>
|
<AppProviders {...props}>
|
||||||
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
||||||
<I18nLanguageHandler />
|
<I18nLanguageHandler />
|
||||||
|
<Script
|
||||||
|
id="page-status"
|
||||||
|
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||||
|
/>
|
||||||
<Head>
|
<Head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }} />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||||
</Head>
|
</Head>
|
||||||
{getLayout(
|
{getLayout(
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
|
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
|
||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
type Props = Record<string, unknown> & DocumentProps;
|
type Props = Record<string, unknown> & DocumentProps;
|
||||||
|
|
||||||
function toRunBeforeReactOnClient() {
|
function toRunBeforeReactOnClient() {
|
||||||
const calEmbedMode =
|
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
|
||||||
location.search.includes("embed=") ||
|
/* Iframe Name */
|
||||||
/* Iframe Name */
|
window.name.includes("cal-embed");
|
||||||
window.name.includes("cal-embed");
|
|
||||||
|
|
||||||
window.isEmbed = () => {
|
window.isEmbed = () => {
|
||||||
// Once an embed mode always an embed mode
|
// Once an embed mode always an embed mode
|
||||||
@@ -70,7 +70,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
|
{/* 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.
|
Persist the embed mode in sessionStorage because query param might get lost during browsing.
|
||||||
*/}
|
*/}
|
||||||
<script
|
<Script
|
||||||
|
id="run-before-client"
|
||||||
|
strategy="beforeInteractive"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `(${toRunBeforeReactOnClient.toString()})()`,
|
__html: `(${toRunBeforeReactOnClient.toString()})()`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import path from "path";
|
|||||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||||
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
|
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
|
||||||
import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||||
import { ErrorCode, verifyPassword, isPasswordValid } from "@calcom/lib/auth";
|
import { ErrorCode, isPasswordValid, verifyPassword } from "@calcom/lib/auth";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||||
import rateLimit from "@calcom/lib/rateLimit";
|
import rateLimit from "@calcom/lib/rateLimit";
|
||||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
|
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
|
||||||
import { randomString } from "@lib/random";
|
import { randomString } from "@lib/random";
|
||||||
@@ -63,6 +64,11 @@ const providers: Provider[] = [
|
|||||||
password: true,
|
password: true,
|
||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
twoFactorSecret: true,
|
twoFactorSecret: true,
|
||||||
|
teams: {
|
||||||
|
include: {
|
||||||
|
team: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +122,14 @@ const providers: Provider[] = [
|
|||||||
intervalInMs: 60 * 1000, // 1 minute
|
intervalInMs: 60 * 1000, // 1 minute
|
||||||
});
|
});
|
||||||
await limiter.check(10, user.email); // 10 requests per 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?
|
// authentication success- but does it meet the minimum password requirements?
|
||||||
if (user.role === "ADMIN" && !isPasswordValid(credentials.password, false, true)) {
|
if (user.role === "ADMIN" && !isPasswordValid(credentials.password, false, true)) {
|
||||||
@@ -125,6 +139,7 @@ const providers: Provider[] = [
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: "USER",
|
role: "USER",
|
||||||
|
belongsToActiveTeam: hasActiveTeams,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +149,7 @@ const providers: Provider[] = [
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
belongsToActiveTeam: hasActiveTeams,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -204,9 +220,9 @@ if (true) {
|
|||||||
});
|
});
|
||||||
const emailTemplate = Handlebars.compile(emailFile);
|
const emailTemplate = Handlebars.compile(emailFile);
|
||||||
transporter.sendMail({
|
transporter.sendMail({
|
||||||
from: `${process.env.EMAIL_FROM}` || "Cal.com",
|
from: `${process.env.EMAIL_FROM}` || APP_NAME,
|
||||||
to: identifier,
|
to: identifier,
|
||||||
subject: "Your sign-in link for Cal.com",
|
subject: "Your sign-in link for " + APP_NAME,
|
||||||
html: emailTemplate({
|
html: emailTemplate({
|
||||||
base_url: WEBAPP_URL,
|
base_url: WEBAPP_URL,
|
||||||
signin_url: url,
|
signin_url: url,
|
||||||
@@ -272,6 +288,7 @@ export default NextAuth({
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
impersonatedByUID: user?.impersonatedByUID,
|
impersonatedByUID: user?.impersonatedByUID,
|
||||||
|
belongsToActiveTeam: user?.belongsToActiveTeam,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +324,7 @@ export default NextAuth({
|
|||||||
email: existingUser.email,
|
email: existingUser.email,
|
||||||
role: existingUser.role,
|
role: existingUser.role,
|
||||||
impersonatedByUID: token.impersonatedByUID as number,
|
impersonatedByUID: token.impersonatedByUID as number,
|
||||||
|
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +342,7 @@ export default NextAuth({
|
|||||||
username: token.username as string,
|
username: token.username as string,
|
||||||
role: token.role as UserPermissionRole,
|
role: token.role as UserPermissionRole,
|
||||||
impersonatedByUID: token.impersonatedByUID as number,
|
impersonatedByUID: token.impersonatedByUID as number,
|
||||||
|
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return calendsoSession;
|
return calendsoSession;
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ async function handler(req: NextApiRequest) {
|
|||||||
name: parsedQuery.data.full_name,
|
name: parsedQuery.data.full_name,
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
locale: "en", // TODO: We should revisit this
|
locale: "en", // TODO: We should revisit this
|
||||||
plan: "PRO",
|
|
||||||
identityProvider: IdentityProvider.CAL,
|
identityProvider: IdentityProvider.CAL,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { defaultResponder } from "@calcom/lib/server";
|
|||||||
|
|
||||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||||
const session = await getSession({ req });
|
const session = await getSession({ req });
|
||||||
/* To mimic API behavior */
|
/* To mimic API behavior and comply with types */
|
||||||
req.userId = session?.user?.id;
|
req.userId = session?.user?.id || -1;
|
||||||
const booking = await handleNewBooking(req);
|
const booking = await handleNewBooking(req);
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { defaultResponder, defaultHandler } from "@calcom/lib/server";
|
|||||||
|
|
||||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||||
const session = await getSession({ req });
|
const session = await getSession({ req });
|
||||||
/* To mimic API behavior */
|
/* To mimic API behavior and comply with types */
|
||||||
req.userId = session?.user?.id;
|
req.userId = session?.user?.id || -1;
|
||||||
return await handleCancelBooking(req);
|
return await handleCancelBooking(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
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) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||||
if (process.env.CRON_API_KEY !== apiKey) {
|
if (process.env.CRON_API_KEY !== apiKey) {
|
||||||
@@ -18,35 +13,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO:
|
* TODO:
|
||||||
* We should add and extra check for non-paying customers in Stripe so we can
|
* Remove this endpoint
|
||||||
* downgrade them here.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -13,10 +13,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
title: "30min between Pro Example and pro@example.com",
|
title: "30min between Pro Example and pro@example.com",
|
||||||
description: null,
|
description: null,
|
||||||
additionalNotes: "asdasdas",
|
additionalNotes: "asdasdas",
|
||||||
customInputs: {
|
|
||||||
"Custom input 01": "sadasdasdsadasd",
|
|
||||||
"Custom input 02": "asdasdasd",
|
|
||||||
},
|
|
||||||
startTime: "2022-06-03T09:00:00-06:00",
|
startTime: "2022-06-03T09:00:00-06:00",
|
||||||
endTime: "2022-06-03T09:30:00-06:00",
|
endTime: "2022-06-03T09:30:00-06:00",
|
||||||
organizer: {
|
organizer: {
|
||||||
@@ -32,26 +28,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
timeZone: "America/Chihuahua",
|
timeZone: "America/Chihuahua",
|
||||||
language,
|
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",
|
location: "Zoom video",
|
||||||
destinationCalendar: null,
|
destinationCalendar: null,
|
||||||
hideCalendarNotes: false,
|
hideCalendarNotes: false,
|
||||||
uid: "bwPWLpjYrx4rZf6MCZdKgE",
|
uid: "xxyPr4cg2xx4XoS2KeMEQy",
|
||||||
metadata: {},
|
metadata: {},
|
||||||
cancellationReason: "It got late",
|
|
||||||
paymentInfo: { id: "pi_12312", link: "https://cal.com", reason: "no reason" },
|
|
||||||
recurringEvent: null,
|
recurringEvent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,7 +42,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
res.setHeader("Content-Type", "text/html");
|
res.setHeader("Content-Type", "text/html");
|
||||||
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
|
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
|
||||||
res.write(
|
res.write(
|
||||||
renderEmail("AttendeeScheduledEmail", {
|
renderEmail("OrganizerRequestEmail", {
|
||||||
attendee: evt.attendees[0],
|
attendee: evt.attendees[0],
|
||||||
calEvent: evt,
|
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,
|
interFontMedium,
|
||||||
]);
|
]);
|
||||||
const ogConfig = {
|
const ogConfig = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
fonts: [
|
fonts: [
|
||||||
{ name: "inter", data: interFontData, weight: 400 },
|
{ name: "inter", data: interFontData, weight: 400 },
|
||||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||||
@@ -71,7 +73,7 @@ export default async function handler(req: NextApiRequest) {
|
|||||||
meetingImage: searchParams.get("meetingImage"),
|
meetingImage: searchParams.get("meetingImage"),
|
||||||
imageType,
|
imageType,
|
||||||
});
|
});
|
||||||
return new ImageResponse(
|
const img = new ImageResponse(
|
||||||
(
|
(
|
||||||
<Meeting
|
<Meeting
|
||||||
title={title}
|
title={title}
|
||||||
@@ -80,7 +82,9 @@ export default async function handler(req: NextApiRequest) {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
ogConfig
|
ogConfig
|
||||||
);
|
) as { body: Buffer };
|
||||||
|
|
||||||
|
return new Response(img.body, { status: 200 });
|
||||||
}
|
}
|
||||||
case "app": {
|
case "app": {
|
||||||
const { name, description, slug } = appSchema.parse({
|
const { name, description, slug } = appSchema.parse({
|
||||||
@@ -89,7 +93,11 @@ export default async function handler(req: NextApiRequest) {
|
|||||||
slug: searchParams.get("slug"),
|
slug: searchParams.get("slug"),
|
||||||
imageType,
|
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": {
|
case "generic": {
|
||||||
@@ -99,7 +107,11 @@ export default async function handler(req: NextApiRequest) {
|
|||||||
imageType,
|
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:
|
default:
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
select: {
|
select: {
|
||||||
username: true,
|
username: true,
|
||||||
id: true,
|
id: true,
|
||||||
plan: true,
|
|
||||||
createdDate: true,
|
createdDate: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -81,7 +80,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US")
|
? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US")
|
||||||
: "No info"
|
: "No info"
|
||||||
}</li>
|
}</li>
|
||||||
<li><b>Plan:</b> ${user.plan}</li>
|
|
||||||
<li><b>Account created:</b> ${new Date(user.createdDate).toLocaleDateString("en-US")}</li>
|
<li><b>Account created:</b> ${new Date(user.createdDate).toLocaleDateString("en-US")}</li>
|
||||||
</ul>
|
</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 crypto from "crypto";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
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 { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { defaultAvatarSrc } from "@lib/profile";
|
import { defaultAvatarSrc } from "@lib/profile";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
const querySchema = z
|
||||||
// const username = req.url?.substring(1, req.url.lastIndexOf("/"));
|
.object({
|
||||||
const username = req.query.username as string;
|
username: z.string(),
|
||||||
const teamname = req.query.teamname as string;
|
teamname: z.string(),
|
||||||
let identity;
|
})
|
||||||
let linksToThisRoute = false;
|
.partial();
|
||||||
|
|
||||||
|
async function getIdentityData(req: NextApiRequest) {
|
||||||
|
const { username, teamname } = querySchema.parse(req.query);
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: { username },
|
||||||
username: username,
|
select: { avatar: true, email: true },
|
||||||
},
|
|
||||||
select: {
|
|
||||||
avatar: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
identity = {
|
return {
|
||||||
name: username,
|
name: username,
|
||||||
email: user?.email,
|
email: user?.email,
|
||||||
avatar: user?.avatar,
|
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`;
|
|
||||||
}
|
}
|
||||||
|
if (teamname) {
|
||||||
const emailMd5 = crypto
|
const team = await prisma.team.findUnique({
|
||||||
.createHash("md5")
|
where: { slug: teamname },
|
||||||
.update((identity?.email as string) || "guest@example.com")
|
select: { logo: true },
|
||||||
.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,
|
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
res.end();
|
name: teamname,
|
||||||
} else if (!img.includes("data:image")) {
|
email: null,
|
||||||
res.writeHead(302, {
|
avatar: team?.logo || getPlaceholderAvatar(null, teamname),
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
export const getStaticPaths = async () => {
|
||||||
const appStore = await getAppRegistry();
|
const paths = Object.keys(AppCategories);
|
||||||
const paths = appStore.reduce((categories, app) => {
|
|
||||||
if (!categories.includes(app.category)) {
|
|
||||||
categories.push(app.category);
|
|
||||||
}
|
|
||||||
return categories;
|
|
||||||
}, [] as string[]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paths: paths.map((category) => ({ params: { category } })),
|
paths: paths.map((category) => ({ params: { category } })),
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export default function Apps({ categories }: InferGetStaticPropsType<typeof getS
|
|||||||
export const getStaticProps = async () => {
|
export const getStaticProps = async () => {
|
||||||
const appStore = await getAppRegistry();
|
const appStore = await getAppRegistry();
|
||||||
const categories = appStore.reduce((c, app) => {
|
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;
|
return c;
|
||||||
}, {} as Record<string, number>);
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,65 @@
|
|||||||
import { InferGetStaticPropsType, NextPageContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
import { ChangeEventHandler, useState } from "react";
|
||||||
|
|
||||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
import { getSession } from "@calcom/lib/auth";
|
import { getSession } from "@calcom/lib/auth";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { AppCategories } from "@calcom/prisma/client";
|
import type { AppCategories } from "@calcom/prisma/client";
|
||||||
import { AllApps, AppsLayout, AppStoreCategories, TrendingAppsSlider } from "@calcom/ui";
|
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||||
|
import { AllApps, AppsLayout, AppStoreCategories, Icon, TextField, TrendingAppsSlider } from "@calcom/ui";
|
||||||
|
|
||||||
import { ssgInit } from "@server/lib/ssg";
|
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 { t } = useLocale();
|
||||||
|
const [searchText, setSearchText] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppsLayout isPublic heading={t("app_store")} subtitle={t("app_store_description")}>
|
<AppsLayout
|
||||||
<AppStoreCategories categories={categories} />
|
isPublic
|
||||||
<TrendingAppsSlider items={appStore} />
|
heading={t("app_store")}
|
||||||
<AllApps apps={appStore} />
|
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>
|
</AppsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = async (context: NextPageContext) => {
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
const ssg = await ssgInit(context);
|
const ssg = await ssgInit(context);
|
||||||
|
|
||||||
const session = await getSession(context);
|
const session = await getSession(context);
|
||||||
@@ -43,7 +82,6 @@ export const getServerSideProps = async (context: NextPageContext) => {
|
|||||||
}, {} as Record<string, number>);
|
}, {} as Record<string, number>);
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: ssg.dehydrate(),
|
|
||||||
categories: Object.entries(categories)
|
categories: Object.entries(categories)
|
||||||
.map(([name, count]): { name: AppCategories; count: number } => ({
|
.map(([name, count]): { name: AppCategories; count: number } => ({
|
||||||
name: name as AppCategories,
|
name: name as AppCategories,
|
||||||
@@ -53,6 +91,7 @@ export const getServerSideProps = async (context: NextPageContext) => {
|
|||||||
return b.count - a.count;
|
return b.count - a.count;
|
||||||
}),
|
}),
|
||||||
appStore,
|
appStore,
|
||||||
|
trpcState: ssg.dehydrate(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4,6 +4,7 @@ import z from "zod";
|
|||||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||||
|
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { App } from "@calcom/types/App";
|
import { App } from "@calcom/types/App";
|
||||||
@@ -11,7 +12,6 @@ import { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProp
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
DisconnectIntegration,
|
|
||||||
EmptyScreen,
|
EmptyScreen,
|
||||||
Icon,
|
Icon,
|
||||||
InstalledAppsLayout,
|
InstalledAppsLayout,
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { useState } from "react";
|
|||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { FaGoogle } from "react-icons/fa";
|
import { FaGoogle } from "react-icons/fa";
|
||||||
|
|
||||||
|
import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
|
||||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Alert, Button, EmailField, Icon, PasswordField, SAMLLogin } from "@calcom/ui";
|
import { Alert, Button, EmailField, Icon, PasswordField } from "@calcom/ui";
|
||||||
|
|
||||||
import { ErrorCode, getSession } from "@lib/auth";
|
import { ErrorCode, getSession } from "@lib/auth";
|
||||||
import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants";
|
import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants";
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { UserPermissionRole } from "@prisma/client";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
||||||
|
import { getSession } from "@calcom/lib/auth";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||||
|
import { WizardForm } from "@calcom/ui";
|
||||||
|
|
||||||
|
import SetupFormStep1 from "./steps/SetupFormStep1";
|
||||||
|
import StepDone from "./steps/StepDone";
|
||||||
|
|
||||||
|
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [isLoadingStep1, setIsLoadingStep1] = useState(false);
|
||||||
|
const shouldDisable = props.userCount !== 0;
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: t("administrator_user"),
|
||||||
|
description: t("lets_create_first_administrator_user"),
|
||||||
|
content: shouldDisable ? <StepDone /> : <SetupFormStep1 setIsLoading={setIsLoadingStep1} />,
|
||||||
|
isLoading: isLoadingStep1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("enable_apps"),
|
||||||
|
description: t("enable_apps_description"),
|
||||||
|
content: <AdminAppsList baseURL="/auth/setup?step=2" />,
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main className="flex items-center bg-gray-100 print:h-full">
|
||||||
|
<WizardForm
|
||||||
|
href="/auth/setup"
|
||||||
|
steps={steps}
|
||||||
|
nextLabel={t("next_step_text")}
|
||||||
|
finishLabel={t("finish")}
|
||||||
|
prevLabel={t("prev_step")}
|
||||||
|
stepLabel={(currentStep, maxSteps) => t("current_step_of_total", { currentStep, maxSteps })}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const userCount = await prisma.user.count();
|
||||||
|
const { req } = context;
|
||||||
|
const session = await getSession({ req });
|
||||||
|
|
||||||
|
if (session?.user.role && session?.user.role !== UserPermissionRole.ADMIN) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: `/404`,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
userCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
+12
-59
@@ -1,34 +1,14 @@
|
|||||||
import { CheckIcon } from "@heroicons/react/solid";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
|
||||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { isPasswordValid } from "@calcom/lib/auth";
|
import { isPasswordValid } from "@calcom/lib/auth";
|
||||||
|
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import { EmailField, Label, PasswordField, TextField } from "@calcom/ui";
|
||||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
|
||||||
import { EmailField, Label, PasswordField, TextField, WizardForm } from "@calcom/ui";
|
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
|
||||||
|
|
||||||
const StepDone = () => {
|
|
||||||
const { t } = useLocale();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
|
|
||||||
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
|
|
||||||
<CheckIcon className="inline-block h-10 w-10 text-white dark:bg-white dark:text-gray-600" />
|
|
||||||
</div>
|
|
||||||
<div className="max-w-[420px] text-center">
|
|
||||||
<h2 className="mt-6 mb-1 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -83,13 +63,19 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
router.replace(`/auth/login?email=${data.email_address.toLowerCase()}`);
|
await signIn("credentials", {
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: "/",
|
||||||
|
email: data.email_address.toLowerCase(),
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
router.replace(`/auth/setup?step=2&category=calendar`);
|
||||||
} else {
|
} else {
|
||||||
router.replace("/auth/setup");
|
router.replace("/auth/setup");
|
||||||
}
|
}
|
||||||
}, onError);
|
}, onError);
|
||||||
|
|
||||||
const longWebsiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL.length > 30;
|
const longWebsiteUrl = WEBSITE_URL.length > 30;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...formMethods}>
|
<FormProvider {...formMethods}>
|
||||||
@@ -201,37 +187,4 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
export default SetupFormStep1;
|
||||||
const { t } = useLocale();
|
|
||||||
const [isLoadingStep1, setIsLoadingStep1] = useState(false);
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
title: t("administrator_user"),
|
|
||||||
description: t("lets_create_first_administrator_user"),
|
|
||||||
content: props.userCount !== 0 ? <StepDone /> : <SetupFormStep1 setIsLoading={setIsLoadingStep1} />,
|
|
||||||
enabled: props.userCount === 0, // to check if the wizard should show buttons to navigate through more steps
|
|
||||||
isLoading: isLoadingStep1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<main className="flex h-screen items-center bg-gray-100 print:h-full">
|
|
||||||
<WizardForm href="/auth/setup" steps={steps} containerClassname="max-w-sm" />
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
|
||||||
const ssr = await ssrInit(context);
|
|
||||||
const userCount = await prisma.user.count();
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
trpcState: ssr.dehydrate(),
|
|
||||||
userCount,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Icon } from "@calcom/ui";
|
||||||
|
|
||||||
|
const StepDone = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
id="wizard-step-1"
|
||||||
|
name="wizard-step-1"
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
router.replace(`/auth/setup?step=2&category=calendar`);
|
||||||
|
}}>
|
||||||
|
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
|
||||||
|
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
|
||||||
|
<Icon.FiCheck className="inline-block h-10 w-10 text-white dark:bg-white dark:text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[420px] text-center">
|
||||||
|
<h2 className="mt-6 mb-1 text-lg font-medium dark:text-gray-300">{t("all_done")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepDone;
|
||||||
@@ -3,7 +3,7 @@ import { signIn } from "next-auth/react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { getPremiumPlanPrice } from "@calcom/app-store/stripepayment/lib/utils";
|
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||||
import {
|
import {
|
||||||
hostedCal,
|
hostedCal,
|
||||||
@@ -161,7 +161,7 @@ const getStripePremiumUsernameUrl = async ({
|
|||||||
customer: customer.id,
|
customer: customer.id,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: getPremiumPlanPrice(),
|
price: getPremiumMonthlyPlanPriceId(),
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { GetStaticPaths, GetStaticProps } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { useEffect } from "react";
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
||||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||||
import { availabilityAsString } from "@calcom/lib/availability";
|
import { availabilityAsString } from "@calcom/lib/availability";
|
||||||
|
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||||
import type { Schedule as ScheduleType } from "@calcom/types/schedule";
|
import type { Schedule as ScheduleType, TimeRange, WorkingHours } from "@calcom/types/schedule";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Form,
|
Form,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
SkeletonText,
|
SkeletonText,
|
||||||
Switch,
|
Switch,
|
||||||
TimezoneSelect,
|
TimezoneSelect,
|
||||||
|
Tooltip,
|
||||||
VerticalDivider,
|
VerticalDivider,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ import { HttpError } from "@lib/core/http/error";
|
|||||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||||
import EditableHeading from "@components/ui/EditableHeading";
|
import EditableHeading from "@components/ui/EditableHeading";
|
||||||
|
|
||||||
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
schedule: stringOrNumber,
|
schedule: stringOrNumber,
|
||||||
});
|
});
|
||||||
@@ -36,31 +40,59 @@ const querySchema = z.object({
|
|||||||
type AvailabilityFormValues = {
|
type AvailabilityFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
schedule: ScheduleType;
|
schedule: ScheduleType;
|
||||||
|
dateOverrides: { ranges: TimeRange[] }[];
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||||
|
const { remove, append, update, fields } = useFieldArray<AvailabilityFormValues, "dateOverrides">({
|
||||||
|
name: "dateOverrides",
|
||||||
|
});
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-5 sm:p-6">
|
||||||
|
<h3 className="font-medium leading-6 text-gray-900">
|
||||||
|
{t("date_overrides")}{" "}
|
||||||
|
<Tooltip content={t("date_overrides_info")}>
|
||||||
|
<span className="inline-block">
|
||||||
|
<Icon.FiInfo />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{t("date_overrides_subtitle")}</p>
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
<DateOverrideList
|
||||||
|
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
|
||||||
|
remove={remove}
|
||||||
|
update={update}
|
||||||
|
items={fields}
|
||||||
|
workingHours={workingHours}
|
||||||
|
/>
|
||||||
|
<DateOverrideInputDialog
|
||||||
|
workingHours={workingHours}
|
||||||
|
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
|
||||||
|
onChange={(ranges) => append({ ranges })}
|
||||||
|
Trigger={
|
||||||
|
<Button color="secondary" StartIcon={Icon.FiPlus} data-testid="add-override">
|
||||||
|
Add an override
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Availability({ schedule }: { schedule: number }) {
|
export default function Availability({ schedule }: { schedule: number }) {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const me = useMeQuery();
|
const me = useMeQuery();
|
||||||
const { timeFormat } = me.data || { timeFormat: null };
|
const { timeFormat } = me.data || { timeFormat: null };
|
||||||
const { data, isLoading } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId: schedule });
|
const { data, isLoading } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId: schedule });
|
||||||
|
const { data: defaultValues } = trpc.viewer.availability.defaultValues.useQuery({ scheduleId: schedule });
|
||||||
const form = useForm<AvailabilityFormValues>();
|
const form = useForm<AvailabilityFormValues>({ defaultValues });
|
||||||
const { control, reset } = form;
|
const { control } = form;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading && data) {
|
|
||||||
reset({
|
|
||||||
name: data?.schedule?.name,
|
|
||||||
schedule: data.availability,
|
|
||||||
timeZone: data.timeZone,
|
|
||||||
isDefault: data.isDefault,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data, isLoading, reset]);
|
|
||||||
|
|
||||||
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
|
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
|
||||||
onSuccess: async ({ prevDefaultId, currentDefaultId, ...data }) => {
|
onSuccess: async ({ prevDefaultId, currentDefaultId, ...data }) => {
|
||||||
if (prevDefaultId && currentDefaultId) {
|
if (prevDefaultId && currentDefaultId) {
|
||||||
@@ -71,7 +103,7 @@ export default function Availability({ schedule }: { schedule: number }) {
|
|||||||
utils.viewer.availability.schedule.get.refetch({ scheduleId: prevDefaultId });
|
utils.viewer.availability.schedule.get.refetch({ scheduleId: prevDefaultId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
utils.viewer.availability.schedule.get.setData({ scheduleId: data.schedule.id }, data);
|
utils.viewer.availability.schedule.get.invalidate({ scheduleId: data.schedule.id });
|
||||||
utils.viewer.availability.list.invalidate();
|
utils.viewer.availability.list.invalidate();
|
||||||
showToast(
|
showToast(
|
||||||
t("availability_updated_successfully", {
|
t("availability_updated_successfully", {
|
||||||
@@ -91,22 +123,26 @@ export default function Availability({ schedule }: { schedule: number }) {
|
|||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
backPath="/availability"
|
backPath="/availability"
|
||||||
title={data?.schedule.name && data.schedule.name + " | " + t("availability")}
|
title={data?.schedule.name ? data.schedule.name + " | " + t("availability") : t("availability")}
|
||||||
heading={
|
heading={
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => <EditableHeading isReady={!isLoading} {...field} />}
|
render={({ field }) => (
|
||||||
|
<EditableHeading isReady={!isLoading} {...field} data-testid="availablity-title" />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
subtitle={
|
subtitle={
|
||||||
data ? (
|
data ? (
|
||||||
data.schedule.availability.map((availability) => (
|
data.schedule.availability
|
||||||
<span key={availability.id}>
|
.filter((availability) => !!availability.days.length)
|
||||||
{availabilityAsString(availability, { locale: i18n.language, hour12: timeFormat === 12 })}
|
.map((availability) => (
|
||||||
<br />
|
<span key={availability.id}>
|
||||||
</span>
|
{availabilityAsString(availability, { locale: i18n.language, hour12: timeFormat === 12 })}
|
||||||
))
|
<br />
|
||||||
|
</span>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<SkeletonText className="h-4 w-48" />
|
<SkeletonText className="h-4 w-48" />
|
||||||
)
|
)
|
||||||
@@ -138,93 +174,88 @@ export default function Availability({ schedule }: { schedule: number }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<div className="flex items-baseline sm:mt-0">
|
<div className="w-full">
|
||||||
{/* TODO: Find a better way to guarantee alignment, but for now this'll do. */}
|
<Form
|
||||||
<Icon.FiArrowLeft className=" mr-3 text-transparent hover:cursor-pointer" />
|
form={form}
|
||||||
<div className="w-full">
|
id="availability-form"
|
||||||
<Form
|
handleSubmit={async ({ dateOverrides, ...values }) => {
|
||||||
form={form}
|
updateMutation.mutate({
|
||||||
id="availability-form"
|
scheduleId: schedule,
|
||||||
handleSubmit={async (values) => {
|
dateOverrides: dateOverrides.flatMap((override) => override.ranges),
|
||||||
updateMutation.mutate({
|
...values,
|
||||||
scheduleId: schedule,
|
});
|
||||||
...values,
|
}}
|
||||||
});
|
className="flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
|
||||||
}}
|
<div className="flex-1 divide-y divide-neutral-200 rounded-md border">
|
||||||
className="-mx-4 flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
|
<div className=" py-5 sm:p-6">
|
||||||
<div className="flex-1">
|
<h3 className="mb-2 px-5 text-base font-medium leading-6 text-gray-900 sm:pl-0">
|
||||||
<div className="rounded-md border-gray-200 bg-white py-5 pr-4 sm:border sm:p-6">
|
{t("change_start_end")}
|
||||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">
|
</h3>
|
||||||
{t("change_start_end")}
|
{typeof me.data?.weekStart === "string" && (
|
||||||
</h3>
|
<Schedule
|
||||||
{typeof me.data?.weekStart === "string" && (
|
control={control}
|
||||||
<Schedule
|
name="schedule"
|
||||||
control={control}
|
weekStart={
|
||||||
name="schedule"
|
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
||||||
weekStart={
|
me.data?.weekStart
|
||||||
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||||
me.data?.weekStart
|
}
|
||||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
|
/>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
{data?.workingHours && <DateOverride workingHours={data.workingHours} />}
|
||||||
<div className="xl:max-w-80 mt-4 w-full pr-4 sm:p-0">
|
</div>
|
||||||
<div>
|
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
<div className="xl:max-w-80 mt-4 w-full pr-4 sm:p-0">
|
||||||
{t("timezone")}
|
<div>
|
||||||
</label>
|
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||||
<Controller
|
{t("timezone")}
|
||||||
name="timeZone"
|
</label>
|
||||||
render={({ field: { onChange, value } }) =>
|
<Controller
|
||||||
value ? (
|
name="timeZone"
|
||||||
<TimezoneSelect
|
render={({ field: { onChange, value } }) =>
|
||||||
value={value}
|
value ? (
|
||||||
className="focus:border-brand mt-1 block w-72 rounded-md border-gray-300 text-sm"
|
<TimezoneSelect
|
||||||
onChange={(timezone) => onChange(timezone.value)}
|
value={value}
|
||||||
/>
|
className="focus:border-brand mt-1 block w-72 rounded-md border-gray-300 text-sm"
|
||||||
) : (
|
onChange={(timezone) => onChange(timezone.value)}
|
||||||
<SelectSkeletonLoader className="w-72" />
|
/>
|
||||||
)
|
) : (
|
||||||
}
|
<SelectSkeletonLoader className="w-72" />
|
||||||
/>
|
)
|
||||||
</div>
|
}
|
||||||
<hr className="my-8" />
|
/>
|
||||||
<div className="rounded-md">
|
</div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
|
<hr className="my-8" />
|
||||||
<div className="mt-3 flex">
|
<div className="rounded-md">
|
||||||
<Button href="/availability/troubleshoot" color="secondary">
|
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
|
||||||
{t("launch_troubleshooter")}
|
<div className="mt-3 flex">
|
||||||
</Button>
|
<Button href="/availability/troubleshoot" color="secondary">
|
||||||
</div>
|
{t("launch_troubleshooter")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</div>
|
||||||
</div>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = (ctx) => {
|
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||||
const params = querySchema.safeParse(ctx.params);
|
const params = querySchema.safeParse(ctx.params);
|
||||||
|
const ssr = await ssrInit(ctx);
|
||||||
|
|
||||||
if (!params.success) return { notFound: true };
|
if (!params.success) return { notFound: true };
|
||||||
|
|
||||||
|
const scheduleId = params.data.schedule;
|
||||||
|
await ssr.viewer.availability.schedule.get.fetch({ scheduleId });
|
||||||
|
await ssr.viewer.availability.defaultValues.fetch({ scheduleId });
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
schedule: params.data.schedule,
|
schedule: scheduleId,
|
||||||
|
trpcState: ssr.dehydrate(),
|
||||||
},
|
},
|
||||||
revalidate: 10, // seconds
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = () => {
|
|
||||||
return {
|
|
||||||
paths: [],
|
|
||||||
fallback: "blocking",
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -10,6 +11,8 @@ import { HttpError } from "@lib/core/http/error";
|
|||||||
|
|
||||||
import SkeletonLoader from "@components/availability/SkeletonLoader";
|
import SkeletonLoader from "@components/availability/SkeletonLoader";
|
||||||
|
|
||||||
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
|
||||||
export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availability"]["list"]) {
|
export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availability"]["list"]) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
@@ -45,6 +48,24 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
|
||||||
|
onSuccess: async ({ schedule }) => {
|
||||||
|
await utils.viewer.availability.list.invalidate();
|
||||||
|
showToast(
|
||||||
|
t("availability_updated_successfully", {
|
||||||
|
scheduleName: schedule.name,
|
||||||
|
}),
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const message = `${err.statusCode}: ${err.message}`;
|
||||||
|
showToast(message, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Adds smooth delete button - item fades and old item slides into place
|
// Adds smooth delete button - item fades and old item slides into place
|
||||||
|
|
||||||
const [animationParentRef] = useAutoAnimate<HTMLUListElement>();
|
const [animationParentRef] = useAutoAnimate<HTMLUListElement>();
|
||||||
@@ -71,6 +92,7 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
|
|||||||
}}
|
}}
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
|
updateDefault={updateMutation.mutate}
|
||||||
deleteFunction={deleteMutation.mutate}
|
deleteFunction={deleteMutation.mutate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -93,3 +115,13 @@ export default function AvailabilityPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const ssr = await ssrInit(context);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: ssr.dehydrate(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { Shell, SkeletonText } from "@calcom/ui";
|
import { Shell, SkeletonText } from "@calcom/ui";
|
||||||
|
|
||||||
|
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||||
|
|
||||||
type User = RouterOutputs["viewer"]["me"];
|
type User = RouterOutputs["viewer"]["me"];
|
||||||
|
|
||||||
export interface IBusySlot {
|
export interface IBusySlot {
|
||||||
@@ -16,7 +16,9 @@ export interface IBusySlot {
|
|||||||
|
|
||||||
const AvailabilityView = ({ user }: { user: User }) => {
|
const AvailabilityView = ({ user }: { user: User }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
const { date, setQuery: setSelectedDate } = useRouterQuery("date");
|
||||||
|
const selectedDate = dayjs(date);
|
||||||
|
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
|
||||||
|
|
||||||
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
|
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
|
||||||
{
|
{
|
||||||
@@ -30,6 +32,17 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const overrides =
|
||||||
|
data?.dateOverrides.reduce((acc, override) => {
|
||||||
|
if (
|
||||||
|
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
|
||||||
|
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
|
||||||
|
)
|
||||||
|
return acc;
|
||||||
|
acc.push({ ...override, source: "Date override" });
|
||||||
|
return acc;
|
||||||
|
}, [] as IBusySlot[]) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl overflow-hidden rounded-md bg-white shadow">
|
<div className="max-w-xl overflow-hidden rounded-md bg-white shadow">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="px-4 py-5 sm:p-6">
|
||||||
@@ -37,9 +50,9 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="inline h-8 border-none p-0"
|
className="inline h-8 border-none p-0"
|
||||||
defaultValue={selectedDate.format("YYYY-MM-DD")}
|
defaultValue={formattedSelectedDate}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.value) setSelectedDate(dayjs(e.target.value));
|
if (e.target.value) setSelectedDate(e.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
|
<small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
|
||||||
@@ -49,39 +62,47 @@ const AvailabilityView = ({ user }: { user: User }) => {
|
|||||||
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
|
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{(() => {
|
||||||
<>
|
if (isLoading)
|
||||||
<SkeletonText className="block h-16 w-full" />
|
return (
|
||||||
<SkeletonText className="block h-16 w-full" />
|
<>
|
||||||
</>
|
<SkeletonText className="block h-16 w-full" />
|
||||||
) : data && data.busy.length > 0 ? (
|
<SkeletonText className="block h-16 w-full" />
|
||||||
data.busy
|
</>
|
||||||
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
|
);
|
||||||
.map((slot: IBusySlot) => (
|
|
||||||
<div
|
if (data && (data.busy.length > 0 || overrides.length > 0))
|
||||||
key={dayjs(slot.start).format("HH:mm")}
|
return [...data.busy, ...overrides]
|
||||||
className="overflow-hidden rounded-md bg-neutral-100">
|
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
|
||||||
<div className="px-4 py-5 text-black sm:p-6">
|
.map((slot: IBusySlot) => (
|
||||||
{t("calendar_shows_busy_between")}{" "}
|
<div
|
||||||
<span className="font-medium text-neutral-800" title={dayjs(slot.start).format("HH:mm")}>
|
key={dayjs(slot.start).format("HH:mm")}
|
||||||
{dayjs(slot.start).format("HH:mm")}
|
className="overflow-hidden rounded-md bg-neutral-100"
|
||||||
</span>{" "}
|
data-testid="troubleshooter-busy-time">
|
||||||
{t("and")}{" "}
|
<div className="px-4 py-5 text-black sm:p-6">
|
||||||
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}>
|
{t("calendar_shows_busy_between")}{" "}
|
||||||
{dayjs(slot.end).format("HH:mm")}
|
<span
|
||||||
</span>{" "}
|
className="font-medium text-neutral-800"
|
||||||
{t("on")} {dayjs(slot.start).format("D")}{" "}
|
title={dayjs(slot.start).format("HH:mm")}>
|
||||||
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
|
{dayjs(slot.start).format("HH:mm")}
|
||||||
{slot.title && ` - (${slot.title})`}
|
</span>{" "}
|
||||||
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
|
{t("and")}{" "}
|
||||||
|
<span className="font-medium text-neutral-800" title={dayjs(slot.end).format("HH:mm")}>
|
||||||
|
{dayjs(slot.end).format("HH:mm")}
|
||||||
|
</span>{" "}
|
||||||
|
{t("on")} {dayjs(slot.start).format("D")}{" "}
|
||||||
|
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
|
||||||
|
{slot.title && ` - (${slot.title})`}
|
||||||
|
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
));
|
||||||
))
|
return (
|
||||||
) : (
|
<div className="overflow-hidden rounded-md bg-neutral-100">
|
||||||
<div className="overflow-hidden rounded-md bg-neutral-100">
|
<div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div>
|
||||||
<div className="px-4 py-5 text-black sm:p-6">{t("calendar_no_busy_slots")}</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
|
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
|
||||||
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
|
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "@calcom/embed-core/embed-iframe";
|
} from "@calcom/embed-core/embed-iframe";
|
||||||
import { parseRecurringEvent } from "@calcom/lib";
|
import { parseRecurringEvent } from "@calcom/lib";
|
||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { formatTime } from "@calcom/lib/date-fns";
|
import { formatTime } from "@calcom/lib/date-fns";
|
||||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -33,13 +34,14 @@ import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/t
|
|||||||
import { localStorage } from "@calcom/lib/webstorage";
|
import { localStorage } from "@calcom/lib/webstorage";
|
||||||
import prisma, { baseUserSelect } from "@calcom/prisma";
|
import prisma, { baseUserSelect } from "@calcom/prisma";
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import { Prisma } from "@calcom/prisma/client";
|
||||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { Button, EmailInput, Icon } from "@calcom/ui";
|
import { Button, EmailInput, Icon } from "@calcom/ui";
|
||||||
|
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import CancelBooking from "@components/booking/CancelBooking";
|
import CancelBooking from "@components/booking/CancelBooking";
|
||||||
|
import EventReservationSchema from "@components/schemas/EventReservationSchema";
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
@@ -55,26 +57,41 @@ function redirectToExternalUrl(url: string) {
|
|||||||
function RedirectionToast({ url }: { url: string }) {
|
function RedirectionToast({ url }: { url: string }) {
|
||||||
const [timeRemaining, setTimeRemaining] = useState(10);
|
const [timeRemaining, setTimeRemaining] = useState(10);
|
||||||
const [isToastVisible, setIsToastVisible] = useState(true);
|
const [isToastVisible, setIsToastVisible] = useState(true);
|
||||||
const parsedSuccessUrl = new URL(document.URL);
|
|
||||||
const parsedExternalUrl = new URL(url);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
/* @ts-ignore */ //https://stackoverflow.com/questions/49218765/typescript-and-iterator-type-iterableiteratort-is-not-an-array-type
|
|
||||||
for (const [name, value] of parsedExternalUrl.searchParams.entries()) {
|
|
||||||
parsedSuccessUrl.searchParams.set(name, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlWithSuccessParams =
|
|
||||||
parsedExternalUrl.origin +
|
|
||||||
parsedExternalUrl.pathname +
|
|
||||||
"?" +
|
|
||||||
parsedSuccessUrl.searchParams.toString() +
|
|
||||||
parsedExternalUrl.hash;
|
|
||||||
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const { cancel: isCancellationMode } = querySchema.parse(router.query);
|
||||||
|
const urlWithSuccessParamsRef = useRef<string | null>();
|
||||||
|
if (isCancellationMode && timerRef.current) {
|
||||||
|
setIsToastVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isToastVisible && timerRef.current) {
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
}, [isToastVisible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parsedExternalUrl = new URL(url);
|
||||||
|
|
||||||
|
const parsedSuccessUrl = new URL(document.URL);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
/* @ts-ignore */ //https://stackoverflow.com/questions/49218765/typescript-and-iterator-type-iterableiteratort-is-not-an-array-type
|
||||||
|
for (const [name, value] of parsedExternalUrl.searchParams.entries()) {
|
||||||
|
parsedSuccessUrl.searchParams.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlWithSuccessParams =
|
||||||
|
parsedExternalUrl.origin +
|
||||||
|
parsedExternalUrl.pathname +
|
||||||
|
"?" +
|
||||||
|
parsedSuccessUrl.searchParams.toString() +
|
||||||
|
parsedExternalUrl.hash;
|
||||||
|
urlWithSuccessParamsRef.current = urlWithSuccessParams;
|
||||||
|
|
||||||
timerRef.current = window.setInterval(() => {
|
timerRef.current = window.setInterval(() => {
|
||||||
if (timeRemaining > 0) {
|
if (timeRemaining > 0) {
|
||||||
setTimeRemaining((timeRemaining) => {
|
setTimeRemaining((timeRemaining) => {
|
||||||
@@ -88,7 +105,7 @@ function RedirectionToast({ url }: { url: string }) {
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(timerRef.current as number);
|
window.clearInterval(timerRef.current as number);
|
||||||
};
|
};
|
||||||
}, [timeRemaining, urlWithSuccessParams]);
|
}, [timeRemaining, url]);
|
||||||
|
|
||||||
if (!isToastVisible) {
|
if (!isToastVisible) {
|
||||||
return null;
|
return null;
|
||||||
@@ -111,7 +128,9 @@ function RedirectionToast({ url }: { url: string }) {
|
|||||||
<div className="order-3 mt-2 w-full flex-shrink-0 sm:order-2 sm:mt-0 sm:w-auto">
|
<div className="order-3 mt-2 w-full flex-shrink-0 sm:order-2 sm:mt-0 sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
redirectToExternalUrl(urlWithSuccessParams);
|
if (urlWithSuccessParamsRef.current) {
|
||||||
|
redirectToExternalUrl(urlWithSuccessParamsRef.current);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center justify-center rounded-sm border border-transparent bg-white px-4 py-2 text-sm font-medium text-green-600 hover:bg-green-50">
|
className="flex w-full items-center justify-center rounded-sm border border-transparent bg-white px-4 py-2 text-sm font-medium text-green-600 hover:bg-green-50">
|
||||||
{t("continue")}
|
{t("continue")}
|
||||||
@@ -122,7 +141,6 @@ function RedirectionToast({ url }: { url: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsToastVisible(false);
|
setIsToastVisible(false);
|
||||||
window.clearInterval(timerRef.current as number);
|
|
||||||
}}
|
}}
|
||||||
className="-mr-1 flex rounded-md p-2 hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-white">
|
className="-mr-1 flex rounded-md p-2 hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-white">
|
||||||
<Icon.FiX className="h-6 w-6 text-white" />
|
<Icon.FiX className="h-6 w-6 text-white" />
|
||||||
@@ -147,8 +165,10 @@ const querySchema = z.object({
|
|||||||
uid: z.string(),
|
uid: z.string(),
|
||||||
allRemainingBookings: stringToBoolean,
|
allRemainingBookings: stringToBoolean,
|
||||||
cancel: stringToBoolean,
|
cancel: stringToBoolean,
|
||||||
|
changes: stringToBoolean,
|
||||||
reschedule: stringToBoolean,
|
reschedule: stringToBoolean,
|
||||||
isSuccessBookingPage: z.string().optional(),
|
isSuccessBookingPage: z.string().optional(),
|
||||||
|
formerTime: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Success(props: SuccessProps) {
|
export default function Success(props: SuccessProps) {
|
||||||
@@ -159,26 +179,34 @@ export default function Success(props: SuccessProps) {
|
|||||||
allRemainingBookings,
|
allRemainingBookings,
|
||||||
isSuccessBookingPage,
|
isSuccessBookingPage,
|
||||||
cancel: isCancellationMode,
|
cancel: isCancellationMode,
|
||||||
|
changes,
|
||||||
|
formerTime,
|
||||||
} = querySchema.parse(router.query);
|
} = querySchema.parse(router.query);
|
||||||
|
|
||||||
if (isCancellationMode && typeof window !== "undefined") {
|
if ((isCancellationMode || changes) && typeof window !== "undefined") {
|
||||||
window.scrollTo(0, document.body.scrollHeight);
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(props.bookingInfo.location)
|
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(props.bookingInfo.location)
|
||||||
? props.bookingInfo.location[0] || ""
|
? props.bookingInfo.location[0]
|
||||||
: props.bookingInfo.location || "";
|
: // If there is no location set then we default to Cal Video
|
||||||
|
"integrations:daily";
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
// Can't use logger.error because it throws error on client. stdout isn't available to it.
|
// Can't use logger.error because it throws error on client. stdout isn't available to it.
|
||||||
console.error(`No location found `);
|
console.error(`No location found `);
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = props.bookingInfo?.user?.name;
|
|
||||||
const email = props.bookingInfo?.user?.email;
|
const email = props.bookingInfo?.user?.email;
|
||||||
const status = props.bookingInfo?.status;
|
const status = props.bookingInfo?.status;
|
||||||
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
|
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
|
||||||
const cancellationReason = props.bookingInfo.cancellationReason;
|
const cancellationReason = props.bookingInfo.cancellationReason;
|
||||||
|
|
||||||
|
const attendeeName =
|
||||||
|
typeof props?.bookingInfo?.attendees?.[0]?.name === "string"
|
||||||
|
? props?.bookingInfo?.attendees?.[0]?.name
|
||||||
|
: "Nameless";
|
||||||
|
|
||||||
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
@@ -189,6 +217,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
const isEmbed = useIsEmbed();
|
const isEmbed = useIsEmbed();
|
||||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||||
|
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
function setIsCancellationMode(value: boolean) {
|
function setIsCancellationMode(value: boolean) {
|
||||||
if (value) router.query.cancel = "true";
|
if (value) router.query.cancel = "true";
|
||||||
@@ -199,8 +228,6 @@ export default function Success(props: SuccessProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const attendeeName = typeof name === "string" ? name : "Nameless";
|
|
||||||
|
|
||||||
const eventNameObject = {
|
const eventNameObject = {
|
||||||
attendeeName,
|
attendeeName,
|
||||||
eventType: props.eventType.title,
|
eventType: props.eventType.title,
|
||||||
@@ -220,7 +247,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (top !== window) {
|
if (top !== window) {
|
||||||
//page_view will be collected automatically by _middleware.ts
|
//page_view will be collected automatically by _middleware.ts
|
||||||
telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/success"));
|
telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/booking"));
|
||||||
}
|
}
|
||||||
}, [telemetry]);
|
}, [telemetry]);
|
||||||
|
|
||||||
@@ -231,7 +258,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
sdkActionManager.fire("bookingSuccessful", {
|
sdkActionManager.fire("bookingSuccessful", {
|
||||||
eventType,
|
eventType,
|
||||||
date: date.toString(),
|
date: date.toString(),
|
||||||
duration: eventType.length,
|
duration: calculatedDuration,
|
||||||
organizer: {
|
organizer: {
|
||||||
name: users[0].name || "Nameless",
|
name: users[0].name || "Nameless",
|
||||||
email: users[0].email || "Email-less",
|
email: users[0].email || "Email-less",
|
||||||
@@ -245,6 +272,12 @@ export default function Success(props: SuccessProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [eventType, needsConfirmation]);
|
}, [eventType, needsConfirmation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCalculatedDuration(
|
||||||
|
dayjs(props.bookingInfo.endTime).diff(dayjs(props.bookingInfo.startTime), "minutes")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function eventLink(): string {
|
function eventLink(): string {
|
||||||
const optional: { location?: string } = {};
|
const optional: { location?: string } = {};
|
||||||
if (location) {
|
if (location) {
|
||||||
@@ -263,7 +296,9 @@ export default function Success(props: SuccessProps) {
|
|||||||
title: eventName,
|
title: eventName,
|
||||||
description: props.eventType.description ? props.eventType.description : undefined,
|
description: props.eventType.description ? props.eventType.description : undefined,
|
||||||
/** formatted to required type of description ^ */
|
/** formatted to required type of description ^ */
|
||||||
duration: { minutes: props.eventType.length },
|
duration: {
|
||||||
|
minutes: calculatedDuration,
|
||||||
|
},
|
||||||
...optional,
|
...optional,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,6 +340,19 @@ export default function Success(props: SuccessProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
||||||
|
{!isEmbed && (
|
||||||
|
<EventReservationSchema
|
||||||
|
reservationId={bookingInfo.uid}
|
||||||
|
eventName={eventName}
|
||||||
|
startTime={bookingInfo.startTime}
|
||||||
|
endTime={bookingInfo.endTime}
|
||||||
|
organizer={bookingInfo.user}
|
||||||
|
attendees={bookingInfo.attendees}
|
||||||
|
location={locationToDisplay}
|
||||||
|
description={bookingInfo.description}
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{userIsOwner && !isEmbed && (
|
{userIsOwner && !isEmbed && (
|
||||||
<div className="mt-2 ml-4 -mb-4">
|
<div className="mt-2 ml-4 -mb-4">
|
||||||
<Link href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}>
|
<Link href={allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}>
|
||||||
@@ -319,7 +367,9 @@ export default function Success(props: SuccessProps) {
|
|||||||
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
||||||
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
|
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
|
||||||
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
|
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
|
||||||
{eventType.successRedirectUrl ? <RedirectionToast url={eventType.successRedirectUrl} /> : null}{" "}
|
{isSuccessBookingPage && !isCancellationMode && eventType.successRedirectUrl ? (
|
||||||
|
<RedirectionToast url={eventType.successRedirectUrl} />
|
||||||
|
) : null}{" "}
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
shouldAlignCentrally ? "text-center" : "",
|
shouldAlignCentrally ? "text-center" : "",
|
||||||
@@ -361,6 +411,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
<div className="mt-6 mb-8 text-center last:mb-0">
|
<div className="mt-6 mb-8 text-center last:mb-0">
|
||||||
<h3
|
<h3
|
||||||
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
|
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
|
||||||
|
data-testid={isCancelled ? "cancelled-headline" : ""}
|
||||||
id="modal-headline">
|
id="modal-headline">
|
||||||
{needsConfirmation && !isCancelled
|
{needsConfirmation && !isCancelled
|
||||||
? props.recurringBookings
|
? props.recurringBookings
|
||||||
@@ -376,9 +427,11 @@ export default function Success(props: SuccessProps) {
|
|||||||
<p className="text-neutral-600 dark:text-gray-300">{getTitle()}</p>
|
<p className="text-neutral-600 dark:text-gray-300">{getTitle()}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-300 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
|
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-300 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
|
||||||
{isCancelled && cancellationReason && (
|
{(isCancelled || reschedule) && cancellationReason && (
|
||||||
<>
|
<>
|
||||||
<div className="font-medium">{t("reason")}</div>
|
<div className="font-medium">
|
||||||
|
{isCancelled ? t("reason") : t("reschedule_reason_success_page")}
|
||||||
|
</div>
|
||||||
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -386,18 +439,33 @@ export default function Success(props: SuccessProps) {
|
|||||||
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
|
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
|
||||||
<div className="font-medium">{t("when")}</div>
|
<div className="font-medium">{t("when")}</div>
|
||||||
<div className="col-span-2 mb-6 last:mb-0">
|
<div className="col-span-2 mb-6 last:mb-0">
|
||||||
|
{reschedule && !!formerTime && (
|
||||||
|
<p className="line-through">
|
||||||
|
<RecurringBookings
|
||||||
|
eventType={props.eventType}
|
||||||
|
duration={calculatedDuration}
|
||||||
|
recurringBookings={props.recurringBookings}
|
||||||
|
allRemainingBookings={allRemainingBookings}
|
||||||
|
date={dayjs(formerTime)}
|
||||||
|
is24h={is24h}
|
||||||
|
isCancelled={isCancelled}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<RecurringBookings
|
<RecurringBookings
|
||||||
eventType={props.eventType}
|
eventType={props.eventType}
|
||||||
|
duration={calculatedDuration}
|
||||||
recurringBookings={props.recurringBookings}
|
recurringBookings={props.recurringBookings}
|
||||||
allRemainingBookings={allRemainingBookings}
|
allRemainingBookings={allRemainingBookings}
|
||||||
date={date}
|
date={date}
|
||||||
is24h={is24h}
|
is24h={is24h}
|
||||||
|
isCancelled={isCancelled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(bookingInfo?.user || bookingInfo?.attendees) && (
|
{(bookingInfo?.user || bookingInfo?.attendees) && (
|
||||||
<>
|
<>
|
||||||
<div className="font-medium">{t("who")}</div>
|
<div className="font-medium">{t("who")}</div>
|
||||||
<div className="col-span-2 mb-6 last:mb-0">
|
<div className="col-span-2 last:mb-0">
|
||||||
<>
|
<>
|
||||||
{bookingInfo?.user && (
|
{bookingInfo?.user && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -439,13 +507,40 @@ export default function Success(props: SuccessProps) {
|
|||||||
)}
|
)}
|
||||||
{customInputs &&
|
{customInputs &&
|
||||||
Object.keys(customInputs).map((key) => {
|
Object.keys(customInputs).map((key) => {
|
||||||
|
// This breaks if you have two label that are the same.
|
||||||
|
// TODO: Fix this in another PR
|
||||||
const customInput = customInputs[key as keyof typeof customInputs];
|
const customInput = customInputs[key as keyof typeof customInputs];
|
||||||
|
const eventTypeCustomFound = eventType.customInputs?.find((ci) => ci.label === key);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{customInput !== "" && (
|
{eventTypeCustomFound?.type === "RADIO" && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-2 pr-3 font-medium">{key}</div>
|
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||||
<div className="col-span-2 mt-2 mb-2">
|
{eventTypeCustomFound.label}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 mt-1 mb-2">
|
||||||
|
{eventTypeCustomFound.options &&
|
||||||
|
eventTypeCustomFound.options.map((option) => {
|
||||||
|
const selected = option.label == customInput;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.label}
|
||||||
|
className={classNames(
|
||||||
|
"flex space-x-1",
|
||||||
|
!selected && "text-gray-500"
|
||||||
|
)}>
|
||||||
|
<p>{option.label}</p>
|
||||||
|
<span>{option.label === customInput && "✅"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
|
||||||
|
<>
|
||||||
|
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">{key}</div>
|
||||||
|
<div className="col-span-3 mt-2 mb-2">
|
||||||
{typeof customInput === "boolean" ? (
|
{typeof customInput === "boolean" ? (
|
||||||
<p>{customInput ? "true" : "false"}</p>
|
<p>{customInput ? "true" : "false"}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -467,7 +562,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!needsConfirmation &&
|
{(!needsConfirmation || !userIsOwner) &&
|
||||||
!isCancelled &&
|
!isCancelled &&
|
||||||
(!isCancellationMode ? (
|
(!isCancellationMode ? (
|
||||||
<>
|
<>
|
||||||
@@ -511,119 +606,125 @@ export default function Success(props: SuccessProps) {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
{userIsOwner && !needsConfirmation && !isCancellationMode && !isCancelled && (
|
{userIsOwner &&
|
||||||
<>
|
!needsConfirmation &&
|
||||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
!isCancellationMode &&
|
||||||
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
|
!isCancelled &&
|
||||||
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
calculatedDuration && (
|
||||||
{t("add_to_calendar")}
|
<>
|
||||||
</span>
|
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||||
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
|
||||||
<Link
|
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||||
href={
|
{t("add_to_calendar")}
|
||||||
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
</span>
|
||||||
.utc()
|
<div className="justify-left mt-1 flex text-left sm:mt-0">
|
||||||
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
<Link
|
||||||
.add(props.eventType.length, "minute")
|
href={
|
||||||
.utc()
|
`https://calendar.google.com/calendar/r/eventedit?dates=${date
|
||||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
.utc()
|
||||||
props.eventType.description
|
.format("YYYYMMDDTHHmmss[Z]")}/${date
|
||||||
}` +
|
.add(calculatedDuration, "minute")
|
||||||
(typeof location === "string"
|
.utc()
|
||||||
? "&location=" + encodeURIComponent(location)
|
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||||
: "") +
|
props.eventType.description
|
||||||
(props.eventType.recurringEvent
|
}` +
|
||||||
? "&recur=" +
|
(typeof location === "string"
|
||||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
? "&location=" + encodeURIComponent(location)
|
||||||
: "")
|
: "") +
|
||||||
}>
|
(props.eventType.recurringEvent
|
||||||
<a className="mr-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
|
? "&recur=" +
|
||||||
<svg
|
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
||||||
className="-mt-1.5 inline-block h-4 w-4"
|
: "")
|
||||||
fill="currentColor"
|
}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<a className="mr-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
|
||||||
viewBox="0 0 24 24">
|
<svg
|
||||||
<title>Google</title>
|
className="-mt-1.5 inline-block h-4 w-4"
|
||||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
fill="currentColor"
|
||||||
</svg>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</a>
|
viewBox="0 0 24 24">
|
||||||
</Link>
|
<title>Google</title>
|
||||||
<Link
|
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||||
href={
|
</svg>
|
||||||
encodeURI(
|
</a>
|
||||||
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
</Link>
|
||||||
props.eventType.description +
|
<Link
|
||||||
"&enddt=" +
|
href={
|
||||||
date.add(props.eventType.length, "minute").utc().format() +
|
encodeURI(
|
||||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
|
||||||
date.utc().format() +
|
props.eventType.description +
|
||||||
"&subject=" +
|
"&enddt=" +
|
||||||
eventName
|
date.add(calculatedDuration, "minute").utc().format() +
|
||||||
) + (location ? "&location=" + location : "")
|
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||||
}>
|
date.utc().format() +
|
||||||
<a
|
"&subject=" +
|
||||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
eventName
|
||||||
target="_blank">
|
) + (location ? "&location=" + location : "")
|
||||||
<svg
|
}>
|
||||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
<a
|
||||||
fill="currentColor"
|
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
target="_blank">
|
||||||
viewBox="0 0 24 24">
|
<svg
|
||||||
<title>Microsoft Outlook</title>
|
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||||
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
fill="currentColor"
|
||||||
</svg>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</a>
|
viewBox="0 0 24 24">
|
||||||
</Link>
|
<title>Microsoft Outlook</title>
|
||||||
<Link
|
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
|
||||||
href={
|
</svg>
|
||||||
encodeURI(
|
</a>
|
||||||
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
</Link>
|
||||||
props.eventType.description +
|
<Link
|
||||||
"&enddt=" +
|
href={
|
||||||
date.add(props.eventType.length, "minute").utc().format() +
|
encodeURI(
|
||||||
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
|
||||||
date.utc().format() +
|
props.eventType.description +
|
||||||
"&subject=" +
|
"&enddt=" +
|
||||||
eventName
|
date.add(calculatedDuration, "minute").utc().format() +
|
||||||
) + (location ? "&location=" + location : "")
|
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
|
||||||
}>
|
date.utc().format() +
|
||||||
<a
|
"&subject=" +
|
||||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
eventName
|
||||||
target="_blank">
|
) + (location ? "&location=" + location : "")
|
||||||
<svg
|
}>
|
||||||
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
<a
|
||||||
fill="currentColor"
|
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
target="_blank">
|
||||||
viewBox="0 0 24 24">
|
<svg
|
||||||
<title>Microsoft Office</title>
|
className="mr-1 -mt-1.5 inline-block h-4 w-4"
|
||||||
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
fill="currentColor"
|
||||||
</svg>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</a>
|
viewBox="0 0 24 24">
|
||||||
</Link>
|
<title>Microsoft Office</title>
|
||||||
<Link href={"data:text/calendar," + eventLink()}>
|
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
|
||||||
<a
|
</svg>
|
||||||
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
</a>
|
||||||
download={props.eventType.title + ".ics"}>
|
</Link>
|
||||||
<svg
|
<Link href={"data:text/calendar," + eventLink()}>
|
||||||
version="1.1"
|
<a
|
||||||
fill="currentColor"
|
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
download={props.eventType.title + ".ics"}>
|
||||||
viewBox="0 0 1000 1000"
|
<svg
|
||||||
className="mr-1 -mt-1.5 inline-block h-4 w-4">
|
version="1.1"
|
||||||
<title>{t("other")}</title>
|
fill="currentColor"
|
||||||
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</svg>
|
viewBox="0 0 1000 1000"
|
||||||
</a>
|
className="mr-1 -mt-1.5 inline-block h-4 w-4">
|
||||||
</Link>
|
<title>{t("other")}</title>
|
||||||
|
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
{session === null && !(userIsOwner || props.hideBranding) && (
|
{session === null && !(userIsOwner || props.hideBranding) && (
|
||||||
<>
|
<>
|
||||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||||
<div className="border-bookinglightest text-booking-lighter dark:border-darkgray-300 pt-8 text-center text-xs dark:text-white">
|
<div className="border-bookinglightest text-booking-lighter dark:border-darkgray-300 pt-8 text-center text-xs dark:text-white">
|
||||||
<a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a>
|
<a href="https://cal.com/signup">
|
||||||
|
{t("create_booking_link_with_calcom", { appName: APP_NAME })}
|
||||||
|
</a>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -667,16 +768,20 @@ type RecurringBookingsProps = {
|
|||||||
eventType: SuccessProps["eventType"];
|
eventType: SuccessProps["eventType"];
|
||||||
recurringBookings: SuccessProps["recurringBookings"];
|
recurringBookings: SuccessProps["recurringBookings"];
|
||||||
date: dayjs.Dayjs;
|
date: dayjs.Dayjs;
|
||||||
|
duration: number | undefined;
|
||||||
is24h: boolean;
|
is24h: boolean;
|
||||||
allRemainingBookings: boolean;
|
allRemainingBookings: boolean;
|
||||||
|
isCancelled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RecurringBookings({
|
export function RecurringBookings({
|
||||||
eventType,
|
eventType,
|
||||||
recurringBookings,
|
recurringBookings,
|
||||||
|
duration,
|
||||||
date,
|
date,
|
||||||
allRemainingBookings,
|
allRemainingBookings,
|
||||||
is24h,
|
is24h,
|
||||||
|
isCancelled,
|
||||||
}: RecurringBookingsProps) {
|
}: RecurringBookingsProps) {
|
||||||
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
|
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -685,6 +790,8 @@ export function RecurringBookings({
|
|||||||
? recurringBookings.sort((a: ConfigType, b: ConfigType) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1))
|
? recurringBookings.sort((a: ConfigType, b: ConfigType) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (!duration) return null;
|
||||||
|
|
||||||
if (recurringBookingsSorted && allRemainingBookings) {
|
if (recurringBookingsSorted && allRemainingBookings) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -699,11 +806,11 @@ export function RecurringBookings({
|
|||||||
)}
|
)}
|
||||||
{eventType.recurringEvent?.count &&
|
{eventType.recurringEvent?.count &&
|
||||||
recurringBookingsSorted.slice(0, 4).map((dateStr: string, idx: number) => (
|
recurringBookingsSorted.slice(0, 4).map((dateStr: string, idx: number) => (
|
||||||
<div key={idx} className="mb-2">
|
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||||
{dayjs.tz(dateStr, timeZone()).format("MMMM DD, YYYY")}
|
{dayjs.tz(dateStr, timeZone()).format("dddd, DD MMMM YYYY")}
|
||||||
<br />
|
<br />
|
||||||
{formatTime(dateStr, is24h ? 24 : 12, timeZone())} -{" "}
|
{formatTime(dateStr, is24h ? 24 : 12, timeZone().toLowerCase())} -{" "}
|
||||||
{formatTime(dayjs(dateStr).add(eventType.length, "m"), is24h ? 24 : 12, timeZone())}{" "}
|
{formatTime(dayjs(dateStr).add(duration, "m"), is24h ? 24 : 12, timeZone().toLowerCase())}{" "}
|
||||||
<span className="text-bookinglight">({timeZone()})</span>
|
<span className="text-bookinglight">({timeZone()})</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -717,11 +824,11 @@ export function RecurringBookings({
|
|||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
{eventType.recurringEvent?.count &&
|
{eventType.recurringEvent?.count &&
|
||||||
recurringBookingsSorted.slice(4).map((dateStr: string, idx: number) => (
|
recurringBookingsSorted.slice(4).map((dateStr: string, idx: number) => (
|
||||||
<div key={idx} className="mb-2">
|
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||||
{dayjs.tz(dateStr, timeZone()).format("MMMM DD, YYYY")}
|
{dayjs.tz(dateStr, timeZone()).format("dddd, DD MMMM YYYY")}
|
||||||
<br />
|
<br />
|
||||||
{formatTime(dateStr, is24h ? 24 : 12, timeZone())} -{" "}
|
{formatTime(dateStr, is24h ? 24 : 12, timeZone().toLowerCase())} -{" "}
|
||||||
{formatTime(dayjs(dateStr).add(eventType.length, "m"), is24h ? 24 : 12, timeZone())}{" "}
|
{formatTime(dayjs(dateStr).add(duration, "m"), is24h ? 24 : 12, timeZone().toLowerCase())}{" "}
|
||||||
<span className="text-bookinglight">({timeZone()})</span>
|
<span className="text-bookinglight">({timeZone()})</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -733,13 +840,13 @@ export function RecurringBookings({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={classNames(isCancelled ? "line-through" : "")}>
|
||||||
{dayjs.tz(date, timeZone()).format("MMMM DD, YYYY")}
|
{dayjs.tz(date, timeZone()).format("dddd, DD MMMM YYYY")}
|
||||||
<br />
|
<br />
|
||||||
{formatTime(date, is24h ? 24 : 12, timeZone())} -{" "}
|
{formatTime(date, is24h ? 24 : 12, timeZone().toLowerCase())} -{" "}
|
||||||
{formatTime(dayjs(date).add(eventType.length, "m"), is24h ? 24 : 12, timeZone())}{" "}
|
{formatTime(dayjs(date).add(duration, "m"), is24h ? 24 : 12, timeZone().toLowerCase())}{" "}
|
||||||
<span className="text-bookinglight">({timeZone()})</span>
|
<span className="text-bookinglight">({timeZone()})</span>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +865,7 @@ const getEventTypesFromDB = async (id: number) => {
|
|||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
successRedirectUrl: true,
|
successRedirectUrl: true,
|
||||||
|
customInputs: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
@@ -767,7 +875,6 @@ const getEventTypesFromDB = async (id: number) => {
|
|||||||
name: true,
|
name: true,
|
||||||
username: true,
|
username: true,
|
||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
plan: true,
|
|
||||||
theme: true,
|
theme: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
@@ -824,22 +931,25 @@ const handleSeatsEventTypeOnBooking = (
|
|||||||
seatsShowAttendees: boolean | null;
|
seatsShowAttendees: boolean | null;
|
||||||
[x: string | number | symbol]: unknown;
|
[x: string | number | symbol]: unknown;
|
||||||
},
|
},
|
||||||
booking: Partial<
|
bookingInfo: Partial<
|
||||||
Prisma.BookingGetPayload<{ include: { attendees: { select: { name: true; email: true } } } }>
|
Prisma.BookingGetPayload<{ include: { attendees: { select: { name: true; email: true } } } }>
|
||||||
>,
|
>,
|
||||||
email: string
|
email: string
|
||||||
) => {
|
) => {
|
||||||
if (eventType?.seatsPerTimeSlot !== null) {
|
if (eventType?.seatsPerTimeSlot !== null) {
|
||||||
// @TODO: right now bookings with seats doesn't save every description that its entered by every user
|
// @TODO: right now bookings with seats doesn't save every description that its entered by every user
|
||||||
delete booking.description;
|
delete bookingInfo.description;
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!eventType.seatsShowAttendees) {
|
if (!eventType.seatsShowAttendees) {
|
||||||
const attendee = booking?.attendees?.find((a) => a.email === email);
|
const attendee = bookingInfo?.attendees?.find((a) => {
|
||||||
booking["attendees"] = attendee ? [attendee] : [];
|
return a.email === email;
|
||||||
|
});
|
||||||
|
|
||||||
|
bookingInfo["attendees"] = attendee ? [attendee] : [];
|
||||||
}
|
}
|
||||||
return;
|
return bookingInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
@@ -861,6 +971,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
smsReminderNumber: true,
|
smsReminderNumber: true,
|
||||||
recurringEventId: true,
|
recurringEventId: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
location: true,
|
location: true,
|
||||||
status: true,
|
status: true,
|
||||||
cancellationReason: true,
|
cancellationReason: true,
|
||||||
@@ -897,6 +1008,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||||
// probably fixable with json.stringify -> json.parse
|
// probably fixable with json.stringify -> json.parse
|
||||||
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
|
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
|
||||||
|
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
|
||||||
|
|
||||||
const eventTypeRaw = !bookingInfo.eventTypeId
|
const eventTypeRaw = !bookingInfo.eventTypeId
|
||||||
? getDefaultEvent(eventTypeSlug || "")
|
? getDefaultEvent(eventTypeSlug || "")
|
||||||
@@ -916,7 +1028,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
select: baseUserSelect,
|
select: baseUserSelect,
|
||||||
});
|
});
|
||||||
if (user) {
|
if (user) {
|
||||||
eventTypeRaw.users.push(user);
|
eventTypeRaw.users.push({
|
||||||
|
...user,
|
||||||
|
avatar: "",
|
||||||
|
allowDynamicBooking: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,6 +1048,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null,
|
periodEndDate: eventTypeRaw.periodEndDate?.toString() ?? null,
|
||||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata),
|
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata),
|
||||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||||
|
customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs),
|
||||||
};
|
};
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
@@ -943,7 +1060,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
slug: eventType.team?.slug || eventType.users[0]?.username || null,
|
slug: eventType.team?.slug || eventType.users[0]?.username || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (bookingInfo !== null && email) {
|
if (bookingInfo !== null && email && eventType.seatsPerTimeSlot) {
|
||||||
handleSeatsEventTypeOnBooking(eventType, bookingInfo, email);
|
handleSeatsEventTypeOnBooking(eventType, bookingInfo, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,18 +5,29 @@ import { Fragment } from "react";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||||
|
import BookingLayout from "@calcom/features/bookings/layout/BookingLayout";
|
||||||
|
import { filterQuerySchema, useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { Alert, BookingLayout, Button, EmptyScreen, Icon } from "@calcom/ui";
|
import { Alert, Button, EmptyScreen, Icon } from "@calcom/ui";
|
||||||
|
|
||||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
||||||
|
|
||||||
import BookingListItem from "@components/booking/BookingListItem";
|
import BookingListItem from "@components/booking/BookingListItem";
|
||||||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||||
|
|
||||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["status"];
|
import { ssgInit } from "@server/lib/ssg";
|
||||||
|
|
||||||
|
type BookingListingStatus = z.infer<typeof filterQuerySchema>["status"];
|
||||||
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
|
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
|
||||||
|
|
||||||
|
type RecurringInfo = {
|
||||||
|
recurringEventId: string | null;
|
||||||
|
count: number;
|
||||||
|
firstDate: Date | null;
|
||||||
|
bookings: { [key: string]: Date[] };
|
||||||
|
};
|
||||||
|
|
||||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
||||||
|
|
||||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||||
@@ -32,12 +43,19 @@ const querySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function Bookings() {
|
export default function Bookings() {
|
||||||
|
const { data: filterQuery } = useFilterQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { status } = router.isReady ? querySchema.parse(router.query) : { status: "upcoming" as const };
|
const { status } = router.isReady ? querySchema.parse(router.query) : { status: "upcoming" as const };
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||||
{ status, limit: 10 },
|
{
|
||||||
|
limit: 10,
|
||||||
|
filters: {
|
||||||
|
...filterQuery,
|
||||||
|
status: filterQuery.status ?? status,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// first render has status `undefined`
|
// first render has status `undefined`
|
||||||
enabled: router.isReady,
|
enabled: router.isReady,
|
||||||
@@ -73,6 +91,18 @@ export default function Bookings() {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let recurringInfoToday: RecurringInfo | undefined;
|
||||||
|
|
||||||
|
const bookingsToday =
|
||||||
|
query.data?.pages.map((page) =>
|
||||||
|
page.bookings.filter((booking: BookingOutput) => {
|
||||||
|
recurringInfoToday = page.recurringInfo.find(
|
||||||
|
(info) => info.recurringEventId === booking.recurringEventId
|
||||||
|
);
|
||||||
|
return new Date(booking.startTime).toDateString() === new Date().toDateString();
|
||||||
|
})
|
||||||
|
)[0] || [];
|
||||||
|
|
||||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,48 +113,68 @@ export default function Bookings() {
|
|||||||
)}
|
)}
|
||||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||||
{query.status === "success" && !isEmpty && (
|
{query.status === "success" && !isEmpty && (
|
||||||
<div className="pt-2 xl:pt-0">
|
<>
|
||||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
{!!bookingsToday.length && status === "upcoming" && (
|
||||||
{/* TODO: add today only for the current day
|
<div className="mb-6 pt-2 xl:pt-0">
|
||||||
<p className="pb-3 text-xs font-medium leading-4 text-gray-500 uppercase">{t("today")}</p>
|
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||||
*/}
|
<p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("today")}</p>
|
||||||
|
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
<table className="w-full max-w-full table-fixed">
|
||||||
<table className="w-full max-w-full table-fixed">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
<Fragment>
|
||||||
{query.data.pages.map((page, index) => (
|
{bookingsToday.map((booking: BookingOutput) => (
|
||||||
<Fragment key={index}>
|
|
||||||
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
|
|
||||||
const recurringInfo = page.recurringInfo.find(
|
|
||||||
(info) => info.recurringEventId === booking.recurringEventId
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<BookingListItem
|
<BookingListItem
|
||||||
key={booking.id}
|
key={booking.id}
|
||||||
listingStatus={status}
|
listingStatus={status}
|
||||||
recurringInfo={recurringInfo}
|
recurringInfo={recurringInfoToday}
|
||||||
{...booking}
|
{...booking}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
</Fragment>
|
||||||
</Fragment>
|
</tbody>
|
||||||
))}
|
</table>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-2 xl:pt-0">
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("all")}</p>
|
||||||
|
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||||
|
<table className="w-full max-w-full table-fixed">
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
||||||
|
{query.data.pages.map((page, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
|
||||||
|
const recurringInfo = page.recurringInfo.find(
|
||||||
|
(info) => info.recurringEventId === booking.recurringEventId
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<BookingListItem
|
||||||
|
key={booking.id}
|
||||||
|
listingStatus={status}
|
||||||
|
recurringInfo={recurringInfo}
|
||||||
|
{...booking}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 text-center" ref={buttonInView.ref}>
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
loading={query.isFetchingNextPage}
|
||||||
|
disabled={!query.hasNextPage}
|
||||||
|
onClick={() => query.fetchNextPage()}>
|
||||||
|
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 text-center" ref={buttonInView.ref}>
|
</>
|
||||||
<Button
|
|
||||||
color="minimal"
|
|
||||||
loading={query.isFetchingNextPage}
|
|
||||||
disabled={!query.hasNextPage}
|
|
||||||
onClick={() => query.fetchNextPage()}>
|
|
||||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{query.status === "success" && isEmpty && (
|
{query.status === "success" && isEmpty && (
|
||||||
<div className="flex items-center justify-center pt-2 xl:mx-6 xl:pt-0">
|
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={Icon.FiCalendar}
|
Icon={Icon.FiCalendar}
|
||||||
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
||||||
@@ -140,14 +190,16 @@ export default function Bookings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = (ctx) => {
|
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||||
const params = querySchema.safeParse(ctx.params);
|
const params = querySchema.safeParse(ctx.params);
|
||||||
|
const ssg = await ssgInit(ctx);
|
||||||
|
|
||||||
if (!params.success) return { notFound: true };
|
if (!params.success) return { notFound: true };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
status: params.data.status,
|
status: params.data.status,
|
||||||
|
trpcState: ssg.dehydrate(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { GetServerSidePropsContext } from "next";
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
const querySchema = z.object({
|
|
||||||
uid: z.string(),
|
|
||||||
allRemainingBookings: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform((val) => (val ? JSON.parse(val) : false)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Type() {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
|
||||||
const { allRemainingBookings, uid } = querySchema.parse(context.query);
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
permanent: false,
|
|
||||||
destination: `/success?uid=${uid}&allRemainingBookings=${allRemainingBookings}&cancel=true`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { Button, Icon } from "@calcom/ui";
|
|
||||||
|
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
|
||||||
|
|
||||||
const querySchema = z.object({
|
|
||||||
title: z.string().optional(),
|
|
||||||
name: z.string().optional(),
|
|
||||||
eventPage: z.string().optional(),
|
|
||||||
allRemainingBookings: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform((val) => (val ? JSON.parse(val) : false)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function CancelSuccess() {
|
|
||||||
const { t } = useLocale();
|
|
||||||
// Get router variables
|
|
||||||
const router = useRouter();
|
|
||||||
const { title, name, eventPage, allRemainingBookings } = querySchema.parse(router.query);
|
|
||||||
let team: string | string[] | number | undefined = router.query.team;
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const loading = status === "loading";
|
|
||||||
// If team param passed wrongly just assume it be a non team case.
|
|
||||||
if (team instanceof Array || typeof team === "undefined") {
|
|
||||||
team = 0;
|
|
||||||
}
|
|
||||||
const isTeamEvent = +team === 1;
|
|
||||||
// FIXME: In case of Dynamic Event Booking, it takes the booker to one of the user's page(e.g. A) in the dynamic group(A+B+...). Booker should be taken to the same dynamic group
|
|
||||||
// This isn't directly possible because a booking doesn't know if it was done for a Dynamic Event(booking.eventType is null)
|
|
||||||
const eventUrl = `/${isTeamEvent ? "team/" : ""}${eventPage as string}`;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<HeadSeo
|
|
||||||
title={`${t("cancelled")} ${title} | ${name}`}
|
|
||||||
description={`${t("cancelled")} ${title} | ${name}`}
|
|
||||||
/>
|
|
||||||
<main className="mx-auto my-24 max-w-3xl">
|
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
||||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
|
|
||||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
|
||||||
​
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="modal-headline">
|
|
||||||
<div>
|
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
|
||||||
<Icon.FiCheck className="h-6 w-6 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
|
||||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
|
|
||||||
{t("cancellation_successful")}
|
|
||||||
</h3>
|
|
||||||
{!loading && !session?.user && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-500">{t("free_to_pick_another_event_type")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 text-center sm:mt-6">
|
|
||||||
<div className="mt-5">
|
|
||||||
{!loading && !session?.user && <Button href={eventUrl}>Pick another</Button>}
|
|
||||||
{!loading && session?.user && (
|
|
||||||
<Button
|
|
||||||
data-testid="back-to-bookings"
|
|
||||||
href={!!allRemainingBookings ? "/bookings/recurring" : "/bookings/upcoming"}
|
|
||||||
StartIcon={Icon.FiArrowLeft}>
|
|
||||||
{t("back_to_bookings")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -90,7 +90,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
theme: true,
|
theme: true,
|
||||||
plan: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +116,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
name: u.name,
|
name: u.name,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
hideBranding: u.hideBranding,
|
hideBranding: u.hideBranding,
|
||||||
plan: u.plan,
|
|
||||||
timeZone: u.timeZone,
|
timeZone: u.timeZone,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -158,7 +156,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
away: user.away,
|
away: user.away,
|
||||||
isDynamicGroup: false,
|
isDynamicGroup: false,
|
||||||
profile,
|
profile,
|
||||||
plan: user.plan,
|
|
||||||
date,
|
date,
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
workingHours,
|
workingHours,
|
||||||
@@ -166,6 +163,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
previousPage: context.req.headers.referer ?? null,
|
previousPage: context.req.headers.referer ?? null,
|
||||||
booking,
|
booking,
|
||||||
users: [user.username],
|
users: [user.username],
|
||||||
|
isBrandingHidden: user.hideBranding,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -4,6 +4,7 @@ import { JSONObject } from "superjson/dist/types";
|
|||||||
import { parseRecurringEvent } from "@calcom/lib";
|
import { parseRecurringEvent } from "@calcom/lib";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { bookEventTypeSelect } from "@calcom/prisma/selects";
|
import { bookEventTypeSelect } from "@calcom/prisma/selects";
|
||||||
|
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
@@ -69,7 +70,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
if (!eventTypeRaw) return { notFound: true };
|
if (!eventTypeRaw) return { notFound: true };
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...eventTypeRaw,
|
...eventTypeRaw,
|
||||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
...e,
|
...e,
|
||||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||||
|
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
||||||
schedulingType: null,
|
schedulingType: null,
|
||||||
users: users.map((u) => ({
|
users: users.map((u) => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
|
|||||||
Loaded 100 of 635 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user