Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ba8f99302 | ||
|
|
e7b634419b | ||
|
|
de0cb867d1 | ||
|
|
02571aabe0 | ||
|
|
58b2a6425a | ||
|
|
2c2261f085 | ||
|
|
9a3624bb9a | ||
|
|
3449ad1a9f | ||
|
|
44f0893674 | ||
|
|
af6f33c709 | ||
|
|
942b8da124 | ||
|
|
87efab5b2b | ||
|
|
f16f9466cb | ||
|
|
ddd62d3729 | ||
|
|
862feb2bc4 | ||
|
|
79556f1d13 | ||
|
|
5ec4b7ad37 | ||
|
|
3605fca9a4 | ||
|
|
7874ff9c6d | ||
|
|
02e5ec37e7 | ||
|
|
fd863b2ae7 | ||
|
|
13b5618a71 | ||
|
|
643b46aa8f | ||
|
|
06b7744b30 | ||
|
|
7f53c4288d | ||
|
|
7be60b47f1 | ||
|
|
bc88fd0a04 | ||
|
|
60c989432e | ||
|
|
d1b791518e | ||
|
|
39f6ef8dcd | ||
|
|
777d260d78 | ||
|
|
7f6a9cf555 | ||
|
|
21fb0fc3a3 | ||
|
|
1e7eac4133 | ||
|
|
fef6f3baff | ||
|
|
6f354e0c7f | ||
|
|
40a18243e4 | ||
|
|
43f1e86322 | ||
|
|
2f5928be84 | ||
|
|
d7cd6e011e | ||
|
|
993bd18a73 | ||
|
|
dfc6373bb4 | ||
|
|
1b81e6d8ec | ||
|
|
591309f335 | ||
|
|
b339736a6f | ||
|
|
89ef6c3f13 | ||
|
|
67ca98018e | ||
|
|
7337c75b26 | ||
|
|
4dee7d8a80 | ||
|
|
b004701601 | ||
|
|
929c3a9a27 | ||
|
|
5ca914b32e | ||
|
|
d440458794 | ||
|
|
b3f8f9f51f | ||
|
|
2d28ca61a4 | ||
|
|
7599f2384e | ||
|
|
9e598c8855 | ||
|
|
7e75e50650 | ||
|
|
3ad160d0d8 | ||
|
|
314fef82a5 | ||
|
|
159c026533 | ||
|
|
b9b3fef202 | ||
|
|
ee1edc9c27 | ||
|
|
b38991fd25 | ||
|
|
3234e0f0a9 | ||
|
|
a9987bb795 | ||
|
|
d44c8f7912 | ||
|
|
2e79446667 | ||
|
|
16692aee16 | ||
|
|
67ea727686 | ||
|
|
f239922fb6 | ||
|
|
60e06e65c2 | ||
|
|
c4c20cf77d | ||
|
|
6253216484 | ||
|
|
c5a40f6c37 | ||
|
|
abe090881a | ||
|
|
e65116d9e3 | ||
|
|
f82404392f | ||
|
|
ddc686075b | ||
|
|
56e61c8d7c | ||
|
|
34cae662f3 | ||
|
|
5dcd439901 | ||
|
|
4eec01e2ad | ||
|
|
3ae35a3b5d | ||
|
|
673633a773 | ||
|
|
e9f3248fc0 | ||
|
|
6959eb2d27 | ||
|
|
6f8d0521f4 | ||
|
|
de375f0e77 | ||
|
|
833adaf7e9 | ||
|
|
3e777dfaeb | ||
|
|
8cfc91df95 | ||
|
|
ecea455b7f | ||
|
|
041ad9cd94 | ||
|
|
eb64904ba3 | ||
|
|
a3e05ff98c | ||
|
|
f335678a78 | ||
|
|
b5b48e7992 | ||
|
|
c5d2bac26f | ||
|
|
22460a460b | ||
|
|
7bef84a8d0 | ||
|
|
7e8dc0f848 | ||
|
|
74704bb699 | ||
|
|
cb66f96b63 | ||
|
|
0ebda5d483 | ||
|
|
5208400b24 | ||
|
|
1960046994 | ||
|
|
3c9cf81fb3 | ||
|
|
8d893d3ed1 | ||
|
|
d1cf91a1ea | ||
|
|
2362133b25 | ||
|
|
f9d05f7a0e | ||
|
|
dfe3e55e74 | ||
|
|
881de59bf5 | ||
|
|
e0a391d46e | ||
|
|
b044c6fb70 | ||
|
|
06482388bd | ||
|
|
5653282dea | ||
|
|
624a7a036d | ||
|
|
5c3d399ccb | ||
|
|
0a308eb13d | ||
|
|
e487f295da | ||
|
|
63983c9d60 | ||
|
|
2e61eb41b2 | ||
|
|
ad4bcfafce | ||
|
|
2faf7e6806 | ||
|
|
a2bb210ebd | ||
|
|
f3d49fe59e | ||
|
|
fa4ecf09fb | ||
|
|
78d340786f | ||
|
|
7abd8adf9c | ||
|
|
17b8ec65e4 | ||
|
|
c03ff0a543 | ||
|
|
8fa0d0f8bb | ||
|
|
567a7c8f12 | ||
|
|
514fca7a53 | ||
|
|
67a7c578b1 | ||
|
|
0df0b6a0f2 | ||
|
|
22d2bae46b | ||
|
|
1f1e364a30 | ||
|
|
65d8b2d45c | ||
|
|
b11398f551 | ||
|
|
8cd420173c | ||
|
|
d276763b11 | ||
|
|
0f0513b8b7 | ||
|
|
a9e519f643 | ||
|
|
8f86e0b164 | ||
|
|
ac3a4bcaab | ||
|
|
205c774ca3 | ||
|
|
abe5d53fc1 | ||
|
|
289cdefc27 | ||
|
|
1d96933f5f | ||
|
|
2e1cadb5da | ||
|
|
30e35fe068 | ||
|
|
7151c65f1a | ||
|
|
3a2224a4e4 | ||
|
|
33a4bb797d | ||
|
|
b8cd4c32ac | ||
|
|
bb4875443a | ||
|
|
d1cd83acea | ||
|
|
5a84adcd8a | ||
|
|
55d6f1ee35 | ||
|
|
e863b71dfa | ||
|
|
0f5550680a | ||
|
|
e77ddc3d2e | ||
|
|
c3b7b32af5 | ||
|
|
f4f953cca1 | ||
|
|
e2ef7216a8 | ||
|
|
18e0f13605 | ||
|
|
51b86b1d97 | ||
|
|
875e279def | ||
|
|
88db424a16 | ||
|
|
a8aeac22ee | ||
|
|
13f20dd7cf | ||
|
|
41c5ad45fd | ||
|
|
7fec9d64fa | ||
|
|
c95b8d0048 | ||
|
|
f65edba982 | ||
|
|
94c3bc7f19 | ||
|
|
bfd27f0d94 | ||
|
|
67b96d990c | ||
|
|
ba04533de3 | ||
|
|
547b40913f | ||
|
|
15d5333cf6 | ||
|
|
ad101e0a31 | ||
|
|
12d66cb9df | ||
|
|
3b321e5d3c | ||
|
|
d0ac4d41ed | ||
|
|
983c970069 | ||
|
|
3189f8b77a | ||
|
|
079016052d | ||
|
|
f0a5b7734f | ||
|
|
03e787ad5a | ||
|
|
dd09854932 | ||
|
|
b9428301b6 | ||
|
|
2344a4da13 | ||
|
|
4652878e6c | ||
|
|
4efc2914af | ||
|
|
9866e12a84 | ||
|
|
8998e41104 | ||
|
|
0a64715d84 | ||
|
|
879619e075 | ||
|
|
16cab18163 | ||
|
|
456ba9db04 | ||
|
|
1c89afc7ad | ||
|
|
16836d0cfb | ||
|
|
2c65e39482 | ||
|
|
65dc7c5b65 | ||
|
|
eb02efb25a | ||
|
|
864a873c57 | ||
|
|
fcba3bec03 | ||
|
|
4569a96e78 | ||
|
|
debbd980d9 | ||
|
|
d5e40faeb5 | ||
|
|
10edc1f4ac | ||
|
|
42c49a8263 | ||
|
|
7b0d5811d6 | ||
|
|
9d1e6f23db | ||
|
|
b4f3680876 | ||
|
|
7536473c9d | ||
|
|
fd8e4ef890 | ||
|
|
c8a16ce43b | ||
|
|
887bd66a33 | ||
|
|
eeb9ff662e | ||
|
|
262df1a1b2 | ||
|
|
3919d87486 | ||
|
|
6f203d14e3 | ||
|
|
456a4ba581 | ||
|
|
60d6c7be51 | ||
|
|
0f273c5efc | ||
|
|
33a45a8af5 | ||
|
|
6179b0cc56 | ||
|
|
9ded50b5fc | ||
|
|
7c34be272f | ||
|
|
6145c7ad90 | ||
|
|
5225fc4317 | ||
|
|
514cff7b3c | ||
|
|
eb6e092a21 | ||
|
|
7d2c0ab405 | ||
|
+22 |
01631e808f | ||
|
|
3af6b3b6b3 | ||
|
|
bba9b755bc | ||
|
|
ef523bda51 | ||
|
|
0761e8664a | ||
|
|
6b0cffa79a | ||
|
|
843daad481 | ||
|
|
375517486c | ||
|
|
bc74014040 | ||
|
|
3035412cdc | ||
|
|
f7cec8d71f | ||
|
|
b22c562719 | ||
|
|
e493b16466 | ||
|
|
d1f117f17a | ||
|
|
af5bc9d530 | ||
|
|
d94267cdc1 | ||
|
|
a15fe78ddb | ||
|
|
e270be1b87 | ||
|
|
8fe969d3b8 | ||
|
|
9fced41cd0 | ||
|
|
b3e9a38231 | ||
|
|
c144c365de | ||
|
|
00e728e968 | ||
|
|
be92cc3563 | ||
|
|
99c6b92bfe | ||
|
|
ce0fb6c09e | ||
|
|
dfd6bc1523 | ||
|
|
a542930b87 | ||
|
|
46752bd0d8 | ||
|
|
0458203281 | ||
|
|
7b01bae829 | ||
|
|
b869e70a7f | ||
|
|
411497575a | ||
|
|
e1bf425c1c | ||
|
|
445cdd7cbf | ||
|
|
d6678f9b3a | ||
|
|
01025fa34c | ||
|
|
b973f971a7 | ||
|
|
2f832800fc | ||
|
|
9df4867fca | ||
|
|
6940c8d6cc | ||
|
|
24a3bfdf78 | ||
|
|
9710c6faa6 | ||
|
|
a0a4ff6ce6 | ||
|
|
c67338bb3f | ||
|
|
727c6f723e | ||
|
|
40c81ee405 | ||
|
|
7c3090bc23 | ||
|
|
1bd6d03352 | ||
|
|
058550ba3d | ||
|
|
6c248bb9f0 | ||
|
|
29b09ce85d | ||
|
|
ba4d2425cf | ||
|
|
49b361ab9c | ||
|
|
1bf009f5f5 | ||
|
|
e16eee68b0 | ||
|
|
07aa2e88a1 | ||
|
|
dc934ed8ca | ||
|
|
29bb6711bb | ||
|
|
94966b1643 | ||
|
|
7548233a96 | ||
|
|
9fba3f3767 | ||
|
|
db784f8333 | ||
|
|
a48a164e71 | ||
|
|
eacfdac016 | ||
|
|
323524b77c | ||
|
|
c8d6c0dbdd | ||
|
|
cc0ea19420 | ||
|
|
a551aa0089 | ||
|
|
0ed0891d97 | ||
|
|
f0a36f8194 | ||
|
|
23576038aa | ||
|
|
0c44d9e0a6 | ||
|
|
67770bf878 | ||
|
+21 |
81d33b51bc | ||
|
|
162c6c10a8 | ||
|
|
7d7a1c5bbc | ||
|
|
53a7b1cc37 | ||
|
|
f9f4a5aeb9 | ||
|
|
066fce9d73 | ||
|
|
3a1d8b8183 | ||
|
|
38f23986ad | ||
|
|
27422c351c | ||
|
|
c5ad74f61c | ||
|
|
0eff265682 | ||
|
|
2560b72596 | ||
|
|
7d7c61a1ab | ||
|
|
c92283f03d | ||
|
|
ba9be4e69d | ||
|
|
b334586c3b | ||
|
|
a38c76e279 | ||
|
|
6973d26153 | ||
|
|
e62af90619 | ||
|
|
e72cceff81 | ||
|
|
1c296fb2d5 | ||
|
|
1de50bd093 | ||
|
+3 |
4b6c701c48 | ||
|
|
463aff0702 | ||
|
|
c1cc93c164 | ||
|
|
6148e89425 | ||
|
|
a7f7b29bae | ||
|
|
afafd45223 | ||
|
|
31aff64e42 | ||
|
|
952a149604 | ||
|
|
3aa7387ff5 | ||
|
|
f8007a4f01 | ||
|
|
1fb637d29d | ||
|
|
80954aa866 | ||
|
|
821105dccb | ||
|
|
5a124cec72 | ||
|
|
86a4037088 | ||
|
|
faa2805ccc | ||
|
|
ca3e309ad9 | ||
|
|
fc59338057 | ||
|
|
2d56096d8f | ||
|
|
8545bfef67 | ||
|
|
8a9b7ca78c | ||
|
|
c910b41043 | ||
|
|
d51aade461 | ||
|
|
fa837a5d94 | ||
|
|
1b59dacd64 | ||
|
|
adf817766e | ||
|
|
98d7d7a11c | ||
|
|
4fa380d7ed | ||
|
|
39031123e1 | ||
|
|
b718718bd6 | ||
|
|
0153bc583a | ||
|
|
fbbda20792 | ||
|
|
ef8018d2f7 | ||
|
|
46cd74b0b8 | ||
|
|
db01f7be19 | ||
|
|
25c550de3a | ||
|
|
683e8c4490 | ||
|
|
a0296b8ccb | ||
|
|
6771960e9e | ||
|
|
1c4d2fae56 | ||
|
|
3b1dff3b83 | ||
|
|
84967e5cdf | ||
|
|
6b758eeffa | ||
|
|
8265c65e4c | ||
|
|
676268b62d | ||
|
|
9921c43c3f | ||
|
|
fc13a22162 | ||
|
|
6e2cb4f90d |
+10
-2
@@ -15,6 +15,9 @@
|
||||
# - You can not repackage or sell the codebase
|
||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
# To enable enterprise-only features, fill your license key in here.
|
||||
# @see https://console.cal.com
|
||||
CALCOM_LICENSE_KEY=
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
@@ -25,6 +28,7 @@ NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
||||
# Change to 'http://localhost:3001' if running the website simultaneously
|
||||
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
||||
NEXT_PUBLIC_CONSOLE_URL='http://localhost:3004'
|
||||
NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js'
|
||||
|
||||
# To enable SAML login, set both these variables
|
||||
@@ -50,8 +54,8 @@ NEXTAUTH_SECRET=
|
||||
# Used for cross-domain cookie authentication
|
||||
NEXTAUTH_COOKIE_DOMAIN=.example.com
|
||||
|
||||
# Remove this var if you don't want Cal to collect anonymous usage
|
||||
NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r
|
||||
# Set this to '1' if you don't want Cal to collect anonymous usage
|
||||
CALCOM_TELEMETRY_DISABLED=
|
||||
|
||||
# ApiKey for cronjobs
|
||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||
@@ -70,6 +74,9 @@ NEXT_PUBLIC_ZENDESK_KEY=
|
||||
# Help Scout Config
|
||||
NEXT_PUBLIC_HELPSCOUT_KEY=
|
||||
|
||||
# Inbox to send user feedback
|
||||
SEND_FEEDBACK_EMAIL=
|
||||
|
||||
# This is used so we can bypass emails in auth flows for E2E testing
|
||||
# Set it to "1" if you need to run E2E tests locally
|
||||
NEXT_PUBLIC_IS_E2E=
|
||||
@@ -79,6 +86,7 @@ NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT=
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
|
||||
@@ -8,6 +8,8 @@ Fixes # (issue)
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
**Environment**: Staging(main branch) / Production
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete options that are not relevant. -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Check types
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
name: Check types
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -16,11 +16,13 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: yarn
|
||||
cache: "yarn"
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn type-check
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: E2E tests Embed
|
||||
on:
|
||||
pull_request_target: # So we can test on forks
|
||||
branches:
|
||||
- main
|
||||
# Embed e2e - tests verify booking flow which is applicable to non-embed case also. So, don't ignore apps/web changes.
|
||||
paths-ignore:
|
||||
- apps/api/**
|
||||
- apps/console/**
|
||||
- apps/docs/**
|
||||
- apps/swagger/**
|
||||
- apps/website/**
|
||||
- apps/web/public/**
|
||||
- tests/**
|
||||
- playwright/**
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 20
|
||||
name: Embed and booking flow(for non-embed as well)
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
NEXT_PUBLIC_IS_E2E: 1
|
||||
# EMAIL_FROM: e2e@cal.com
|
||||
# EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
# EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
# EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
# EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||
# MS_GRAPH_CLIENT_ID: xxx
|
||||
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# ZOOM_CLIENT_ID: xxx
|
||||
# ZOOM_CLIENT_SECRET: xxx
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
||||
fetch-depth: 2
|
||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "yarn"
|
||||
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v2
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/ms-playwright
|
||||
~/.cache/ms-playwright
|
||||
${{ github.workspace }}/node_modules/playwright
|
||||
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: cache-playwright-
|
||||
- run: yarn --frozen-lockfile
|
||||
- name: Install playwright deps
|
||||
# if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Run Tests
|
||||
run: yarn turbo run embed-tests-update-snapshots:ci --scope=@calcom/embed-react --concurrency=1
|
||||
|
||||
- name: Upload embed-core results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-core
|
||||
path: packages/embeds/embed-core/playwright/results
|
||||
|
||||
- name: Upload embed-react results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-react
|
||||
path: packages/embeds/embed-react/playwright/results
|
||||
+13
-25
@@ -4,14 +4,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- public/static/locales/**
|
||||
- apps/web/public/static/locales/**
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 20
|
||||
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -43,6 +43,8 @@ jobs:
|
||||
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# ZOOM_CLIENT_ID: xxx
|
||||
# ZOOM_CLIENT_SECRET: xxx
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
@@ -58,23 +60,13 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks
|
||||
fetch-depth: 2
|
||||
|
||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
# cache: "yarn"
|
||||
# cache-dependency-path: yarn.lock
|
||||
cache: "yarn"
|
||||
|
||||
- name: Turbo Cache
|
||||
id: turbo-cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ github.job }}-${{ github.event.pull_request.head.ref }}-${{ github.event.pull_request.head.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ github.job }}-${{ github.event.pull_request.head.ref }}-
|
||||
- run: yarn
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v2
|
||||
id: playwright-cache
|
||||
@@ -82,22 +74,18 @@ jobs:
|
||||
path: |
|
||||
~/Library/Caches/ms-playwright
|
||||
~/.cache/ms-playwright
|
||||
**/node_modules/playwright
|
||||
${{ github.workspace }}/node_modules/playwright
|
||||
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
restore-keys: cache-playwright-
|
||||
- run: yarn --frozen-lockfile
|
||||
- name: Install playwright deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: yarn playwright install --with-deps
|
||||
- run: yarn test-e2e
|
||||
|
||||
- name: Upload videos
|
||||
- name: Upload test results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: videos
|
||||
path: |
|
||||
test-results
|
||||
playwright/screenshots
|
||||
playwright/videos
|
||||
playwright/results
|
||||
playwright/reports
|
||||
name: test-results
|
||||
path: test-results
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
lint:
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -18,16 +18,15 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Use Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
# cache: "yarn"
|
||||
# cache-dependency-path: yarn.lock
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install deps
|
||||
if: steps.yarn-cache.outputs.cache-hit != 'true'
|
||||
run: yarn
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint:report
|
||||
|
||||
@@ -72,3 +72,7 @@ dist
|
||||
|
||||
# Linting
|
||||
lint-results
|
||||
|
||||
# Snaplet
|
||||
.snaplet/snapshots
|
||||
.snaplet/structure.d.ts
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
[submodule "apps/admin"]
|
||||
path = apps/admin
|
||||
url = https://github.com/calcom/admin.git
|
||||
[submodule "apps/console"]
|
||||
path = apps/console
|
||||
url = https://github.com/calcom/console.git
|
||||
branch = main
|
||||
[submodule "apps/api"]
|
||||
path = apps/api
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
|
||||
yarn app-store:build && git add packages/app-store/*.generated.*
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"projectId": "cl4u26bwz7962859ply7ibuo43t",
|
||||
"databaseUrl": "postgresql://postgres@localhost:5450/calendso"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": "0.3.0"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// This transform config was generated by Snaplet.
|
||||
// Snaplet found fields that may contain personally identifiable information (PII)
|
||||
// and used that to populate this file.
|
||||
import { copycat } from "@snaplet/copycat";
|
||||
|
||||
import type { Transform } from "./structure";
|
||||
|
||||
function hasStringProp<T extends string>(x: unknown, key: T): x is { [key in T]: string } {
|
||||
return !!x && typeof x === "object" && key in x;
|
||||
}
|
||||
|
||||
function replaceKeyIfExists<T extends string>(x: object, key: T) {
|
||||
if (hasStringProp(x, key)) {
|
||||
return { ...x, [key]: copycat.uuid(x[key]) };
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
function generateSlug(x: string) {
|
||||
return copycat.words(x, { max: 3 }).split(" ").join("-");
|
||||
}
|
||||
|
||||
function replaceSensitiveKeys(record: object) {
|
||||
return {
|
||||
...record,
|
||||
...replaceKeyIfExists(record, "client_id"),
|
||||
...replaceKeyIfExists(record, "client_secret"),
|
||||
...replaceKeyIfExists(record, "public_key"),
|
||||
...replaceKeyIfExists(record, "api_key"),
|
||||
...replaceKeyIfExists(record, "signing_secret"),
|
||||
...replaceKeyIfExists(record, "access_token"),
|
||||
...replaceKeyIfExists(record, "refresh_token"),
|
||||
...replaceKeyIfExists(record, "stripe_user_id"),
|
||||
...replaceKeyIfExists(record, "stripe_publishable_key"),
|
||||
...replaceKeyIfExists(record, "accessToken"),
|
||||
...replaceKeyIfExists(record, "refreshToken"),
|
||||
...replaceKeyIfExists(record, "bot_user_id"),
|
||||
...replaceKeyIfExists(record, "app_id"),
|
||||
};
|
||||
}
|
||||
|
||||
const config: Transform = () => ({
|
||||
public: {
|
||||
_user_eventtype: ({ row }) => ({
|
||||
A: copycat.int(row.A),
|
||||
B: copycat.int(row.B),
|
||||
}),
|
||||
ApiKey: ({ row }) => ({
|
||||
note: copycat.sentence(row.note),
|
||||
}),
|
||||
App: ({ row }) => ({
|
||||
keys: replaceSensitiveKeys(row.keys),
|
||||
}),
|
||||
Attendee: ({ row }) => ({
|
||||
email: copycat.email(row.email),
|
||||
name: copycat.fullName(row.name),
|
||||
timeZone: copycat.timezone(row.timeZone),
|
||||
}),
|
||||
Availability: ({ row }) => ({
|
||||
// date: copycat.fullName(row.date),
|
||||
}),
|
||||
Credential: ({ row }) => ({
|
||||
key: typeof row.key === "string" ? copycat.uuid(row.key) : replaceSensitiveKeys(row.key),
|
||||
}),
|
||||
EventType: ({ row }) => ({
|
||||
slug: generateSlug(row.slug),
|
||||
timeZone: copycat.timezone(row.timeZone),
|
||||
}),
|
||||
Feedback: ({ row }) => ({
|
||||
// date: copycat.dateString(row.date),
|
||||
}),
|
||||
ResetPasswordRequest: ({ row }) => ({
|
||||
email: copycat.email(row.email),
|
||||
}),
|
||||
Schedule: ({ row }) => ({
|
||||
timeZone: copycat.timezone(row.timeZone),
|
||||
}),
|
||||
Team: ({ row }) => ({
|
||||
bio: copycat.sentence(row.bio),
|
||||
name: copycat.words(row.name, { max: 2 }),
|
||||
slug: generateSlug(row.slug),
|
||||
}),
|
||||
users: ({ row }) => ({
|
||||
bio: copycat.sentence(row.bio),
|
||||
email: copycat.email(row.email),
|
||||
name: copycat.fullName(row.name),
|
||||
password: copycat.password(row.password),
|
||||
timeZone: copycat.timezone(row.timeZone),
|
||||
username: generateSlug(row.username),
|
||||
}),
|
||||
VerificationToken: ({ row }) => ({
|
||||
token: copycat.uuid(row.token),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export default config;
|
||||
Vendored
+2
-1
@@ -7,6 +7,7 @@
|
||||
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
|
||||
"ban.spellright", // Spell check for docs
|
||||
"stripe.vscode-stripe", // stripe VSCode extension
|
||||
"Prisma.prisma" // syntax|format|completion for prisma
|
||||
"Prisma.prisma", // syntax|format|completion for prisma
|
||||
"rebornix.project-snippets" // Share useful snippets between collaborators
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"presentation": {
|
||||
"echo": false,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "dedicated",
|
||||
"showReuseMessage": true
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Launch Cal.com Development terminals",
|
||||
"dependsOn": [
|
||||
"Web App(3000)",
|
||||
"Website(3001)",
|
||||
"Embed Core(3100)",
|
||||
"Embed React(3101)",
|
||||
"Prisma Studio(5555)",
|
||||
"Maildev(587)"
|
||||
],
|
||||
// Mark as the default build task so cmd/ctrl+shift+b will create them
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
// Try start the task on folder open
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen"
|
||||
}
|
||||
},
|
||||
{
|
||||
// The name that shows up in terminal tab
|
||||
"label": "Web App(3000)",
|
||||
// The task will launch a shell
|
||||
"type": "shell",
|
||||
"command": "yarn dev",
|
||||
// Set the shell type
|
||||
// Mark as a background task to avoid the spinner animation on the terminal tab
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Website(3001)",
|
||||
"type": "shell",
|
||||
"command": "cd apps/website && yarn dev",
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Embed Core(3100)",
|
||||
"type": "shell",
|
||||
"command": "cd packages/embeds/embed-core && yarn dev",
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Embed React(3101)",
|
||||
"type": "shell",
|
||||
"command": "cd packages/embeds/embed-react && yarn dev",
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Prisma Studio(5555)",
|
||||
"type": "shell",
|
||||
"command": "yarn db-studio",
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Maildev(587)",
|
||||
"type": "shell",
|
||||
"command": "maildev -s 587",
|
||||
"isBackground": false,
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
-11
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
|
||||
@@ -4,6 +4,23 @@ Contributions are what make the open source community such an amazing place to b
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
## Areas of expertise
|
||||
|
||||
### Legend
|
||||
|
||||
✅ = has knowledge
|
||||
|
||||
🥇 = is their main priority
|
||||
|
||||
⚠️ = is the only one with knowledge
|
||||
|
||||
👀 = has no knowledge but wants to be onboarded
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://dynamic-svgs.vercel.app/image.svg?dark">
|
||||
<img alt="Areas of expertise table" src="https://dynamic-svgs.vercel.app/image.svg">
|
||||
</picture>
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
|
||||
@@ -18,22 +18,26 @@
|
||||
<a href="https://cal.com">Website</a>
|
||||
·
|
||||
<a href="https://github.com/calcom/cal.com/issues">Issues</a>
|
||||
·
|
||||
<a href="https://cal.com/roadmap">Roadmap</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cal.com/slack"><img src="https://img.shields.io/badge/Slack-calendso.slack.com-%234A154B" alt="Join Cal.com Slack"></a>
|
||||
<a href="https://www.producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
||||
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
|
||||
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=social"></a>
|
||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
||||
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
|
||||
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
||||
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
|
||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||
</p>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
@@ -80,7 +84,7 @@ To get a local copy up and running, please follow these simple steps.
|
||||
|
||||
Here is what you need to be able to run Cal.
|
||||
|
||||
- Node.js (Version: >=14.x <15)
|
||||
- Node.js (Version: >=14.x <17)
|
||||
- PostgreSQL
|
||||
- Yarn _(recommended)_
|
||||
|
||||
@@ -129,7 +133,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
|
||||
#### Manual setup
|
||||
|
||||
1. Configure environment variables in the .env file. Replace `<user>`, `<pass>`, `<db-host>`, `<db-port>` with their applicable values
|
||||
1. Configure environment variables in the `packages/prisma/.env` file. Replace `<user>`, `<pass>`, `<db-host>`, `<db-port>` with their applicable values
|
||||
|
||||
```
|
||||
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>'
|
||||
@@ -398,7 +402,7 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot othercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspotothercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
@@ -433,7 +437,7 @@ Special thanks to these amazing projects which help power Cal.com:
|
||||
- [Day.js](https://day.js.org/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Prisma](https://prisma.io/)
|
||||
|
||||
<a href="https://jitsu.com/?utm_source=cal.com-gihub"><img height="40px" src="https://jitsu.com/img/powered-by-jitsu.png?gh=true" alt="Jitsu.com"></a>
|
||||
|
||||
Cal.com is an [open startup](https://jitsu.com) and [Jitsu](https://github.com/jitsucom/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics.
|
||||
<a href="https://jitsu.com/?utm_source=cal.com-gihub"><img height="40px" src="https://jitsu.com/img/powered-by-jitsu.png?gh=true" alt="Jitsu.com"></a>
|
||||
|
||||
Cal.com is an [open startup](https://cal.com/open) and [Jitsu](https://github.com/jitsucom/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics.
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
"description": "Application Key for symmetric encryption and decryption. Must be 32 bytes for AES256 encryption algorithm.",
|
||||
"value": "secret"
|
||||
},
|
||||
"NEXTAUTH_SECRET": "secret"
|
||||
"NEXTAUTH_SECRET": "secret",
|
||||
"NEXTAUTH_URL": {
|
||||
"description": "Replace HEROKU_APP_NAME with the name given to your app",
|
||||
"value": "https://HEROKU_APP_NAME.herokuapp.com"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"postdeploy": "cd packages/prisma && npx prisma migrate deploy"
|
||||
|
||||
-1
Submodule apps/admin deleted from 943cd10de1
+1
-1
Submodule apps/api updated: be2d4338ee...6ed007d873
Submodule
+1
Submodule apps/console added at e89fa235e1
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const withNextra = require("nextra")({
|
||||
theme: "nextra-theme-docs",
|
||||
themeConfig: "./theme.config.js",
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iframe-resizer-react": "^1.1.0",
|
||||
"next": "^12.1.0",
|
||||
"next": "^12.1.6",
|
||||
"nextra": "^1.1.0",
|
||||
"nextra-theme-docs": "^1.2.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
"react": "^18.1.0",
|
||||
"react-dom": "^18.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/config": "*",
|
||||
"eslint": "^8.10.0"
|
||||
"eslint": "^8.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
-3623
File diff suppressed because it is too large
Load Diff
@@ -10,16 +10,16 @@
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.5.1",
|
||||
"isarray": "2.0.5",
|
||||
"next": "12.1.5",
|
||||
"next": "12.1.6",
|
||||
"openapi-snippet": "^0.13.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"swagger-ui-react": "4.10.3"
|
||||
"react": "18.1.0",
|
||||
"react-dom": "18.1.0",
|
||||
"swagger-ui-react": "4.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "17.0.27",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/react-dom": "17.0.14",
|
||||
"typescript": "4.6.3"
|
||||
"@types/node": "16.9.1",
|
||||
"@types/react": "18.0.9",
|
||||
"@types/react-dom": "18.0.4",
|
||||
"typescript": "4.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import Select from "react-select";
|
||||
import { OptionProps } from "react-select";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
interface AdditionalCalendarSelectorProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App["type"] }>) => {
|
||||
const { data } = optionProps;
|
||||
return (
|
||||
<InstallAppButton
|
||||
type={data.type}
|
||||
render={(installProps) => {
|
||||
return (
|
||||
<Button {...installProps} className="w-full" color="minimal">
|
||||
{/* eslint-disable @next/next/no-img-element */}
|
||||
{data.image && (
|
||||
<img className="float-left mr-3 inline h-5 w-5" src={data.image} alt={data.label} />
|
||||
)}
|
||||
<p>{data.label}</p>
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorProps): JSX.Element | null => {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: true }]);
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
const options = data.items.map((item) => ({
|
||||
label: item.name,
|
||||
slug: item.slug,
|
||||
image: item.imageSrc,
|
||||
type: item.type,
|
||||
}));
|
||||
return (
|
||||
<Select
|
||||
name={"additionalCalendar"}
|
||||
placeholder={t("connect_additional_calendar")}
|
||||
options={options}
|
||||
styles={{
|
||||
placeholder: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
color: "#3E3E3E",
|
||||
marginLeft: "3px",
|
||||
};
|
||||
},
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "2px",
|
||||
"@media only screen and (min-width: 640px)": {
|
||||
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||
maxWidth: "320px",
|
||||
},
|
||||
};
|
||||
},
|
||||
}}
|
||||
isSearchable={false}
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 font-medium text-gray-700 sm:text-sm"
|
||||
isLoading={isLoading}
|
||||
components={{ Option: ImageOption }}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdditionalCalendarSelector;
|
||||
+42
-21
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
CheckIcon,
|
||||
DocumentTextIcon,
|
||||
ExternalLinkIcon,
|
||||
FlagIcon,
|
||||
MailIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/solid";
|
||||
@@ -13,7 +15,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Button, SkeletonButton } from "@calcom/ui";
|
||||
|
||||
import Shell from "@components/Shell";
|
||||
import Badge from "@components/ui/Badge";
|
||||
@@ -59,7 +61,8 @@ export default function App({
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price);
|
||||
const [installedApp, setInstalledApp] = useState(false);
|
||||
const [installedAppCount, setInstalledAppCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
async function getInstalledApp(appCredentialType: string) {
|
||||
const queryParam = new URLSearchParams();
|
||||
@@ -70,9 +73,13 @@ export default function App({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then((data) => {
|
||||
setIsLoading(false);
|
||||
return data;
|
||||
});
|
||||
if (result.status === 200) {
|
||||
setInstalledApp(true);
|
||||
const res = await result.json();
|
||||
setInstalledAppCount(res.count);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@@ -82,11 +89,12 @@ export default function App({
|
||||
}
|
||||
getInstalledApp(type);
|
||||
}, [type]);
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
||||
return (
|
||||
<>
|
||||
<Shell large isPublic>
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<div className="bg-gray-50 px-4">
|
||||
<div className="bg-gray-50 px-8">
|
||||
<Link href="/apps">
|
||||
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
|
||||
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
|
||||
@@ -94,10 +102,7 @@ export default function App({
|
||||
</Link>
|
||||
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
||||
<div className="flex">
|
||||
{
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="h-16 w-16" src={logo} alt={name} />
|
||||
}
|
||||
<img className="h-16 w-16 rounded-sm" src={logo} alt={name} />
|
||||
<header className="px-4 py-2">
|
||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
||||
<h2 className="text-sm text-gray-500">
|
||||
@@ -107,19 +112,35 @@ export default function App({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
||||
{isGlobal || installedApp ? (
|
||||
<Button color="secondary" disabled title="This app is globally installed">
|
||||
{t("installed")}
|
||||
</Button>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
<Button data-testid="install-app-button" {...buttonProps}>
|
||||
{t("install_app")}
|
||||
{!isLoading ? (
|
||||
isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? (
|
||||
<div className="space-x-3">
|
||||
<Button StartIcon={CheckIcon} color="secondary" disabled>
|
||||
{installedAppCount > 0
|
||||
? t("active_install", { count: installedAppCount })
|
||||
: t("globally_install")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
<Button StartIcon={PlusIcon} data-testid="install-app-button" {...buttonProps}>
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
<Button data-testid="install-app-button" {...buttonProps}>
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<SkeletonButton width="24" height="10" />
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<small className="block text-right">
|
||||
@@ -135,7 +156,7 @@ export default function App({
|
||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
|
||||
</div>
|
||||
|
||||
<div className="justify-between px-4 py-10 md:flex">
|
||||
<div className="justify-between px-8 py-10 md:flex">
|
||||
<div className="prose-sm prose mb-6">{body}</div>
|
||||
<div className="md:max-w-80 flex-1 md:ml-8">
|
||||
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: "app_store",
|
||||
href: "/apps",
|
||||
},
|
||||
{
|
||||
name: "installed_apps",
|
||||
href: "/apps/installed",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AppsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
const { status } = useSession();
|
||||
const tabs = [
|
||||
{
|
||||
name: t("app_store"),
|
||||
href: "/apps",
|
||||
},
|
||||
{
|
||||
name: t("installed_apps"),
|
||||
href: "/apps/installed",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
export default function BookingsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
const tabs = [
|
||||
{
|
||||
name: t("upcoming"),
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: t("recurring"),
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: t("past"),
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: t("cancelled"),
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
const tabs = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BookingsShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useBrandColors } from "@calcom/embed-core";
|
||||
import { useBrandColors } from "@calcom/embed-core/embed-iframe";
|
||||
|
||||
const brandColor = "#292929";
|
||||
const brandTextColor = "#ffffff";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import classNames from "classnames";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
interface Props {
|
||||
@@ -24,6 +24,22 @@ const DestinationCalendarSelector = ({
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
// Extra styles to show prefixed text in react-select
|
||||
const content = (hidePlaceholder = false) => {
|
||||
if (!hidePlaceholder) {
|
||||
return {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
":before": {
|
||||
content: `'${t("select_destination_calendar")}:'`,
|
||||
display: "block",
|
||||
marginRight: 8,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const selected = query.data?.connectedCalendars
|
||||
.map((connected) => connected.calendars ?? [])
|
||||
@@ -52,23 +68,39 @@ const DestinationCalendarSelector = ({
|
||||
})) ?? [];
|
||||
return (
|
||||
<div className="relative" title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||
{/* There's no easy way to customize the displayed value for a Select, so we fake it. */}
|
||||
{!hidePlaceholder && (
|
||||
<div className="pointer-events-none absolute z-10 w-full">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="m-[1px] w-[calc(100%_-_40px)] overflow-hidden overflow-ellipsis whitespace-nowrap rounded-sm border-none leading-5">
|
||||
{t("select_destination_calendar")}: {selectedOption?.label || ""}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
name={"primarySelectedCalendar"}
|
||||
placeholder={!hidePlaceholder ? `${t("select_destination_calendar")}:` : undefined}
|
||||
options={options}
|
||||
styles={{
|
||||
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
option: (defaultStyles, state) => ({
|
||||
...defaultStyles,
|
||||
backgroundColor: state.isSelected
|
||||
? state.isFocused
|
||||
? "var(--brand-color)"
|
||||
: "var(--brand-color)"
|
||||
: state.isFocused
|
||||
? "var(--brand-color-dark-mode)"
|
||||
: "var(--brand-text-color)",
|
||||
}),
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "2px",
|
||||
"@media only screen and (min-width: 640px)": {
|
||||
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||
maxWidth: "320px",
|
||||
},
|
||||
};
|
||||
},
|
||||
}}
|
||||
isSearchable={false}
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-md border-gray-300 sm:text-sm"
|
||||
className={classNames(
|
||||
"mt-1 mb-2 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm",
|
||||
!hidePlaceholder && "font-medium"
|
||||
)}
|
||||
onChange={(option) => {
|
||||
setSelectedOption(option);
|
||||
if (!option) {
|
||||
|
||||
+430
-163
@@ -1,18 +1,18 @@
|
||||
import { CodeIcon, EyeIcon, SunIcon, ChevronRightIcon, ArrowLeftIcon } from "@heroicons/react/solid";
|
||||
import { ArrowLeftIcon, ChevronRightIcon, CodeIcon, EyeIcon, SunIcon } from "@heroicons/react/solid";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRef, useState } from "react";
|
||||
import { components, ControlProps, SingleValue } from "react-select";
|
||||
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
||||
import { components, ControlProps } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
import { Button, Switch } from "@calcom/ui";
|
||||
import { Dialog, DialogContent, DialogClose } from "@calcom/ui/Dialog";
|
||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { WEBAPP_URL, EMBED_LIB_URL } from "@lib/config/constants";
|
||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import NavTabs from "@components/NavTabs";
|
||||
@@ -20,8 +20,253 @@ import ColorPicker from "@components/ui/colorpicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type EmbedType = "inline" | "floating-popup" | "element-click";
|
||||
type EmbedFramework = "react" | "HTML";
|
||||
|
||||
const enum Theme {
|
||||
auto = "auto",
|
||||
light = "light",
|
||||
dark = "dark",
|
||||
}
|
||||
|
||||
type PreviewState = {
|
||||
inline: {
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
theme: Theme;
|
||||
floatingPopup: Record<string, string>;
|
||||
elementClick: Record<string, string>;
|
||||
palette: {
|
||||
brandColor: string;
|
||||
};
|
||||
};
|
||||
const queryParamsForDialog = ["embedType", "tabName", "eventTypeId"];
|
||||
|
||||
const getDimension = (dimension: string) => {
|
||||
if (dimension.match(/^\d+$/)) {
|
||||
dimension = `${dimension}%`;
|
||||
}
|
||||
return dimension;
|
||||
};
|
||||
|
||||
/**
|
||||
* It allows us to show code with certain reusable blocks indented according to the block variable placement
|
||||
* So, if you add a variable ${abc} with indentation of 4 spaces, it will automatically indent all newlines in `abc` with the same indent before constructing the final string
|
||||
* `A${var}C` with var = "B" -> partsWithoutBlock=['A','C'] blocksOrVariables=['B']
|
||||
*/
|
||||
const code = (partsWithoutBlock: TemplateStringsArray, ...blocksOrVariables: string[]) => {
|
||||
const constructedCode: string[] = [];
|
||||
for (let i = 0; i < partsWithoutBlock.length; i++) {
|
||||
const partWithoutBlock = partsWithoutBlock[i];
|
||||
// blocksOrVariables length would always be 1 less than partsWithoutBlock
|
||||
// So, last item should be concatenated as is.
|
||||
if (i >= blocksOrVariables.length) {
|
||||
constructedCode.push(partWithoutBlock);
|
||||
continue;
|
||||
}
|
||||
const block = blocksOrVariables[i];
|
||||
const indentedBlock: string[] = [];
|
||||
let indent = "";
|
||||
block.split("\n").forEach((line) => {
|
||||
indentedBlock.push(line);
|
||||
});
|
||||
// non-null assertion is okay because we know that we are referencing last element.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const indentationMatch = partWithoutBlock
|
||||
.split("\n")
|
||||
.at(-1)!
|
||||
.match(/(^[\t ]*).*$/);
|
||||
if (indentationMatch) {
|
||||
indent = indentationMatch[1];
|
||||
}
|
||||
constructedCode.push(partWithoutBlock + indentedBlock.join("\n" + indent));
|
||||
}
|
||||
return constructedCode.join("");
|
||||
};
|
||||
|
||||
const getInstructionString = ({
|
||||
apiName,
|
||||
instructionName,
|
||||
instructionArg,
|
||||
}: {
|
||||
apiName: string;
|
||||
instructionName: string;
|
||||
instructionArg: Record<string, unknown>;
|
||||
}) => {
|
||||
return `${apiName}("${instructionName}", ${JSON.stringify(instructionArg)});`;
|
||||
};
|
||||
|
||||
const getEmbedUIInstructionString = ({
|
||||
apiName,
|
||||
theme,
|
||||
brandColor,
|
||||
}: {
|
||||
apiName: string;
|
||||
theme?: string;
|
||||
brandColor: string;
|
||||
}) => {
|
||||
theme = theme !== "auto" ? theme : undefined;
|
||||
return getInstructionString({
|
||||
apiName,
|
||||
instructionName: "ui",
|
||||
instructionArg: {
|
||||
theme,
|
||||
styles: {
|
||||
branding: {
|
||||
brandColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Codes: Record<string, Record<string, (...args: any[]) => string>> = {
|
||||
react: {
|
||||
inline: ({
|
||||
calLink,
|
||||
uiInstructionCode,
|
||||
previewState,
|
||||
}: {
|
||||
calLink: string;
|
||||
uiInstructionCode: string;
|
||||
previewState: PreviewState;
|
||||
}) => {
|
||||
const width = getDimension(previewState.inline.width);
|
||||
const height = getDimension(previewState.inline.height);
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
|
||||
function MyComponent() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
const cal = await getCalApi();
|
||||
${uiInstructionCode}
|
||||
})();
|
||||
}, [])
|
||||
return <Cal calLink="${calLink}" style={{width:"${width}",height:"${height}",overflow:"scroll"}} />;
|
||||
};`;
|
||||
},
|
||||
"floating-popup": ({
|
||||
floatingButtonArg,
|
||||
uiInstructionCode,
|
||||
}: {
|
||||
floatingButtonArg: string;
|
||||
uiInstructionCode: string;
|
||||
}) => {
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
|
||||
function MyComponent() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
const cal = await getCalApi();
|
||||
Cal("floatingButton", ${floatingButtonArg});
|
||||
${uiInstructionCode}
|
||||
})();
|
||||
}, [])
|
||||
};`;
|
||||
},
|
||||
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
||||
return code`
|
||||
import Cal, { getCalApi } from "@calcom/embed-react";
|
||||
|
||||
function MyComponent() {
|
||||
useEffect(()=>{
|
||||
(async function () {
|
||||
const cal = await getCalApi();
|
||||
${uiInstructionCode}
|
||||
})();
|
||||
}, [])
|
||||
return <button data-cal-link="${calLink}" />;
|
||||
};`;
|
||||
},
|
||||
},
|
||||
HTML: {
|
||||
inline: ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
||||
return code`Cal("inline", {
|
||||
elementOrSelector:"#my-cal-inline",
|
||||
calLink: "${calLink}"
|
||||
});
|
||||
|
||||
${uiInstructionCode}`;
|
||||
},
|
||||
|
||||
"floating-popup": ({
|
||||
floatingButtonArg,
|
||||
uiInstructionCode,
|
||||
}: {
|
||||
floatingButtonArg: string;
|
||||
uiInstructionCode: string;
|
||||
}) => {
|
||||
return code`Cal("floatingButton", ${floatingButtonArg});
|
||||
${uiInstructionCode}`;
|
||||
},
|
||||
"element-click": ({ calLink, uiInstructionCode }: { calLink: string; uiInstructionCode: string }) => {
|
||||
return code`// Important: Make sure to add \`data-cal-link="${calLink}"\` attribute to the element you want to open Cal on click
|
||||
${uiInstructionCode}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const getEmbedTypeSpecificString = ({
|
||||
embedFramework,
|
||||
embedType,
|
||||
calLink,
|
||||
previewState,
|
||||
}: {
|
||||
embedFramework: EmbedFramework;
|
||||
embedType: EmbedType;
|
||||
calLink: string;
|
||||
previewState: PreviewState;
|
||||
}) => {
|
||||
const frameworkCodes = Codes[embedFramework];
|
||||
if (!frameworkCodes) {
|
||||
throw new Error(`No code available for the framework:${embedFramework}`);
|
||||
}
|
||||
let uiInstructionStringArg = undefined;
|
||||
if (embedFramework === "react") {
|
||||
uiInstructionStringArg = {
|
||||
apiName: "cal",
|
||||
theme: previewState.theme,
|
||||
brandColor: previewState.palette.brandColor,
|
||||
};
|
||||
} else {
|
||||
uiInstructionStringArg = {
|
||||
apiName: "Cal",
|
||||
theme: previewState.theme,
|
||||
brandColor: previewState.palette.brandColor,
|
||||
};
|
||||
}
|
||||
if (!frameworkCodes[embedType]) {
|
||||
throw new Error(`Code not available for framework:${embedFramework} and embedType:${embedType}`);
|
||||
}
|
||||
if (embedType === "inline") {
|
||||
return frameworkCodes[embedType]({
|
||||
calLink,
|
||||
uiInstructionCode: getEmbedUIInstructionString(uiInstructionStringArg),
|
||||
previewState,
|
||||
});
|
||||
} else if (embedType === "floating-popup") {
|
||||
const floatingButtonArg = {
|
||||
calLink,
|
||||
...previewState.floatingPopup,
|
||||
};
|
||||
return frameworkCodes[embedType]({
|
||||
floatingButtonArg: JSON.stringify(floatingButtonArg),
|
||||
uiInstructionCode: getEmbedUIInstructionString(uiInstructionStringArg),
|
||||
previewState,
|
||||
});
|
||||
} else if (embedType === "element-click") {
|
||||
return frameworkCodes[embedType]({
|
||||
calLink,
|
||||
uiInstructionCode: getEmbedUIInstructionString(uiInstructionStringArg),
|
||||
previewState,
|
||||
});
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const embeds: {
|
||||
illustration: React.ReactElement;
|
||||
title: string;
|
||||
@@ -98,10 +343,6 @@ const embeds: {
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
@@ -128,10 +369,6 @@ const embeds: {
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="2" fill="#292929" />
|
||||
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
@@ -207,41 +444,133 @@ const embeds: {
|
||||
<rect x="157" y="172" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" stroke="#292929" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
const tabs = [
|
||||
{
|
||||
name: "HTML",
|
||||
tabName: "embed-code",
|
||||
icon: CodeIcon,
|
||||
type: "code",
|
||||
Component: forwardRef<
|
||||
HTMLTextAreaElement | HTMLIFrameElement | null,
|
||||
{ embedType: EmbedType; calLink: string; previewState: PreviewState }
|
||||
>(function EmbedHtml({ embedType, calLink, previewState }, ref) {
|
||||
const { t } = useLocale();
|
||||
if (ref instanceof Function || !ref) {
|
||||
return null;
|
||||
}
|
||||
if (ref.current && !(ref.current instanceof HTMLTextAreaElement)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
||||
<TextArea
|
||||
data-testid="embed-code"
|
||||
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
|
||||
name="embed-code"
|
||||
className="h-[36rem]"
|
||||
readOnly
|
||||
value={
|
||||
`<!-- Cal ${embedType} embed code begins -->\n` +
|
||||
(embedType === "inline"
|
||||
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
|
||||
previewState.inline.height
|
||||
)};overflow:scroll" id="my-cal-inline"></div>\n`
|
||||
: "") +
|
||||
`<script type="text/javascript">
|
||||
${getEmbedSnippetString()}
|
||||
${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previewState })}
|
||||
</script>
|
||||
<!-- Cal ${embedType} embed code ends -->`
|
||||
}></TextArea>
|
||||
<p className="hidden text-sm text-gray-500">
|
||||
{t(
|
||||
"Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options."
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "React",
|
||||
tabName: "embed-react",
|
||||
icon: CodeIcon,
|
||||
type: "code",
|
||||
Component: forwardRef<
|
||||
HTMLTextAreaElement | HTMLIFrameElement | null,
|
||||
{ embedType: EmbedType; calLink: string; previewState: PreviewState }
|
||||
>(function EmbedReact({ embedType, calLink, previewState }, ref) {
|
||||
const { t } = useLocale();
|
||||
if (ref instanceof Function || !ref) {
|
||||
return null;
|
||||
}
|
||||
if (ref.current && !(ref.current instanceof HTMLTextAreaElement)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<small className="flex py-4 text-neutral-500">{t("create_update_react_component")}</small>
|
||||
<TextArea
|
||||
data-testid="embed-react"
|
||||
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
|
||||
name="embed-react"
|
||||
className="h-[36rem]"
|
||||
readOnly
|
||||
value={`/* First make sure that you have installed the package */
|
||||
|
||||
/* If you are using yarn */
|
||||
// yarn add @calcom/embed-react
|
||||
|
||||
/* If you are using npm */
|
||||
// npm install @calcom/embed-react
|
||||
${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, previewState })}
|
||||
`}></TextArea>
|
||||
</>
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Preview",
|
||||
tabName: "embed-preview",
|
||||
icon: EyeIcon,
|
||||
type: "iframe",
|
||||
Component: forwardRef<
|
||||
HTMLIFrameElement | HTMLTextAreaElement | null,
|
||||
{ calLink: string; embedType: EmbedType }
|
||||
>(function Preview({ calLink, embedType }, ref) {
|
||||
if (ref instanceof Function || !ref) {
|
||||
return null;
|
||||
}
|
||||
if (ref.current && !(ref.current instanceof HTMLIFrameElement)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
ref={ref as typeof ref & MutableRefObject<HTMLIFrameElement>}
|
||||
data-testid="embed-preview"
|
||||
className="border-1 h-[75vh] border"
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`${WEBAPP_URL}/embed/preview.html?embedType=${embedType}&calLink=${calLink}`}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function getEmbedSnippetString() {
|
||||
// TODO: Import this string from @calcom/embed-snippet
|
||||
return `
|
||||
(function (C, A, L) { let p = function (a, ar) { a.q.push(ar); }; let d = C.document; C.Cal = C.Cal || function () { let cal = C.Cal; let ar = arguments; if (!cal.loaded) { cal.ns = {}; cal.q = cal.q || []; d.head.appendChild(d.createElement("script")).src = A; cal.loaded = true; } if (ar[0] === L) { const api = function () { p(api, arguments); }; const namespace = ar[1]; api.q = api.q || []; typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar); return; } p(cal, ar); }; })(window, "${EMBED_LIB_URL}", "init");
|
||||
return `(function (C, A, L) { let p = function (a, ar) { a.q.push(ar); }; let d = C.document; C.Cal = C.Cal || function () { let cal = C.Cal; let ar = arguments; if (!cal.loaded) { cal.ns = {}; cal.q = cal.q || []; d.head.appendChild(d.createElement("script")).src = A; cal.loaded = true; } if (ar[0] === L) { const api = function () { p(api, arguments); }; const namespace = ar[1]; api.q = api.q || []; typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar); return; } p(cal, ar); }; })(window, "${EMBED_LIB_URL}", "init");
|
||||
Cal("init", {origin:"${WEBAPP_URL}"});
|
||||
`;
|
||||
}
|
||||
|
||||
const EmbedNavBar = () => {
|
||||
const { t } = useLocale();
|
||||
const tabs = [
|
||||
{
|
||||
name: t("Embed"),
|
||||
tabName: "embed-code",
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Preview"),
|
||||
tabName: "embed-preview",
|
||||
icon: EyeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return <NavTabs data-testid="embed-tabs" tabs={tabs} linkProps={{ shallow: true }} />;
|
||||
};
|
||||
const ThemeSelectControl = ({ children, ...props }: ControlProps<any, false>) => {
|
||||
const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: Theme; label: string }, false>) => {
|
||||
return (
|
||||
<components.Control {...props}>
|
||||
<SunIcon className="h-[32px] w-[32px] text-gray-500" />
|
||||
@@ -299,7 +628,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const embedCode = useRef<HTMLTextAreaElement>(null);
|
||||
const embedCodeRefs: Record<typeof tabs[0]["name"], RefObject<HTMLTextAreaElement>> = {};
|
||||
tabs
|
||||
.filter((tab) => tab.type === "code")
|
||||
.forEach((codeTab) => {
|
||||
embedCodeRefs[codeTab.name] = createRef();
|
||||
});
|
||||
|
||||
const refOfEmbedCodesRefs = useRef(embedCodeRefs);
|
||||
const embed = embeds.find((embed) => embed.type === embedType);
|
||||
|
||||
const { data: eventType, isLoading } = trpc.useQuery([
|
||||
@@ -316,7 +652,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
theme: "auto",
|
||||
theme: Theme.auto,
|
||||
floatingPopup: {},
|
||||
elementClick: {},
|
||||
palette: {
|
||||
@@ -363,49 +699,6 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
eventType.slug
|
||||
}`;
|
||||
|
||||
// TODO: Not sure how to make these template strings look better formatted.
|
||||
// This exact formatting is required to make the code look nicely formatted together.
|
||||
const getEmbedUIInstructionString = () =>
|
||||
`Cal("ui", {
|
||||
${getThemeForSnippet() ? 'theme: "' + previewState.theme + '",\n ' : ""}styles: {
|
||||
branding: ${JSON.stringify(previewState.palette)}
|
||||
}
|
||||
})`;
|
||||
|
||||
const getEmbedTypeSpecificString = () => {
|
||||
if (embedType === "inline") {
|
||||
return `
|
||||
Cal("inline", {
|
||||
elementOrSelector:"#my-cal-inline",
|
||||
calLink: "${calLink}"
|
||||
});
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
} else if (embedType === "floating-popup") {
|
||||
let floatingButtonArg = {
|
||||
calLink,
|
||||
...previewState.floatingPopup,
|
||||
};
|
||||
return `
|
||||
Cal("floatingButton", ${JSON.stringify(floatingButtonArg)});
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
} else if (embedType === "element-click") {
|
||||
return `//Important: Also, add data-cal-link="${calLink}" attribute to the element you want to open Cal on click
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getThemeForSnippet = () => {
|
||||
return previewState.theme !== "auto" ? previewState.theme : null;
|
||||
};
|
||||
|
||||
const getDimension = (dimension: string) => {
|
||||
if (dimension.match(/^\d+$/)) {
|
||||
dimension = `${dimension}%`;
|
||||
}
|
||||
return dimension;
|
||||
};
|
||||
|
||||
const addToPalette = (update: typeof previewState["palette"]) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
@@ -418,7 +711,7 @@ ${getEmbedUIInstructionString().trim()}`;
|
||||
});
|
||||
};
|
||||
|
||||
const previewInstruction = (instruction: { name: string; arg: any }) => {
|
||||
const previewInstruction = (instruction: { name: string; arg: unknown }) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
mode: "cal:preview",
|
||||
@@ -475,9 +768,9 @@ ${getEmbedUIInstructionString().trim()}`;
|
||||
}
|
||||
|
||||
const ThemeOptions = [
|
||||
{ value: "auto", label: "Auto Theme" },
|
||||
{ value: "dark", label: "Dark Theme" },
|
||||
{ value: "light", label: "Light Theme" },
|
||||
{ value: Theme.auto, label: "Auto Theme" },
|
||||
{ value: Theme.dark, label: "Dark Theme" },
|
||||
{ value: Theme.light, label: "Light Theme" },
|
||||
];
|
||||
|
||||
const FloatingPopupPositionOptions = [
|
||||
@@ -544,7 +837,7 @@ ${getEmbedUIInstructionString().trim()}`;
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
let width = e.target.value || "100%";
|
||||
const width = e.target.value || "100%";
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
@@ -690,22 +983,6 @@ ${getEmbedUIInstructionString().trim()}`;
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Button Color on Hover</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
"floating-popup-button-color-hover": color,
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</div> */}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
@@ -759,11 +1036,11 @@ ${getEmbedUIInstructionString().trim()}`;
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
//@ts-ignore - How to support dynamic palette names?
|
||||
addToPalette({
|
||||
[palette.name]: color,
|
||||
[palette.name as keyof typeof previewState["palette"]]: color,
|
||||
});
|
||||
}}></ColorPicker>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
@@ -773,63 +1050,53 @@ ${getEmbedUIInstructionString().trim()}`;
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2/3 bg-gray-50 p-6">
|
||||
<EmbedNavBar />
|
||||
<div>
|
||||
<div
|
||||
className={classNames(router.query.tabName === "embed-code" ? "block" : "hidden", "h-[75vh]")}>
|
||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
||||
<TextArea
|
||||
data-testid="embed-code"
|
||||
ref={embedCode}
|
||||
name="embed-code"
|
||||
className="h-[36rem]"
|
||||
readOnly
|
||||
value={
|
||||
`<!-- Cal ${embedType} embed code begins -->\n` +
|
||||
(embedType === "inline"
|
||||
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
|
||||
previewState.inline.height
|
||||
)};overflow:scroll" id="my-cal-inline"></div>\n`
|
||||
: "") +
|
||||
`<script type="text/javascript">
|
||||
${getEmbedSnippetString().trim()}
|
||||
${getEmbedTypeSpecificString().trim()}
|
||||
</script>
|
||||
<!-- Cal ${embedType} embed code ends -->`
|
||||
}></TextArea>
|
||||
<p className="hidden text-sm text-gray-500">
|
||||
{t(
|
||||
"Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={router.query.tabName == "embed-preview" ? "block" : "hidden"}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
data-testid="embed-preview"
|
||||
className="border-1 h-[75vh] border"
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`${WEBAPP_URL}/embed/preview.html?embedType=${embedType}&calLink=${calLink}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
if (!embedCode.current) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(embedCode.current.value);
|
||||
showToast(t("code_copied"), "success");
|
||||
}}>
|
||||
{t("copy_code")}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("Close")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<NavTabs data-testid="embed-tabs" tabs={tabs} linkProps={{ shallow: true }} />
|
||||
{tabs.map((tab) => {
|
||||
return (
|
||||
<div
|
||||
key={tab.tabName}
|
||||
className={classNames(router.query.tabName === tab.tabName ? "block" : "hidden")}>
|
||||
<div>
|
||||
<div className={classNames(tab.type === "code" ? "h-[75vh]" : "")}>
|
||||
{tab.type === "code" ? (
|
||||
<tab.Component
|
||||
embedType={embedType}
|
||||
calLink={calLink}
|
||||
previewState={previewState}
|
||||
ref={refOfEmbedCodesRefs.current[tab.name]}></tab.Component>
|
||||
) : (
|
||||
<tab.Component
|
||||
embedType={embedType}
|
||||
calLink={calLink}
|
||||
previewState={previewState}
|
||||
ref={iframeRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={router.query.tabName == "embed-preview" ? "block" : "hidden"}></div>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
{tab.type === "code" ? (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
const currentTabCodeEl = refOfEmbedCodesRefs.current[tab.name].current;
|
||||
if (!currentTabCodeEl) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(currentTabCodeEl.value);
|
||||
showToast(t("code_copied"), "success");
|
||||
}}>
|
||||
{t("copy_code")}
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("Close")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
export default function EmptyScreen({
|
||||
Icon,
|
||||
headline,
|
||||
description,
|
||||
}: {
|
||||
Icon: SVGComponent;
|
||||
headline: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-80 my-6 flex flex-col items-center justify-center rounded-sm border border-dashed">
|
||||
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-white">
|
||||
<Icon className="inline-block h-10 w-10 bg-white" />
|
||||
</div>
|
||||
<div className="max-w-[420px] text-center">
|
||||
<h2 className="mt-6 mb-1 text-lg font-medium">{headline}</h2>
|
||||
<p className="text-sm leading-6 text-gray-600">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
export function useViewerI18n() {
|
||||
return trpc.useQuery(["viewer.i18n"], {
|
||||
return trpc.useQuery(["viewer.public.i18n"], {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { DialogClose, DialogTrigger, Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
|
||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||
@@ -71,15 +71,15 @@ export default function ImageUploader({
|
||||
...props
|
||||
}: ImageUploaderProps) {
|
||||
const { t } = useLocale();
|
||||
const [imageSrc, setImageSrc] = useState<string | null>();
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>();
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const [{ result }, setFile] = useFileReader({
|
||||
method: "readAsDataURL",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setImageSrc(props.imageSrc);
|
||||
if (props.imageSrc) setImageSrc(props.imageSrc);
|
||||
}, [props.imageSrc]);
|
||||
|
||||
const onInputFile = (e: FileEvent<HTMLInputElement>) => {
|
||||
@@ -90,8 +90,9 @@ export default function ImageUploader({
|
||||
};
|
||||
|
||||
const showCroppedImage = useCallback(
|
||||
async (croppedAreaPixels) => {
|
||||
async (croppedAreaPixels: Area | null) => {
|
||||
try {
|
||||
if (!croppedAreaPixels) return;
|
||||
const croppedImage = await getCroppedImg(
|
||||
result as string /* result is always string when using readAsDataUrl */,
|
||||
croppedAreaPixels
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { AdminRequired } from "components/ui/AdminRequired";
|
||||
import noop from "lodash/noop";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ElementType, FC, Fragment, MouseEventHandler } from "react";
|
||||
import { FC, Fragment, MouseEventHandler } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
export interface NavTabProps {
|
||||
tabs: {
|
||||
@@ -12,7 +16,7 @@ export interface NavTabProps {
|
||||
href?: string;
|
||||
/** If you want to change query param tabName as per current tab */
|
||||
tabName?: string;
|
||||
icon?: ElementType;
|
||||
icon?: SVGComponent;
|
||||
adminRequired?: boolean;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
@@ -20,18 +24,19 @@ export interface NavTabProps {
|
||||
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="-mb-px flex space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse"
|
||||
className="no-scrollbar -mb-px flex space-x-5 overflow-x-scroll rtl:space-x-reverse sm:rtl:space-x-reverse"
|
||||
aria-label="Tabs"
|
||||
{...props}>
|
||||
{tabs.map((tab) => {
|
||||
let href: string;
|
||||
let isCurrent;
|
||||
if ((tab.tabName && tab.href) || (!tab.tabName && !tab.href)) {
|
||||
throw new Error("Use either tabName or href");
|
||||
}
|
||||
let href = "";
|
||||
let isCurrent;
|
||||
if (tab.href) {
|
||||
href = tab.href;
|
||||
isCurrent = router.asPath === tab.href;
|
||||
@@ -39,6 +44,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
href = "";
|
||||
isCurrent = router.query.tabName === tab.tabName;
|
||||
}
|
||||
|
||||
const onClick: MouseEventHandler = tab.tabName
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
@@ -49,12 +55,13 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
: () => {};
|
||||
: noop;
|
||||
|
||||
const Component = tab.adminRequired ? AdminRequired : Fragment;
|
||||
|
||||
return (
|
||||
<Component key={tab.name}>
|
||||
<Link key={tab.name} href={href!} {...linkProps}>
|
||||
<Link key={tab.name} href={href} {...linkProps}>
|
||||
<a
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
@@ -73,7 +80,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
<span>{t(tab.name)}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Component>
|
||||
|
||||
@@ -1,48 +1,66 @@
|
||||
import { CreditCardIcon, KeyIcon, LockClosedIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
KeyIcon,
|
||||
LockClosedIcon,
|
||||
UserGroupIcon,
|
||||
UserIcon,
|
||||
CodeIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import React, { ComponentProps } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import ErrorBoundary from "@lib/ErrorBoundary";
|
||||
|
||||
import NavTabs, { NavTabProps } from "./NavTabs";
|
||||
import NavTabs from "./NavTabs";
|
||||
import Shell from "./Shell";
|
||||
|
||||
export default function SettingsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: t("profile"),
|
||||
href: "/settings/profile",
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
name: t("security"),
|
||||
href: "/settings/security",
|
||||
icon: KeyIcon,
|
||||
},
|
||||
{
|
||||
name: t("teams"),
|
||||
href: "/settings/teams",
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
name: t("billing"),
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
name: t("admin"),
|
||||
href: "/settings/admin",
|
||||
icon: LockClosedIcon,
|
||||
adminRequired: true,
|
||||
},
|
||||
];
|
||||
const tabs = [
|
||||
{
|
||||
name: "profile",
|
||||
href: "/settings/profile",
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
name: "teams",
|
||||
href: "/settings/teams",
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
name: "security",
|
||||
href: "/settings/security",
|
||||
icon: KeyIcon,
|
||||
},
|
||||
{
|
||||
name: "developer",
|
||||
href: "/settings/developer",
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
href: "/settings/admin",
|
||||
icon: LockClosedIcon,
|
||||
adminRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsShell({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<>
|
||||
<Shell {...rest}>
|
||||
<div className="sm:mx-auto">
|
||||
<NavTabs tabs={tabs} />
|
||||
</div>
|
||||
<main className="max-w-4xl">{children}</main>
|
||||
</>
|
||||
<main className="max-w-4xl">
|
||||
<>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</>
|
||||
</main>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
+141
-109
@@ -10,15 +10,16 @@ import {
|
||||
MapIcon,
|
||||
MoonIcon,
|
||||
ViewGridIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { SessionContextValue, signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { Fragment, ReactNode, useEffect } from "react";
|
||||
import React, { Fragment, ReactNode, useEffect, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, {
|
||||
@@ -31,11 +32,12 @@ import LicenseBanner from "@ee/components/LicenseBanner";
|
||||
import TrialBanner from "@ee/components/TrialBanner";
|
||||
import HelpMenuItem from "@ee/components/support/HelpMenuItem";
|
||||
|
||||
import ErrorBoundary from "@lib/ErrorBoundary";
|
||||
import classNames from "@lib/classNames";
|
||||
import { WEBAPP_URL } from "@lib/config/constants";
|
||||
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
@@ -206,6 +208,7 @@ const Layout = ({
|
||||
<Fragment key={item.name}>
|
||||
<Link href={item.href}>
|
||||
<a
|
||||
aria-label={item.name}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "bg-neutral-100 text-neutral-900"
|
||||
@@ -247,7 +250,7 @@ const Layout = ({
|
||||
</div>
|
||||
<TrialBanner />
|
||||
<div
|
||||
className="rounded-sm pb-2 pl-3 pt-2 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2"
|
||||
className="rounded-sm pt-2 pb-2 pl-3 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2"
|
||||
data-testid="user-dropdown-trigger">
|
||||
<span className="hidden lg:inline">
|
||||
<UserDropdown />
|
||||
@@ -347,7 +350,7 @@ const Layout = ({
|
||||
"px-4 sm:px-6 md:px-8",
|
||||
props.flexChildrenContainer && "flex flex-1 flex-col"
|
||||
)}>
|
||||
{!props.isLoading ? props.children : props.customLoader}
|
||||
<ErrorBoundary>{!props.isLoading ? props.children : props.customLoader}</ErrorBoundary>
|
||||
</div>
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
{status === "authenticated" && (
|
||||
@@ -412,16 +415,9 @@ type LayoutProps = {
|
||||
};
|
||||
|
||||
export default function Shell(props: LayoutProps) {
|
||||
const router = useRouter();
|
||||
const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic);
|
||||
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => {
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
|
||||
});
|
||||
}, [telemetry, router.asPath]);
|
||||
const { isReady, Theme } = useTheme("light");
|
||||
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
@@ -430,7 +426,11 @@ export default function Shell(props: LayoutProps) {
|
||||
const { status } = useSession();
|
||||
|
||||
const isLoading =
|
||||
i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading;
|
||||
i18n.status === "loading" ||
|
||||
query.status === "loading" ||
|
||||
isRedirectingToOnboarding ||
|
||||
loading ||
|
||||
!isReady;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -444,6 +444,7 @@ export default function Shell(props: LayoutProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||
<MemoizedLayout plan={user?.plan} status={status} {...props} isLoading={isLoading} />
|
||||
</>
|
||||
@@ -460,9 +461,17 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
},
|
||||
});
|
||||
const utils = trpc.useContext();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const onHelpItemSelect = () => {
|
||||
setHelpOpen(false);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Dropdown open={menuOpen} onOpenChange={() => setHelpOpen(false)}>
|
||||
<DropdownMenuTrigger asChild onClick={() => setMenuOpen(true)}>
|
||||
<button className="group flex w-full cursor-pointer appearance-none items-center text-left">
|
||||
<span
|
||||
className={classNames(
|
||||
@@ -473,7 +482,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={process.env.NEXT_PUBLIC_WEBSITE_URL + "/" + user?.username + "/avatar.png"}
|
||||
src={WEBAPP_URL + "/" + user?.username + "/avatar.png"}
|
||||
alt={user?.username || "Nameless User"}
|
||||
/>
|
||||
}
|
||||
@@ -488,10 +497,14 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
<span className="flex flex-grow items-center truncate">
|
||||
<span className="flex-grow truncate text-sm">
|
||||
<span className="block truncate font-medium text-gray-900">
|
||||
{user?.username || "Nameless User"}
|
||||
{user?.name || "Nameless User"}
|
||||
</span>
|
||||
<span className="block truncate font-normal text-neutral-500">
|
||||
{user?.username ? `cal.com/${user.username}` : "No public page"}
|
||||
{user?.username
|
||||
? process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com"
|
||||
? `cal.com/${user.username}`
|
||||
: `/${user.username}`
|
||||
: "No public page"}
|
||||
</span>
|
||||
</span>
|
||||
<SelectorIcon
|
||||
@@ -502,97 +515,116 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portalled={true}>
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
onClick={() => {
|
||||
mutation.mutate({ away: !user?.away });
|
||||
utils.invalidateQueries("viewer.me");
|
||||
}}
|
||||
className="flex min-w-max cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
||||
<MoonIcon
|
||||
className={classNames(
|
||||
user?.away
|
||||
? "text-purple-500 group-hover:text-purple-700"
|
||||
: "text-gray-500 group-hover:text-gray-700",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{user?.away ? t("set_as_free") : t("set_as_away")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{user?.username && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||
<ExternalLinkIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("view_public_page")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuContent portalled={true} onInteractOutside={() => setMenuOpen(false)}>
|
||||
{helpOpen ? (
|
||||
<HelpMenuItem onHelpItemSelect={() => onHelpItemSelect()} />
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
onClick={() => {
|
||||
mutation.mutate({ away: !user?.away });
|
||||
utils.invalidateQueries("viewer.me");
|
||||
}}
|
||||
className="flex min-w-max cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
||||
<MoonIcon
|
||||
className={classNames(
|
||||
user?.away
|
||||
? "text-purple-500 group-hover:text-purple-700"
|
||||
: "text-gray-500 group-hover:text-gray-700",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{user?.away ? t("set_as_free") : t("set_as_away")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{user?.username && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||
<ExternalLinkIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" />{" "}
|
||||
{t("view_public_page")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
href="https://cal.com/slack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900">
|
||||
<svg
|
||||
viewBox="0 0 2447.6 2452.5"
|
||||
className={classNames(
|
||||
"text-gray-500 group-hover:text-gray-700",
|
||||
"mt-0.5 h-4 w-4 flex-shrink-0 ltr:mr-4 rtl:ml-4"
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipRule="evenodd" fillRule="evenodd">
|
||||
<path
|
||||
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
||||
fill="currentColor"></path>
|
||||
<path
|
||||
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
||||
fill="currentColor"></path>
|
||||
<path
|
||||
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
||||
fill="currentColor"></path>
|
||||
<path
|
||||
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
||||
fill="currentColor"></path>
|
||||
</g>
|
||||
</svg>
|
||||
{t("join_our_slack")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://cal.com/roadmap"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||
<MapIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("visit_roadmap")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<button
|
||||
className="flex w-full px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={() => setHelpOpen(true)}>
|
||||
<QuestionMarkCircleIcon
|
||||
className={classNames(
|
||||
"text-gray-500 group-hover:text-neutral-500",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{t("help")}
|
||||
</button>
|
||||
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
onClick={() => signOut({ callbackUrl: "/auth/logout" })}
|
||||
className="flex cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
||||
<LogoutIcon
|
||||
className={classNames(
|
||||
"text-gray-500 group-hover:text-gray-700",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("sign_out")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
href="https://cal.com/slack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900">
|
||||
<svg
|
||||
viewBox="0 0 2447.6 2452.5"
|
||||
className={classNames(
|
||||
"text-gray-500 group-hover:text-gray-700",
|
||||
"mt-0.5 h-4 w-4 flex-shrink-0 ltr:mr-4 rtl:ml-4"
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipRule="evenodd" fillRule="evenodd">
|
||||
<path
|
||||
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
||||
fill="currentColor"></path>
|
||||
<path
|
||||
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
||||
fill="currentColor"></path>
|
||||
<path
|
||||
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
||||
fill="currentColor"></path>
|
||||
<path
|
||||
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
||||
fill="currentColor"></path>
|
||||
</g>
|
||||
</svg>
|
||||
{t("join_our_slack")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://cal.com/roadmap"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||
<MapIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("visit_roadmap")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<HelpMenuItem />
|
||||
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
onClick={() => signOut({ callbackUrl: "/auth/logout" })}
|
||||
className="flex cursor-pointer px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900">
|
||||
<LogoutIcon
|
||||
className={classNames(
|
||||
"text-gray-500 group-hover:text-gray-700",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("sign_out")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { StarIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonAvatar, SkeletonText } from "@calcom/ui";
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
|
||||
function SkeletonLoader() {
|
||||
function SkeletonLoader({ className }: { className?: string }) {
|
||||
return (
|
||||
<>
|
||||
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100"></div>} />
|
||||
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100"></div>} {...{ className }} />
|
||||
<ul className="-mx-4 animate-pulse divide-y divide-neutral-200 rounded-sm border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
|
||||
@@ -4,7 +4,7 @@ import "@glidejs/glide/dist/css/glide.theme.min.css";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const Slider = <T extends unknown>({
|
||||
const Slider = <T extends string | unknown>({
|
||||
title = "",
|
||||
className = "",
|
||||
items,
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function SAMLLogin(props: Props) {
|
||||
const methods = useFormContext();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.samlTenantProduct", {
|
||||
const mutation = trpc.useMutation("viewer.public.samlTenantProduct", {
|
||||
onSuccess: async (data) => {
|
||||
await signIn("saml", {}, { tenant: data.tenant, product: data.product });
|
||||
},
|
||||
@@ -40,9 +40,7 @@ export default function SAMLLogin(props: Props) {
|
||||
event.preventDefault();
|
||||
|
||||
// track Google logins. Without personal data/payload
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
|
||||
);
|
||||
telemetry.event(telemetryEventTypes.googleLogin, collectPageParameters());
|
||||
|
||||
if (!props.hostedCal) {
|
||||
await signIn("saml", {}, { tenant: props.samlTenantID, product: props.samlProductID });
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { trpc } from "@lib/trpc";
|
||||
@@ -46,20 +46,17 @@ export function NewScheduleButton({ name = "new-schedule" }: { name?: string })
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="mb-4">
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("add_new_schedule")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
createMutation.mutate(values);
|
||||
}}>
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="mt-3 space-y-2">
|
||||
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
</label>
|
||||
|
||||
@@ -6,7 +6,7 @@ import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { GroupBase, Props, SingleValue } from "react-select";
|
||||
import { GroupBase, Props } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
@@ -62,7 +62,8 @@ const useOptions = () => {
|
||||
const filter = useCallback(
|
||||
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
||||
if (current) {
|
||||
setFilteredOptions([options.find((option) => option.value === dayjs(current).toDate().valueOf())!]);
|
||||
const currentOption = options.find((option) => option.value === dayjs(current).toDate().valueOf());
|
||||
if (currentOption) setFilteredOptions([currentOption]);
|
||||
} else
|
||||
setFilteredOptions(
|
||||
options.filter((option) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ScheduleListItem({
|
||||
isDeleting = false,
|
||||
}: {
|
||||
schedule: inferQueryOutput<"viewer.availability.list">["schedules"][number];
|
||||
deleteFunction: Function;
|
||||
deleteFunction: ({ scheduleId }: { scheduleId: number }) => void;
|
||||
isDeleting: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
@@ -1,64 +1,47 @@
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useSlots } from "@lib/hooks/useSlots";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import type { Slot } from "@server/routers/viewer/slots";
|
||||
|
||||
type AvailableTimesProps = {
|
||||
timeFormat: string;
|
||||
minimumBookingNotice: number;
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
recurringCount: number | undefined;
|
||||
eventTypeSlug: string;
|
||||
slotInterval: number | null;
|
||||
date: Dayjs;
|
||||
users: {
|
||||
username: string | null;
|
||||
}[];
|
||||
schedulingType: SchedulingType | null;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
slots?: Slot[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
slots = [],
|
||||
isLoading,
|
||||
date,
|
||||
eventLength,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
recurringCount,
|
||||
timeFormat,
|
||||
users,
|
||||
schedulingType,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
seatsPerTimeSlot,
|
||||
}) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
const { slots, loading, error } = useSlots({
|
||||
date,
|
||||
slotInterval,
|
||||
eventLength,
|
||||
schedulingType,
|
||||
users,
|
||||
minimumBookingNotice,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
eventTypeId,
|
||||
});
|
||||
|
||||
const [brand, setBrand] = useState("#292929");
|
||||
|
||||
@@ -77,9 +60,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto md:h-[364px]">
|
||||
{!loading &&
|
||||
slots?.length > 0 &&
|
||||
<div className="grid flex-grow grid-cols-2 gap-x-2 overflow-y-auto sm:block md:h-[364px]">
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
pathname: string;
|
||||
@@ -89,7 +71,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
pathname: "book",
|
||||
query: {
|
||||
...router.query,
|
||||
date: slot.time.format(),
|
||||
date: dayjs(slot.time).format(),
|
||||
type: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||
@@ -105,40 +87,70 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
bookingUrl.query.user = slot.users;
|
||||
}
|
||||
|
||||
// If event already has an attendee add booking id
|
||||
if (slot.bookingUid) {
|
||||
bookingUrl.query.bookingUid = slot.bookingUid;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={slot.time.format()}>
|
||||
<Link href={bookingUrl}>
|
||||
<a
|
||||
<div key={dayjs(slot.time).format()}>
|
||||
{/* Current there is no way to disable Next.js Links */}
|
||||
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-bookingdarker hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
|
||||
"text-primary-500 mb-2 block rounded-sm border bg-white py-4 font-medium opacity-25 dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 ",
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}
|
||||
data-testid="time">
|
||||
{dayjs.tz(slot.time, timeZone()).format(timeFormat)}
|
||||
</a>
|
||||
</Link>
|
||||
)}>
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && <p className={`text-sm`}>{t("booking_full")}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Link href={bookingUrl}>
|
||||
<a
|
||||
className={classNames(
|
||||
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}
|
||||
data-testid="time">
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && (
|
||||
<p
|
||||
className={`${
|
||||
slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.8
|
||||
? "text-rose-600"
|
||||
: slot.attendees && slot.attendees / seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-emerald-400"
|
||||
} text-sm`}>
|
||||
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
||||
{seatsPerTimeSlot} {t("seats_available")}
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && !error && !slots.length && (
|
||||
|
||||
{!isLoading && !slots.length && (
|
||||
<div className="-mt-4 flex h-full w-full flex-col content-center items-center justify-center">
|
||||
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <Loader />}
|
||||
|
||||
{error && (
|
||||
<div className="border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && !slots.length && (
|
||||
<>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText width="full" height="20" />
|
||||
</SkeletonContainer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline";
|
||||
import { PaperAirplaneIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
LocationMarkerIcon,
|
||||
PaperAirplaneIcon,
|
||||
PencilAltIcon,
|
||||
XIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { RefreshIcon } from "@heroicons/react/solid";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
@@ -16,9 +26,12 @@ import { TextArea } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { linkValueToString } from "@lib/linkValueToString";
|
||||
import { LocationType } from "@lib/location";
|
||||
import { parseRecurringDates } from "@lib/parseDate";
|
||||
import { inferQueryOutput, trpc, inferQueryInput } from "@lib/trpc";
|
||||
import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
@@ -37,6 +50,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const user = query.data;
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
@@ -49,9 +63,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Upcoming" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if (booking.listingStatus === "upcoming" && booking.recurringEventId !== null) {
|
||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
const res = await fetch("/api/book/confirm", {
|
||||
@@ -64,6 +78,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
if (!res.ok) {
|
||||
throw new HttpError({ statusCode: res.status });
|
||||
}
|
||||
setRejectionDialogIsOpen(false);
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
@@ -73,59 +88,84 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
);
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label:
|
||||
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("reject_all")
|
||||
: t("reject"),
|
||||
onClick: () => setRejectionDialogIsOpen(true),
|
||||
onClick: () => {
|
||||
setRejectionDialogIsOpen(true);
|
||||
},
|
||||
icon: BanIcon,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label:
|
||||
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("confirm_all")
|
||||
: t("confirm"),
|
||||
onClick: () => mutation.mutate(true),
|
||||
onClick: () => {
|
||||
mutation.mutate(true);
|
||||
},
|
||||
icon: CheckIcon,
|
||||
disabled: mutation.isLoading,
|
||||
color: "primary",
|
||||
},
|
||||
];
|
||||
|
||||
const bookedActions: ActionType[] = [
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
label: t("cancel"),
|
||||
label:
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("cancel_all_remaining")
|
||||
: t("cancel"),
|
||||
href: `/cancel/${booking.uid}`,
|
||||
icon: XIcon,
|
||||
},
|
||||
{
|
||||
id: "reschedule",
|
||||
label: t("reschedule"),
|
||||
icon: ClockIcon,
|
||||
id: "edit_booking",
|
||||
label: t("edit_booking"),
|
||||
icon: PencilAltIcon,
|
||||
actions: [
|
||||
{
|
||||
id: "edit",
|
||||
icon: PencilAltIcon,
|
||||
label: t("edit_booking"),
|
||||
id: "reschedule",
|
||||
icon: ClockIcon,
|
||||
label: t("reschedule_booking"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
icon: ClockIcon,
|
||||
icon: PaperAirplaneIcon,
|
||||
iconClassName: "rotate-45 w-[18px] -ml-[2px]",
|
||||
label: t("send_reschedule_request"),
|
||||
onClick: () => setIsOpenRescheduleDialog(true),
|
||||
onClick: () => {
|
||||
setIsOpenRescheduleDialog(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "change_location",
|
||||
label: t("edit_location"),
|
||||
onClick: () => {
|
||||
setIsOpenLocationDialog(true);
|
||||
},
|
||||
icon: LocationMarkerIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||
@@ -137,19 +177,80 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const setLocationMutation = trpc.useMutation("viewer.bookings.editLocation", {
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.invalidateQueries("viewer.bookings");
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate the booking date(s)
|
||||
const saveLocation = (newLocationType: LocationType, details: { [key: string]: string }) => {
|
||||
let newLocation = newLocationType as string;
|
||||
if (
|
||||
newLocationType === LocationType.InPerson ||
|
||||
newLocationType === LocationType.Link ||
|
||||
newLocationType === LocationType.UserPhone
|
||||
) {
|
||||
newLocation = details[Object.keys(details)[0]];
|
||||
}
|
||||
setLocationMutation.mutate({ bookingId: booking.id, newLocation });
|
||||
};
|
||||
|
||||
// Calculate the booking date(s) and setup recurring event data to show
|
||||
let recurringStrings: string[] = [];
|
||||
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== null) {
|
||||
[recurringStrings] = parseRecurringDates(
|
||||
let recurringDates: Date[] = [];
|
||||
const today = new Date();
|
||||
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: booking.startTime,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringEvent: parseRecurringEvent(booking.eventType.recurringEvent),
|
||||
recurringCount: booking.recurringCount,
|
||||
},
|
||||
i18n
|
||||
);
|
||||
if (booking.status === BookingStatus.PENDING) {
|
||||
// Only take into consideration next up instances if booking is confirmed
|
||||
recurringDates = recurringDates.filter((aDate) => aDate >= today);
|
||||
recurringStrings = recurringDates.map((_, key) => recurringStrings[key]);
|
||||
}
|
||||
}
|
||||
|
||||
let location = booking.location || "";
|
||||
|
||||
if (location.includes("integration")) {
|
||||
if (booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED) {
|
||||
location = t("web_conference");
|
||||
} else if (isConfirmed) {
|
||||
location = linkValueToString(booking.location, t);
|
||||
} else {
|
||||
location = t("web_conferencing_details_to_follow");
|
||||
}
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
router.push({
|
||||
pathname: "/success",
|
||||
query: {
|
||||
date: booking.startTime,
|
||||
// TODO: Booking when fetched should have id 0 already(for Dynamic Events).
|
||||
type: booking.eventType.id || 0,
|
||||
eventSlug: booking.eventType.slug,
|
||||
user: user?.username || "",
|
||||
name: booking.attendees[0] ? booking.attendees[0].name : undefined,
|
||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||
location: location,
|
||||
eventName: booking.eventType.eventName || "",
|
||||
bookingId: booking.id,
|
||||
recur: booking.recurringEventId,
|
||||
reschedule: isConfirmed,
|
||||
listingStatus: booking.listingStatus,
|
||||
status: booking.status,
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -157,6 +258,12 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
/>
|
||||
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
@@ -191,100 +298,102 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<tr className="flex">
|
||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-56">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{booking.recurringCount &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
booking.listingStatus === "upcoming" && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.map((aDate, key) => (
|
||||
<p key={key}>{aDate}</p>
|
||||
))}>
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{`${t("every_for_freq", {
|
||||
freq: t(
|
||||
`${RRuleFrequency[booking.eventType.recurringEvent.freq]
|
||||
.toString()
|
||||
.toLowerCase()}`
|
||||
),
|
||||
})} ${booking.recurringCount} ${t(
|
||||
`${RRuleFrequency[booking.eventType.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: booking.recurringCount }
|
||||
)}`}
|
||||
</p>
|
||||
</Tooltip>
|
||||
<tr className="flex hover:bg-neutral-50">
|
||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-64" onClick={onClick}>
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{booking.recurringCount &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
(booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.map((aDate, key) => (
|
||||
<p key={key}>{aDate}</p>
|
||||
))}>
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="float-left mr-1 mt-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<p className="pl-[21px]">
|
||||
{booking.status === BookingStatus.ACCEPTED
|
||||
? `${t("event_remaining", {
|
||||
count: recurringDates.length,
|
||||
})}`
|
||||
: getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringCount: booking.recurringCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
|
||||
<div className="sm:hidden">
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>
|
||||
)}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{startTime}:{" "}
|
||||
<small className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</small>
|
||||
<td className={"flex-1 ltr:pl-4 rtl:pr-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="sm:hidden">
|
||||
{isPending && <Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{startTime}:{" "}
|
||||
<small className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
title={booking.title}
|
||||
className={classNames(
|
||||
"max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
||||
{booking.title}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
||||
)}
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div className="max-w-52 md:max-w-96 truncate text-sm text-gray-500" title={booking.description}>
|
||||
"{booking.description}"
|
||||
<div
|
||||
title={booking.title}
|
||||
className={classNames(
|
||||
"max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
||||
{booking.title}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
||||
)}
|
||||
{isPending && <Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>}
|
||||
</div>
|
||||
)}
|
||||
{booking.description && (
|
||||
<div
|
||||
className="max-w-52 md:max-w-96 truncate text-sm text-gray-500"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.attendees.length !== 0 && (
|
||||
<div className="text-sm text-gray-900 hover:text-blue-500">
|
||||
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
|
||||
</div>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block text-left text-sm md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<a
|
||||
className="text-sm text-gray-900 hover:text-blue-500"
|
||||
href={"mailto:" + booking.attendees[0].email}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{booking.attendees[0].email}
|
||||
</a>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block text-left text-sm md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{!booking.confirmed && !booking.rejected && user!.id === booking.user!.id && (
|
||||
<TableActions actions={pendingActions} />
|
||||
)}
|
||||
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
|
||||
{!booking.confirmed && booking.rejected && (
|
||||
<div className="text-sm text-gray-500">{t("rejected")}</div>
|
||||
)}
|
||||
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||
{isConfirmed && <TableActions actions={bookedActions} />}
|
||||
{isRejected && <div className="text-sm text-gray-500">{t("rejected")}</div>}
|
||||
</>
|
||||
) : null}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { XIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
|
||||
type Props = {
|
||||
booking: {
|
||||
title?: string;
|
||||
uid?: string;
|
||||
};
|
||||
profile: {
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
};
|
||||
recurringEvent: RecurringEvent | null;
|
||||
team?: string | null;
|
||||
setIsCancellationMode: (value: boolean) => void;
|
||||
theme: string | null;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { booking, profile, team } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
const { isReady, Theme } = useTheme(props.theme);
|
||||
|
||||
if (isReady) {
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
{error && (
|
||||
<div>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<XIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{error}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
||||
<textarea
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="mt-2 mb-3 w-full dark:border-gray-900 dark:bg-gray-700 dark:text-white sm:mb-3 "
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex rtl:space-x-reverse">
|
||||
{!props.recurringEvent && (
|
||||
<div className="w-full">
|
||||
<Button color="secondary" onClick={() => router.push("/reschedule/" + booking?.uid)}>
|
||||
{t("reschedule_this")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full space-x-2 text-right">
|
||||
<Button color="secondary" onClick={() => props.setIsCancellationMode(false)}>
|
||||
{t("nevermind")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="cancel"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const payload = {
|
||||
uid: booking?.uid,
|
||||
reason: cancellationReason,
|
||||
};
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
await router.push(
|
||||
`/cancel/success?name=${props.profile.name}&title=${booking?.title}&eventPage=${
|
||||
profile.slug
|
||||
}&team=${team ? 1 : 0}&recurring=${!!props.recurringEvent}`
|
||||
);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
loading={loading}>
|
||||
{t("cancel_event")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import { EventType, PeriodType } from "@prisma/client";
|
||||
import { PeriodType } from "@prisma/client";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import dayjsBusinessTime from "dayjs-business-time";
|
||||
import dayjsBusinessTime from "dayjs-business-days2";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { memoize } from "lodash";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useEmbedStyles } from "@calcom/embed-core";
|
||||
import { useEmbedStyles } from "@calcom/embed-core/embed-iframe";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import { doWorkAsync } from "@lib/doWorkAsync";
|
||||
import isOutOfBounds from "@lib/isOutOfBounds";
|
||||
import getSlots from "@lib/slots";
|
||||
import { WorkingHours } from "@lib/types/schedule";
|
||||
|
||||
@@ -37,41 +38,6 @@ type DatePickerProps = {
|
||||
minimumBookingNotice: number;
|
||||
};
|
||||
|
||||
function isOutOfBounds(
|
||||
time: dayjs.ConfigType,
|
||||
{
|
||||
periodType,
|
||||
periodDays,
|
||||
periodCountCalendarDays,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
}: Pick<
|
||||
EventType,
|
||||
"periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
|
||||
>
|
||||
) {
|
||||
const date = dayjs(time);
|
||||
|
||||
switch (periodType) {
|
||||
case PeriodType.ROLLING: {
|
||||
const periodRollingEndDay = periodCountCalendarDays
|
||||
? dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day")
|
||||
: dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day");
|
||||
return date.endOf("day").isAfter(periodRollingEndDay);
|
||||
}
|
||||
|
||||
case PeriodType.RANGE: {
|
||||
const periodRangeStartDay = dayjs(periodStartDate).utcOffset(date.utcOffset()).endOf("day");
|
||||
const periodRangeEndDay = dayjs(periodEndDate).utcOffset(date.utcOffset()).endOf("day");
|
||||
return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
|
||||
}
|
||||
|
||||
case PeriodType.UNLIMITED:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function DatePicker({
|
||||
weekStart,
|
||||
onDatePicked,
|
||||
@@ -94,7 +60,7 @@ function DatePicker({
|
||||
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
|
||||
const [daysFromState, setDays] = useState<
|
||||
| {
|
||||
disabled: Boolean;
|
||||
disabled: boolean;
|
||||
date: number;
|
||||
}[]
|
||||
| null
|
||||
@@ -191,7 +157,7 @@ function DatePicker({
|
||||
name: "DatePicker",
|
||||
length: daysInMonth,
|
||||
callback: (i: number) => {
|
||||
let day = i + 1;
|
||||
const day = i + 1;
|
||||
days[daysInitialOffset + i] = {
|
||||
disabled: isDisabledMemoized(day, {
|
||||
browsingDate,
|
||||
@@ -283,15 +249,9 @@ function DatePicker({
|
||||
day.disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }
|
||||
}
|
||||
className={classNames(
|
||||
"absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center",
|
||||
"hover:border-brand hover:border dark:hover:border-white",
|
||||
day.disabled
|
||||
? "text-bookinglighter cursor-default font-light hover:border-0"
|
||||
: "font-medium",
|
||||
"hover:border-brand disabled:text-bookinglighter absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center font-medium hover:border disabled:cursor-default disabled:font-light disabled:hover:border-0 dark:hover:border-white",
|
||||
date && date.isSame(browsingDate.date(day.date), "day")
|
||||
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
|
||||
: !day.disabled
|
||||
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
|
||||
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast disabled:bg-gray-100 disabled:dark:bg-gray-600 disabled:dark:text-white"
|
||||
: ""
|
||||
)}
|
||||
data-testid="day"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import TimezoneSelect, { ITimezoneOption } from "@components/ui/form/TimezoneSelect";
|
||||
|
||||
import { is24h, timeZone } from "../../lib/clock";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,75 +1,354 @@
|
||||
// Get router variables
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ClipboardCheckIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
GlobeIcon,
|
||||
InformationCircleIcon,
|
||||
LocationMarkerIcon,
|
||||
RefreshIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { EventType } from "@prisma/client";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import timeZone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppStoreLocationType, LocationObject, LocationType } from "@calcom/app-store/locations";
|
||||
import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
useIsBackgroundTransparent,
|
||||
sdkActionManager,
|
||||
useEmbedType,
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
useEmbedStyles,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import DatePicker from "@calcom/ui/booker/DatePicker";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { asStringOrUndefined } from "@lib/asStringOrNull";
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
// import { timeZone } from "@lib/clock";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { parseDate } from "@lib/parseDate";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||
import DatePicker from "@components/booking/DatePicker";
|
||||
import TimeOptions from "@components/booking/TimeOptions";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||
|
||||
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
|
||||
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timeZone);
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => {
|
||||
export const locationKeyToString = (location: LocationObject, t: TFunction) => {
|
||||
switch (location.type) {
|
||||
case LocationType.InPerson:
|
||||
return location.address || "In Person"; // If disabled address won't exist on the object
|
||||
case LocationType.Link:
|
||||
return location.link || "Link"; // If disabled link won't exist on the object
|
||||
case LocationType.Phone:
|
||||
return t("your_number");
|
||||
case LocationType.UserPhone:
|
||||
return t("phone_call");
|
||||
case LocationType.GoogleMeet:
|
||||
return "Google Meet";
|
||||
case LocationType.Zoom:
|
||||
return "Zoom";
|
||||
case LocationType.Daily:
|
||||
return "Cal Video";
|
||||
case LocationType.Jitsi:
|
||||
return "Jitsi";
|
||||
case LocationType.Huddle01:
|
||||
return "Huddle Video";
|
||||
case LocationType.Tandem:
|
||||
return "Tandem";
|
||||
case LocationType.Teams:
|
||||
return "Microsoft Teams";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const GoBackToPreviousPage = ({ slug }: { slug: string }) => {
|
||||
const router = useRouter();
|
||||
const [previousPage, setPreviousPage] = useState<string>();
|
||||
useEffect(() => {
|
||||
setPreviousPage(document.referrer);
|
||||
}, []);
|
||||
|
||||
return previousPage === `${WEBAPP_URL}/${slug}` ? (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<ArrowLeftIcon
|
||||
className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white"
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
<p className="sr-only">Go Back</p>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
const useSlots = ({
|
||||
eventTypeId,
|
||||
startTime,
|
||||
endTime,
|
||||
timeZone,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
startTime?: Dayjs;
|
||||
endTime?: Dayjs;
|
||||
timeZone: string;
|
||||
}) => {
|
||||
const { data, isLoading } = trpc.useQuery(
|
||||
[
|
||||
"viewer.public.slots.getSchedule",
|
||||
{
|
||||
eventTypeId,
|
||||
startTime: startTime?.toISOString() || "",
|
||||
timeZone,
|
||||
endTime: endTime?.toISOString() || "",
|
||||
},
|
||||
],
|
||||
{ enabled: !!startTime && !!endTime }
|
||||
);
|
||||
|
||||
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.slots) {
|
||||
setCachedSlots((c) => ({ ...c, ...data?.slots }));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return { slots: cachedSlots, isLoading };
|
||||
};
|
||||
|
||||
const SlotPicker = ({
|
||||
eventType,
|
||||
timezoneDropdown,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
recurringEventCount,
|
||||
seatsPerTimeSlot,
|
||||
weekStart = 0,
|
||||
}: {
|
||||
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
|
||||
timezoneDropdown: JSX.Element;
|
||||
timeFormat: string;
|
||||
timeZone: string;
|
||||
seatsPerTimeSlot?: number;
|
||||
recurringEventCount?: number;
|
||||
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
}) => {
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) {
|
||||
return;
|
||||
}
|
||||
const month = asStringOrUndefined(router.query.month);
|
||||
const date = asStringOrUndefined(router.query.date);
|
||||
|
||||
// Etc/GMT is not actually a timeZone, so handle this select option explicitly to prevent a hard crash.
|
||||
if (timeZone === "Etc/GMT") {
|
||||
setBrowsingDate(dayjs.utc(month).startOf("month"));
|
||||
if (date) {
|
||||
setSelectedDate(dayjs.utc(date));
|
||||
}
|
||||
} else {
|
||||
setBrowsingDate(dayjs(month).tz(timeZone, true).startOf("month"));
|
||||
if (date) {
|
||||
setSelectedDate(dayjs(date).tz(timeZone, true));
|
||||
}
|
||||
}
|
||||
}, [router.isReady, router.query.month, router.query.date, timeZone]);
|
||||
|
||||
const { i18n, isLocaleReady } = useLocale();
|
||||
|
||||
const { slots: _1 } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
startTime: selectedDate?.startOf("month"),
|
||||
endTime: selectedDate?.endOf("month"),
|
||||
timeZone,
|
||||
});
|
||||
const { slots: _2, isLoading } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
startTime: browsingDate?.startOf("month"),
|
||||
endTime: browsingDate?.endOf("month"),
|
||||
timeZone,
|
||||
});
|
||||
|
||||
const slots = useMemo(() => ({ ..._1, ..._2 }), [_1, _2]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DatePicker
|
||||
isLoading={isLoading}
|
||||
className={
|
||||
"mt-8 w-full sm:mt-0 sm:min-w-[455px] " +
|
||||
(selectedDate
|
||||
? "sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
|
||||
: "sm:pl-4")
|
||||
}
|
||||
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
||||
locale={isLocaleReady ? i18n.language : "en"}
|
||||
selected={selectedDate}
|
||||
onChange={(selectedDate) => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
date: selectedDate.format("YYYY-MM-DD"),
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
}}
|
||||
onMonthChange={(browsingDate) => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
month: browsingDate.format("YYYY-MM"),
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
}}
|
||||
browsingDate={browsingDate}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
isLoading={isLoading}
|
||||
slots={slots[yyyymmdd(selectedDate.toDate())]}
|
||||
date={selectedDate}
|
||||
timeFormat={timeFormat}
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
recurringCount={recurringEventCount}
|
||||
schedulingType={eventType.schedulingType}
|
||||
users={[]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function TimezoneDropdown({
|
||||
onChangeTimeFormat,
|
||||
onChangeTimeZone,
|
||||
timeZone,
|
||||
}: {
|
||||
onChangeTimeFormat: (newTimeFormat: string) => void;
|
||||
onChangeTimeZone: (newTimeZone: string) => void;
|
||||
timeZone?: string;
|
||||
}) {
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||
onChangeTimeZone(newTimeZone);
|
||||
localStorageTimeZone(newTimeZone);
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||
onChangeTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Collapsible.Trigger className="min-w-32 text-bookinglight mb-1 -ml-2 px-2 py-1 text-left dark:text-white">
|
||||
<GlobeIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{timeZone || dayjs.tz.guess()}
|
||||
{isTimeOptionsOpen ? (
|
||||
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
)}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const dateQuerySchema = z.object({
|
||||
rescheduleUid: z.string().optional().default(""),
|
||||
date: z.string().optional().default(""),
|
||||
timeZone: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed();
|
||||
const { rescheduleUid } = router.query;
|
||||
const { isReady, Theme } = useTheme(profile.theme);
|
||||
const { t, i18n } = useLocale();
|
||||
const query = dateQuerySchema.parse(router.query);
|
||||
const { rescheduleUid } = query;
|
||||
const { Theme } = useTheme(profile.theme);
|
||||
const { t } = useLocale();
|
||||
const { contracts } = useContracts();
|
||||
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
let isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
useExposePlanGlobally(plan);
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
|
||||
const [timeZone, setTimeZone] = useState<string>();
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAvailableTimesVisible(!!query.date);
|
||||
}, [query.date]);
|
||||
|
||||
// TODO: Improve this;
|
||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||
|
||||
// TODO: this needs to be extracted elsewhere
|
||||
useEffect(() => {
|
||||
if (eventType.metadata.smartContractAddress) {
|
||||
const eventOwner = eventType.users[0];
|
||||
@@ -78,86 +357,21 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
}
|
||||
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
|
||||
|
||||
const selectedDate = useMemo(() => {
|
||||
const dateString = asStringOrNull(router.query.date);
|
||||
if (dateString) {
|
||||
const offsetString = dateString.substr(11, 14); // hhmm
|
||||
const offsetSign = dateString.substr(10, 1); // + or -
|
||||
|
||||
const offsetHour = offsetString.slice(0, -2);
|
||||
const offsetMinute = offsetString.slice(-2);
|
||||
|
||||
const utcOffsetInMinutes =
|
||||
(offsetSign === "-" ? -1 : 1) *
|
||||
(60 * (offsetHour !== "" ? parseInt(offsetHour) : 0) +
|
||||
(offsetMinute !== "" ? parseInt(offsetMinute) : 0));
|
||||
|
||||
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true);
|
||||
return date.isValid() ? date : null;
|
||||
}
|
||||
return null;
|
||||
}, [router.query.date]);
|
||||
|
||||
if (selectedDate) {
|
||||
// Let iframe take the width available due to increase in max-width
|
||||
sdkActionManager?.fire("__refreshWidth", {});
|
||||
}
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
telemetryEventTypes.embedView,
|
||||
collectPageParameters("/availability", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
);
|
||||
}
|
||||
}, [telemetry]);
|
||||
|
||||
const changeDate = useCallback(
|
||||
(newDate: Dayjs) => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
date: newDate.tz(timeZone(), true).format("YYYY-MM-DDZZ"),
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedDate != null &&
|
||||
selectedDate?.utcOffset() !== selectedDate.clone().utcOffset(0).tz(timeZone()).utcOffset()
|
||||
) {
|
||||
changeDate(selectedDate.tz(timeZone(), true));
|
||||
}
|
||||
}, [selectedDate, changeDate]);
|
||||
|
||||
const handleSelectTimeZone = (selectedTimeZone: string): void => {
|
||||
timeZone(selectedTimeZone);
|
||||
if (selectedDate) {
|
||||
changeDate(selectedDate.tz(selectedTimeZone, true));
|
||||
}
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
const handleToggle24hClock = (is24hClock: boolean) => {
|
||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
};
|
||||
|
||||
// Recurring event sidebar requires more space
|
||||
const maxWidth = selectedDate
|
||||
const maxWidth = isAvailableTimesVisible
|
||||
? recurringEventCount
|
||||
? "max-w-6xl"
|
||||
: "max-w-5xl"
|
||||
@@ -165,6 +379,17 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
? "max-w-4xl"
|
||||
: "max-w-3xl";
|
||||
|
||||
const timezoneDropdown = useMemo(
|
||||
() => (
|
||||
<TimezoneDropdown
|
||||
onChangeTimeFormat={setTimeFormat}
|
||||
timeZone={timeZone}
|
||||
onChangeTimeZone={setTimeZone}
|
||||
/>
|
||||
),
|
||||
[timeZone]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
@@ -173,7 +398,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||
name={profile.name || undefined}
|
||||
username={profile.slug || undefined}
|
||||
// avatar={profile.image || undefined}
|
||||
/>
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<div>
|
||||
@@ -184,48 +408,86 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
? classNames(maxWidth)
|
||||
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
||||
)}>
|
||||
{isReady && (
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||
"border-bookinglightest rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : maxWidth
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
<div>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`,
|
||||
alt: user.name || undefined,
|
||||
})),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
}
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
||||
<div className="mt-2 gap-2 dark:text-gray-100">
|
||||
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||
"border-bookinglightest rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : maxWidth
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block px-4 pt-4 sm:p-8 md:hidden">
|
||||
<div>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||
alt: user.name || undefined,
|
||||
})),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
}
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<p className="break-words text-sm font-medium text-black dark:text-white">{profile.name}</p>
|
||||
<div className="mt-2 gap-2 dark:text-gray-100">
|
||||
<h1 className="text-bookingdark mb-4 break-words text-xl font-semibold dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{eventType?.description && (
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType?.requiresConfirmation && (
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
<ClipboardCheckIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{t("requires_confirmation")}
|
||||
</p>
|
||||
)}
|
||||
{eventType.locations.length === 1 && (
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
{Object.values(AppStoreLocationType).includes(
|
||||
eventType.locations[0].type as unknown as AppStoreLocationType
|
||||
) ? (
|
||||
<VideoCameraIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<LocationMarkerIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
|
||||
{locationKeyToString(eventType.locations[0], t)}
|
||||
</p>
|
||||
)}
|
||||
{eventType.locations.length > 1 && (
|
||||
<div className="flex-warp flex text-gray-600 dark:text-white">
|
||||
<div className="mr-[10px] ml-[2px] -mt-1 ">
|
||||
<LocationMarkerIcon className="inline-block h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<p>
|
||||
{eventType.locations.map((el, i, arr) => {
|
||||
return (
|
||||
<span key={el.type}>
|
||||
{locationKeyToString(el, t)}{" "}
|
||||
{arr.length - 1 !== i && (
|
||||
<span className="font-light"> {t("or_lowercase")} </span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
@@ -240,93 +502,164 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
</IntlProvider>
|
||||
</div>
|
||||
)}
|
||||
<div className="md:hidden">
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-8 mb-2 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_mobile">
|
||||
{t("former_time")}
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<div className="ml-[27px]">
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-white">
|
||||
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{typeof booking.startTime === "string" &&
|
||||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline text-gray-600 dark:text-white">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
|
||||
<div className="md:hidden">
|
||||
{/* Temp disabled booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-8 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_mobile">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-white">
|
||||
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{typeof booking.startTime === "string" &&
|
||||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:flex sm:p-4 sm:py-5">
|
||||
<div
|
||||
className={
|
||||
"hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
||||
(selectedDate ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||
}>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
alt: user.name,
|
||||
image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`,
|
||||
})),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
|
||||
<h1 className="font-cal mb-4 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="p-4 sm:flex sm:py-5">
|
||||
<div
|
||||
className={
|
||||
"hidden overflow-hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
||||
(isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||
}>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
alt: user.name,
|
||||
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||
})),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="mt-3 break-words font-medium text-gray-500 dark:text-gray-300">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal mb-4 break-words text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{eventType?.description && (
|
||||
<p className="text-bookinglight mb-3 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.description}
|
||||
<div className="flex text-gray-600 dark:text-white">
|
||||
<div>
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<p>{eventType.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<div className="flex text-gray-600 dark:text-white">
|
||||
<div>
|
||||
<ClipboardCheckIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
{t("requires_confirmation")}
|
||||
</div>
|
||||
)}
|
||||
{eventType.locations.length === 1 && (
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
{Object.values(AppStoreLocationType).includes(
|
||||
eventType.locations[0].type as unknown as AppStoreLocationType
|
||||
) ? (
|
||||
<VideoCameraIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<LocationMarkerIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
|
||||
{locationKeyToString(eventType.locations[0], t)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinglight mb-3 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
|
||||
<div className="mb-3 text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{t("every_for_freq", {
|
||||
freq: t(
|
||||
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-16 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline text-gray-600 dark:text-white">
|
||||
{t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
|
||||
count: recurringEventCount,
|
||||
{eventType.locations.length > 1 && (
|
||||
<div className="flex-warp flex text-gray-600 dark:text-white">
|
||||
<div className="mr-[10px] ml-[2px] -mt-1 ">
|
||||
<LocationMarkerIcon className="inline-block h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<p>
|
||||
{eventType.locations.map((el, i, arr) => {
|
||||
return (
|
||||
<span key={el.type}>
|
||||
{locationKeyToString(el, t)}{" "}
|
||||
{arr.length - 1 !== i && (
|
||||
<span className="font-light"> {t("or_lowercase")} </span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<div className="ml-[27px]">
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 h-7 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline text-gray-600 dark:text-white">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
||||
<p className="-ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
@@ -337,18 +670,12 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</div>
|
||||
|
||||
<TimezoneDropdown />
|
||||
{previousPage === `${WEBAPP_URL}/${profile.slug}` && (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<ArrowLeftIcon
|
||||
className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white"
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
<p className="sr-only">Go Back</p>
|
||||
</div>
|
||||
)}
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<GoBackToPreviousPage slug={profile.slug || ""} />
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-white"
|
||||
@@ -360,70 +687,38 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
{typeof booking.startTime === "string" && parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
periodType={eventType?.periodType}
|
||||
periodStartDate={eventType?.periodStartDate}
|
||||
periodEndDate={eventType?.periodEndDate}
|
||||
periodDays={eventType?.periodDays}
|
||||
periodCountCalendarDays={eventType?.periodCountCalendarDays}
|
||||
onDatePicked={changeDate}
|
||||
workingHours={workingHours}
|
||||
weekStart={profile.weekStart || "Sunday"}
|
||||
eventLength={eventType.length}
|
||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||
/>
|
||||
|
||||
<div className="mt-4 ml-1 block sm:hidden">
|
||||
<TimezoneDropdown />
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
timeFormat={timeFormat}
|
||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
slotInterval={eventType.slotInterval}
|
||||
eventLength={eventType.length}
|
||||
recurringCount={recurringEventCount}
|
||||
date={selectedDate}
|
||||
users={eventType.users}
|
||||
schedulingType={eventType.schedulingType ?? null}
|
||||
beforeBufferTime={eventType.beforeEventBuffer}
|
||||
afterBufferTime={eventType.afterEventBuffer}
|
||||
/>
|
||||
)}
|
||||
)*/}
|
||||
</div>
|
||||
{timeZone && (
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
? ([
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
].indexOf(profile.weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: profile.weekStart /* Allows providing weekStart as number */
|
||||
}
|
||||
eventType={eventType}
|
||||
timezoneDropdown={timezoneDropdown}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function TimezoneDropdown() {
|
||||
return (
|
||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-600 dark:text-white">
|
||||
<GlobeIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{timeZone()}
|
||||
{isTimeOptionsOpen ? (
|
||||
<ChevronUpIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-1 -mt-1 inline-block h-4 w-4" />
|
||||
)}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default AvailabilityPage;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ExclamationCircleIcon,
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
ClipboardCheckIcon,
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -20,14 +21,18 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
import { useMutation } from "react-query";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed } from "@calcom/embed-core";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { createPaymentLink } from "@calcom/stripe/client";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
@@ -37,7 +42,7 @@ import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { ensureArray } from "@lib/ensureArray";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { LocationType } from "@lib/location";
|
||||
import { LocationObject, LocationType } from "@lib/location";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
@@ -52,12 +57,23 @@ import { BookPageProps } from "../../../pages/[user]/book";
|
||||
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var web3: {
|
||||
currentProvider: {
|
||||
selectedAddress: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
const PhoneInput = dynamic(
|
||||
() => import("@components/ui/form/PhoneInput")
|
||||
) as unknown as typeof PhoneInputType;
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
|
||||
type BookingPageProps = (BookPageProps | TeamBookingPageProps | HashLinkPageProps) & {
|
||||
locationLabels: Record<LocationType, string>;
|
||||
};
|
||||
|
||||
type BookingFormValues = {
|
||||
name: string;
|
||||
@@ -68,8 +84,9 @@ type BookingFormValues = {
|
||||
phone?: string;
|
||||
hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers
|
||||
customInputs?: {
|
||||
[key: string]: string;
|
||||
[key: string]: string | boolean;
|
||||
};
|
||||
rescheduleReason?: string;
|
||||
};
|
||||
|
||||
const BookingPage = ({
|
||||
@@ -93,12 +110,13 @@ const BookingPage = ({
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
top !== window ? telemetryEventTypes.embedView : telemetryEventTypes.pageView,
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(
|
||||
telemetryEventTypes.embedView,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
);
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -107,7 +125,6 @@ const BookingPage = ({
|
||||
const eventOwner = eventType.users[0];
|
||||
|
||||
if (!contracts[(eventType.metadata.smartContractAddress || null) as number])
|
||||
/* @ts-ignore */
|
||||
router.replace(`/${eventOwner.username}`);
|
||||
}
|
||||
}, [contracts, eventType.metadata.smartContractAddress, eventType.users, router]);
|
||||
@@ -121,6 +138,7 @@ const BookingPage = ({
|
||||
paymentUid,
|
||||
date,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
absolute: false,
|
||||
})
|
||||
);
|
||||
@@ -149,6 +167,7 @@ const BookingPage = ({
|
||||
location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
isSuccessBookingPage: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -194,10 +213,9 @@ const BookingPage = ({
|
||||
|
||||
const eventTypeDetail = { isWeb3Active: false, ...eventType };
|
||||
|
||||
type Location = { type: LocationType; address?: string; link?: string; hostPhoneNumber?: string };
|
||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||
const locations: Location[] = useMemo(
|
||||
() => (eventType.locations as Location[]) || [],
|
||||
const locations: LocationObject[] = useMemo(
|
||||
() => (eventType.locations as LocationObject[]) || [],
|
||||
[eventType.locations]
|
||||
);
|
||||
|
||||
@@ -208,7 +226,7 @@ const BookingPage = ({
|
||||
}, [router.query.guest]);
|
||||
|
||||
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
|
||||
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
|
||||
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
||||
const guestListEmails = !isDynamicGroupBooking
|
||||
? booking?.attendees.slice(1).map((attendee) => attendee.email)
|
||||
: [];
|
||||
@@ -216,8 +234,8 @@ const BookingPage = ({
|
||||
const defaultValues = () => {
|
||||
if (!rescheduleUid) {
|
||||
return {
|
||||
name: loggedInIsOwner ? "" : session?.user?.name || (router.query.name as string) || "",
|
||||
email: loggedInIsOwner ? "" : session?.user?.email || (router.query.email as string) || "",
|
||||
name: (router.query.name as string) || (!loggedInIsOwner && session?.user?.name) || "",
|
||||
email: (router.query.email as string) || (!loggedInIsOwner && session?.user?.email) || "",
|
||||
notes: (router.query.notes as string) || "",
|
||||
guests: ensureArray(router.query.guest) as string[],
|
||||
customInputs: eventType.customInputs.reduce(
|
||||
@@ -236,11 +254,23 @@ const BookingPage = ({
|
||||
if (!primaryAttendee) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const customInputType = booking.customInputs;
|
||||
return {
|
||||
name: primaryAttendee.name || "",
|
||||
email: primaryAttendee.email || "",
|
||||
guests: guestListEmails,
|
||||
notes: booking.description || "",
|
||||
rescheduleReason: "",
|
||||
customInputs: eventType.customInputs.reduce(
|
||||
(customInputs, input) => ({
|
||||
...customInputs,
|
||||
[input.id]: booking.customInputs
|
||||
? booking.customInputs[input.label as keyof typeof customInputType]
|
||||
: "",
|
||||
}),
|
||||
{}
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -307,13 +337,10 @@ const BookingPage = ({
|
||||
}
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||
collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") })
|
||||
)
|
||||
telemetry.event(
|
||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||
{ isTeamBooking: document.URL.includes("team/") }
|
||||
);
|
||||
|
||||
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
|
||||
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
|
||||
|
||||
@@ -331,7 +358,6 @@ const BookingPage = ({
|
||||
let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
|
||||
if (eventTypeDetail.metadata.smartContractAddress) {
|
||||
web3Details = {
|
||||
// @ts-ignore
|
||||
userWallet: window.web3.currentProvider.selectedAddress,
|
||||
userSignature: contracts[(eventTypeDetail.metadata.smartContractAddress || null) as number],
|
||||
};
|
||||
@@ -359,8 +385,8 @@ const BookingPage = ({
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
@@ -377,14 +403,15 @@ const BookingPage = ({
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
bookingUid: router.query.bookingUid as string,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||
value: booking.customInputs && inputId in booking.customInputs ? booking.customInputs[inputId] : "",
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
@@ -393,6 +420,9 @@ const BookingPage = ({
|
||||
};
|
||||
|
||||
const disableInput = !!rescheduleUid;
|
||||
const disabledExceptForOwner = disableInput && !loggedInIsOwner;
|
||||
const inputClassName =
|
||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black disabled:bg-gray-200 disabled:hover:cursor-not-allowed dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500 sm:text-sm";
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -447,12 +477,33 @@ const BookingPage = ({
|
||||
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<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-emerald-400"
|
||||
} mb-2`}>
|
||||
{booking
|
||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||
</p>
|
||||
)}
|
||||
{eventType?.description && (
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClipboardCheckIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{t("requires_confirmation")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.length} {t("minutes")}
|
||||
@@ -473,24 +524,23 @@ const BookingPage = ({
|
||||
<div className="mb-3 text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{`${t("every_for_freq", {
|
||||
freq: t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`),
|
||||
})} ${recurringEventCount} ${t(
|
||||
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: parseInt(recurringEventCount.toString()) }
|
||||
)}`}
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight mb-4 flex">
|
||||
<CalendarIcon className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
<div className="-mt-1">
|
||||
{(rescheduleUid || !eventType.recurringEvent.freq) &&
|
||||
{(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
||||
parseDate(dayjs(date).tz(timeZone()), i18n)}
|
||||
{!rescheduleUid &&
|
||||
eventType.recurringEvent.freq &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
recurringStrings.slice(0, 5).map((aDate, key) => <p key={key}>{aDate}</p>)}
|
||||
{!rescheduleUid && eventType.recurringEvent.freq && recurringStrings.length > 5 && (
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((aDate, key) => (
|
||||
@@ -521,7 +571,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
@@ -534,10 +584,7 @@ const BookingPage = ({
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
className={classNames(
|
||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
||||
)}
|
||||
className={inputClassName}
|
||||
placeholder={t("example_name")}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
@@ -554,8 +601,7 @@ const BookingPage = ({
|
||||
{...bookingForm.register("email")}
|
||||
required
|
||||
className={classNames(
|
||||
"focus:border-brand block w-full rounded-sm shadow-sm focus:ring-black dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
||||
disableInput ? "bg-gray-200 dark:text-gray-500" : "",
|
||||
inputClassName,
|
||||
bookingForm.formState.errors.email
|
||||
? "border-red-700 focus:ring-red-700"
|
||||
: " border-gray-300 dark:border-gray-900"
|
||||
@@ -630,12 +676,9 @@ const BookingPage = ({
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
rows={3}
|
||||
className={classNames(
|
||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
||||
)}
|
||||
className={inputClassName}
|
||||
placeholder={input.placeholder}
|
||||
disabled={disableInput}
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.TEXT && (
|
||||
@@ -645,9 +688,9 @@ const BookingPage = ({
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
className={inputClassName}
|
||||
placeholder={input.placeholder}
|
||||
disabled={disableInput}
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.NUMBER && (
|
||||
@@ -657,8 +700,9 @@ const BookingPage = ({
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
|
||||
className={inputClassName}
|
||||
placeholder=""
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.BOOL && (
|
||||
@@ -669,8 +713,9 @@ const BookingPage = ({
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black ltr:mr-2 rtl:ml-2"
|
||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||
placeholder=""
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
@@ -750,26 +795,36 @@ const BookingPage = ({
|
||||
<label
|
||||
htmlFor="notes"
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("additional_notes")}
|
||||
{rescheduleUid ? t("reschedule_optional") : t("additional_notes")}
|
||||
</label>
|
||||
<textarea
|
||||
{...bookingForm.register("notes")}
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
className={classNames(
|
||||
"focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
||||
)}
|
||||
placeholder={t("share_additional_notes")}
|
||||
disabled={disableInput}
|
||||
/>
|
||||
{rescheduleUid ? (
|
||||
<textarea
|
||||
{...bookingForm.register("rescheduleReason")}
|
||||
id="rescheduleReason"
|
||||
name="rescheduleReason"
|
||||
rows={3}
|
||||
className={inputClassName}
|
||||
placeholder={t("reschedule_placeholder")}
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
{...bookingForm.register("notes")}
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
className={inputClassName}
|
||||
placeholder={t("share_additional_notes")}
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||
loading={mutation.isLoading}>
|
||||
loading={mutation.isLoading || recurringMutation.isLoading}>
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
</Button>
|
||||
<Button color="secondary" type="button" onClick={() => router.back()}>
|
||||
@@ -777,22 +832,8 @@ const BookingPage = ({
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
{mutation.isError && (
|
||||
<div
|
||||
data-testid="booking-fail"
|
||||
className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||
{(mutation.error as HttpError)?.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(mutation.isError || recurringMutation.isError) && (
|
||||
<ErrorMessage error={mutation.error || recurringMutation.error} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -804,3 +845,24 @@ const BookingPage = ({
|
||||
};
|
||||
|
||||
export default BookingPage;
|
||||
|
||||
function ErrorMessage({ error }: { error: unknown }) {
|
||||
const { t } = useLocale();
|
||||
const { query: { rescheduleUid } = {} } = useRouter();
|
||||
|
||||
return (
|
||||
<div data-testid="booking-fail" className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}{" "}
|
||||
{error instanceof HttpError || error instanceof Error ? error.message : "Unknown error"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ConfirmationDialogContentProps = {
|
||||
confirmBtnText?: string;
|
||||
cancelBtnText?: string;
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
title: string;
|
||||
variety?: "danger" | "warning" | "success";
|
||||
@@ -25,6 +26,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||
confirmBtn = null,
|
||||
confirmBtnText = t("confirm"),
|
||||
cancelBtnText = t("cancel"),
|
||||
loadingText = t("loading"),
|
||||
isLoading = false,
|
||||
onConfirm,
|
||||
children,
|
||||
@@ -63,7 +65,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||
<DialogClose disabled={isLoading} onClick={onConfirm} asChild>
|
||||
{confirmBtn || (
|
||||
<Button color="primary" loading={isLoading}>
|
||||
{isLoading ? t("loading") : confirmBtnText}
|
||||
{isLoading ? loadingText : confirmBtnText}
|
||||
</Button>
|
||||
)}
|
||||
</DialogClose>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { ExclamationIcon } from "@heroicons/react/outline";
|
||||
import { CheckIcon } from "@heroicons/react/solid";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren, ReactNode } from "react";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
export type DeleteStripeDialogContentProps = {
|
||||
confirmBtn?: ReactNode;
|
||||
cancelAllBookingsBtnText?: string;
|
||||
removeBtnText?: string;
|
||||
cancelBtnText?: string;
|
||||
@@ -24,7 +22,6 @@ export default function DeleteStripeDialogContent(props: PropsWithChildren<Delet
|
||||
const {
|
||||
title,
|
||||
variety,
|
||||
confirmBtn = null,
|
||||
cancelAllBookingsBtnText,
|
||||
removeBtnText,
|
||||
cancelBtnText = t("cancel"),
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
import { LocationMarkerIcon } from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { linkValueToString } from "@lib/linkValueToString";
|
||||
import { LocationType } from "@lib/location";
|
||||
import { LocationOptionsToString } from "@lib/locationOptions";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
import type PhoneInputType from "@components/ui/form/PhoneInput";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
const PhoneInput = dynamic(
|
||||
() => import("@components/ui/form/PhoneInput")
|
||||
) as unknown as typeof PhoneInputType;
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
type OptionTypeBase = {
|
||||
label: string;
|
||||
value: LocationType;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type LocationFormValues = {
|
||||
locationType: LocationType;
|
||||
locationAddress?: string;
|
||||
locationLink?: string;
|
||||
locationPhoneNumber?: string;
|
||||
displayLocationPublicly?: boolean;
|
||||
};
|
||||
interface ISetLocationDialog {
|
||||
saveLocation: (newLocationType: LocationType, details: { [key: string]: string }) => void;
|
||||
selection?: OptionTypeBase;
|
||||
booking?: BookingItem;
|
||||
defaultValues?: {
|
||||
type: LocationType;
|
||||
address?: string | undefined;
|
||||
link?: string | undefined;
|
||||
hostPhoneNumber?: string | undefined;
|
||||
displayLocationPublicly?: boolean | undefined;
|
||||
}[];
|
||||
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isOpenDialog: boolean;
|
||||
setSelectedLocation?: (param: OptionTypeBase | undefined) => void;
|
||||
}
|
||||
|
||||
export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
const {
|
||||
saveLocation,
|
||||
selection,
|
||||
booking,
|
||||
setShowLocationModal,
|
||||
isOpenDialog,
|
||||
defaultValues,
|
||||
setSelectedLocation,
|
||||
} = props;
|
||||
const { t } = useLocale();
|
||||
const locationsQuery = trpc.useQuery(["viewer.locationOptions"]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selection) {
|
||||
locationFormMethods.setValue("locationType", selection?.value);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selection]);
|
||||
|
||||
const locationFormSchema = z.object({
|
||||
locationType: z.string(),
|
||||
locationAddress: z.string().optional(),
|
||||
locationLink:
|
||||
selection?.value === LocationType.Whereby
|
||||
? z
|
||||
.string()
|
||||
.regex(/^http(s)?:\/\/www.whereby.com\/[a-zA-Z0-9]*/)
|
||||
.optional()
|
||||
: selection?.value === LocationType.Around
|
||||
? z
|
||||
.string()
|
||||
.regex(/^http(s)?:\/\/www.around.co\/[a-zA-Z0-9]*/)
|
||||
.optional()
|
||||
: selection?.value === LocationType.Riverside
|
||||
? z
|
||||
.string()
|
||||
.regex(/^http(s)?:\/\/www.riverside.fm\/studio\/[a-zA-Z0-9]*/)
|
||||
.optional()
|
||||
: z.string().url().optional(),
|
||||
displayLocationPublicly: z.boolean().optional(),
|
||||
locationPhoneNumber: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const locationFormMethods = useForm<LocationFormValues>({
|
||||
mode: "onSubmit",
|
||||
resolver: zodResolver(locationFormSchema),
|
||||
});
|
||||
|
||||
const selectedLocation = useWatch({
|
||||
control: locationFormMethods.control,
|
||||
name: "locationType",
|
||||
});
|
||||
|
||||
const LocationOptions =
|
||||
selectedLocation === LocationType.InPerson ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||
{t("set_address_place")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
{...locationFormMethods.register("locationAddress")}
|
||||
id="address"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={
|
||||
defaultValues
|
||||
? defaultValues.find(
|
||||
(location: { type: LocationType }) => location.type === LocationType.InPerson
|
||||
)?.address
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!booking && (
|
||||
<div className="mt-3">
|
||||
<Controller
|
||||
name="displayLocationPublicly"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
defaultChecked={
|
||||
defaultValues
|
||||
? defaultValues.find((location) => location.type === LocationType.InPerson)
|
||||
?.displayLocationPublicly
|
||||
: undefined
|
||||
}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) =>
|
||||
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||
}
|
||||
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : selectedLocation === LocationType.Link ? (
|
||||
<div>
|
||||
<label htmlFor="link" className="block text-sm font-medium text-gray-700">
|
||||
{t("set_link_meeting")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
{...locationFormMethods.register("locationLink")}
|
||||
required
|
||||
id="link"
|
||||
className="block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
defaultValue={
|
||||
defaultValues
|
||||
? defaultValues.find(
|
||||
(location: { type: LocationType }) => location.type === LocationType.Link
|
||||
)?.link
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{locationFormMethods.formState.errors.locationLink && (
|
||||
<p className="mt-1 text-sm text-red-500">{t("url_start_with_https")}</p>
|
||||
)}
|
||||
</div>
|
||||
{!booking && (
|
||||
<div className="mt-3">
|
||||
<Controller
|
||||
name="displayLocationPublicly"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
description={t("display_location_label")}
|
||||
defaultChecked={
|
||||
defaultValues
|
||||
? defaultValues.find((location) => location.type === LocationType.Link)
|
||||
?.displayLocationPublicly
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => locationFormMethods.setValue("displayLocationPublicly", e.target.checked)}
|
||||
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : selectedLocation === LocationType.UserPhone ? (
|
||||
<div>
|
||||
<label htmlFor="phonenumber" className="block text-sm font-medium text-gray-700">
|
||||
{t("set_your_phone_number")}
|
||||
{locationFormMethods.formState?.errors?.locationPhoneNumber?.message}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<PhoneInput<LocationFormValues>
|
||||
control={locationFormMethods.control}
|
||||
name="locationPhoneNumber"
|
||||
required
|
||||
id="locationPhoneNumber"
|
||||
placeholder={t("host_phone_number")}
|
||||
defaultValue={
|
||||
defaultValues
|
||||
? defaultValues.find(
|
||||
(location: { type: LocationType }) => location.type === LocationType.UserPhone
|
||||
)?.hostPhoneNumber
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{locationFormMethods.formState.errors.locationPhoneNumber && (
|
||||
<p className="mt-1 text-sm text-red-500">Invalid input</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedLocation === LocationType.Whereby ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||
{t("set_whereby_link")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
{...locationFormMethods.register("locationLink")}
|
||||
id="wherebylink"
|
||||
placeholder="https://www.whereby.com/cal"
|
||||
required
|
||||
className={"block w-full rounded-sm border-gray-300 text-sm"}
|
||||
defaultValue={
|
||||
defaultValues
|
||||
? defaultValues.find(
|
||||
(location: { type: LocationType }) => location.type === LocationType.Whereby
|
||||
)?.address
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!booking && (
|
||||
<div className="mt-3">
|
||||
<Controller
|
||||
name="displayLocationPublicly"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
defaultChecked={
|
||||
defaultValues
|
||||
? defaultValues.find((location) => location.type === LocationType.Whereby)
|
||||
?.displayLocationPublicly
|
||||
: undefined
|
||||
}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) =>
|
||||
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||
}
|
||||
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : selectedLocation === LocationType.Around ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||
{t("set_around_link")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
{...locationFormMethods.register("locationLink")}
|
||||
id="aroundlink"
|
||||
placeholder="https://www.around.com/rick"
|
||||
required
|
||||
className={"block w-full rounded-sm border-gray-300 text-sm"}
|
||||
defaultValue={
|
||||
defaultValues
|
||||
? defaultValues.find(
|
||||
(location: { type: LocationType }) => location.type === LocationType.Around
|
||||
)?.address
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!booking && (
|
||||
<div className="mt-3">
|
||||
<Controller
|
||||
name="displayLocationPublicly"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
defaultChecked={
|
||||
defaultValues
|
||||
? defaultValues.find((location) => location.type === LocationType.Around)
|
||||
?.displayLocationPublicly
|
||||
: undefined
|
||||
}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) =>
|
||||
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||
}
|
||||
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : selectedLocation === LocationType.Riverside ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||
{t("set_around_link")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
{...locationFormMethods.register("locationLink")}
|
||||
id="aroundlink"
|
||||
placeholder="https://www.riverside.fm/studio/rick"
|
||||
required
|
||||
className={"block w-full rounded-sm border-gray-300 text-sm"}
|
||||
defaultValue={
|
||||
defaultValues
|
||||
? defaultValues.find(
|
||||
(location: { type: LocationType }) => location.type === LocationType.Riverside
|
||||
)?.address
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!booking && (
|
||||
<div className="mt-3">
|
||||
<Controller
|
||||
name="displayLocationPublicly"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<CheckboxField
|
||||
defaultChecked={
|
||||
defaultValues
|
||||
? defaultValues.find((location) => location.type === LocationType.Riverside)
|
||||
?.displayLocationPublicly
|
||||
: undefined
|
||||
}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) =>
|
||||
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||
}
|
||||
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm">{LocationOptionsToString(selectedLocation, t)}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog}>
|
||||
<DialogContent asChild>
|
||||
<div className="inline-block transform rounded-sm bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-secondary-100 mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<LocationMarkerIcon className="text-primary-600 h-6 w-6" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("edit_location")}
|
||||
</h3>
|
||||
{!booking && (
|
||||
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"></div>
|
||||
</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">{linkValueToString(booking.location, t)}</p>
|
||||
</>
|
||||
)}
|
||||
<Form
|
||||
form={locationFormMethods}
|
||||
handleSubmit={async (values) => {
|
||||
const { locationType: newLocation, displayLocationPublicly } = values;
|
||||
|
||||
let details = {};
|
||||
if (newLocation === LocationType.InPerson) {
|
||||
details = {
|
||||
address: values.locationAddress,
|
||||
displayLocationPublicly,
|
||||
};
|
||||
}
|
||||
if (
|
||||
newLocation === LocationType.Link ||
|
||||
newLocation === LocationType.Whereby ||
|
||||
newLocation === LocationType.Around ||
|
||||
newLocation === LocationType.Riverside
|
||||
) {
|
||||
details = { link: values.locationLink, displayLocationPublicly };
|
||||
}
|
||||
|
||||
if (newLocation === LocationType.UserPhone) {
|
||||
details = { hostPhoneNumber: values.locationPhoneNumber };
|
||||
}
|
||||
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
locationFormMethods.unregister([
|
||||
"locationType",
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
}}>
|
||||
<QueryCell
|
||||
query={locationsQuery}
|
||||
success={({ data: locationOptions }) => {
|
||||
if (!locationOptions.length) return null;
|
||||
return (
|
||||
<Controller
|
||||
name="locationType"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<Select
|
||||
maxMenuHeight={150}
|
||||
name="location"
|
||||
defaultValue={selection}
|
||||
options={
|
||||
booking
|
||||
? locationOptions.filter((location) => location.value !== "phone")
|
||||
: locationOptions
|
||||
}
|
||||
isSearchable={false}
|
||||
className="my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
locationFormMethods.unregister([
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
locationFormMethods.clearErrors([
|
||||
"locationLink",
|
||||
"locationPhoneNumber",
|
||||
"locationAddress",
|
||||
]);
|
||||
setSelectedLocation?.(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{selectedLocation && LocationOptions}
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
locationFormMethods.unregister("locationType");
|
||||
}}
|
||||
type="button"
|
||||
color="secondary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("update")}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon } from "@heroicons/react/outline";
|
||||
import { RescheduleResponse } from "pages/api/book/request-reschedule";
|
||||
import React, { useState, Dispatch, SetStateAction } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { ClockIcon, CreditCardIcon, RefreshIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
ClipboardCheckIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
RefreshIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { Prisma, SchedulingType } from "@prisma/client";
|
||||
import { useMemo } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||
select: {
|
||||
id: true,
|
||||
length: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
description: true,
|
||||
},
|
||||
select: baseEventTypeSelect,
|
||||
});
|
||||
|
||||
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
||||
@@ -31,8 +30,8 @@ export type EventTypeDescriptionProps = {
|
||||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const recurringEvent: RecurringEvent = useMemo(
|
||||
() => (eventType.recurringEvent as RecurringEvent) || [],
|
||||
const recurringEvent = useMemo(
|
||||
() => parseRecurringEvent(eventType.recurringEvent),
|
||||
[eventType.recurringEvent]
|
||||
);
|
||||
|
||||
@@ -45,32 +44,34 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||
{eventType.description.length > 100 && "..."}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex whitespace-nowrap">
|
||||
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
<ul className="mt-2 flex flex-wrap sm:flex-nowrap">
|
||||
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||
<ClockIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{eventType.length}m
|
||||
</li>
|
||||
{eventType.schedulingType ? (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<UsersIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||
<UsersIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
|
||||
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
|
||||
</li>
|
||||
) : (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||
<UserIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{t("1_on_1")}
|
||||
</li>
|
||||
)}
|
||||
{recurringEvent?.count && recurringEvent.count > 0 && (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<RefreshIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{t("repeats_up_to", { count: recurringEvent.count })}
|
||||
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||
<RefreshIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{t("repeats_up_to", {
|
||||
count: recurringEvent.count,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<CreditCardIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||
<CreditCardIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
@@ -80,6 +81,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||
</IntlProvider>
|
||||
</li>
|
||||
)}
|
||||
{eventType.requiresConfirmation && (
|
||||
<li className="mr-4 mb-1 flex items-center whitespace-nowrap">
|
||||
<ClipboardCheckIcon className="mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{t("requires_confirmation")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type RecurringEventControllerProps = {
|
||||
recurringEvent: RecurringEvent;
|
||||
formMethods: UseFormReturn<any, any>;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
paymentEnabled: boolean;
|
||||
onRecurringEventDefined: Function;
|
||||
onRecurringEventDefined: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export default function RecurringEventController({
|
||||
@@ -23,29 +23,20 @@ export default function RecurringEventController({
|
||||
onRecurringEventDefined,
|
||||
}: RecurringEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const [recurringEventDefined, setRecurringEventDefined] = useState(recurringEvent?.count !== undefined);
|
||||
|
||||
const [recurringEventInterval, setRecurringEventInterval] = useState(recurringEvent?.interval || 1);
|
||||
const [recurringEventFrequency, setRecurringEventFrequency] = useState(
|
||||
recurringEvent?.freq || RRuleFrequency.WEEKLY
|
||||
);
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(recurringEvent?.count || 12);
|
||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
||||
|
||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||
const recurringEventFreqOptions = Object.entries(RRuleFrequency)
|
||||
const recurringEventFreqOptions = Object.entries(Frequency)
|
||||
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
|
||||
.map(([key, value]) => ({
|
||||
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventInterval }),
|
||||
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventState?.interval }),
|
||||
value: value.toString(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
<label htmlFor="recurringEvent" className="flex text-sm font-medium text-neutral-700">
|
||||
{t("recurring_event")}
|
||||
</label>
|
||||
<span className="flex text-sm font-medium text-neutral-700">{t("recurring_event")}</span>
|
||||
</div>
|
||||
<div className={!paymentEnabled ? "w-full" : ""}>
|
||||
{paymentEnabled ? (
|
||||
@@ -56,39 +47,35 @@ export default function RecurringEventController({
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
onChange={(event) => {
|
||||
setRecurringEventDefined(event?.target.checked);
|
||||
onRecurringEventDefined(event?.target.checked);
|
||||
if (!event?.target.checked) {
|
||||
formMethods.setValue("recurringEvent", {});
|
||||
formMethods.setValue("recurringEvent", null);
|
||||
setRecurringEventState(null);
|
||||
} else {
|
||||
formMethods.setValue(
|
||||
"recurringEvent",
|
||||
recurringEventDefined
|
||||
? recurringEvent
|
||||
: {
|
||||
interval: 1,
|
||||
count: 12,
|
||||
freq: RRuleFrequency.WEEKLY,
|
||||
}
|
||||
);
|
||||
const newVal = recurringEvent || {
|
||||
interval: 1,
|
||||
count: 12,
|
||||
freq: Frequency.WEEKLY,
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}
|
||||
recurringEvent = formMethods.getValues("recurringEvent");
|
||||
}}
|
||||
type="checkbox"
|
||||
className="text-primary-600 h-4 w-4 rounded border-gray-300"
|
||||
defaultChecked={recurringEventDefined}
|
||||
defaultChecked={recurringEventState !== null}
|
||||
data-testid="recurring-event-check"
|
||||
id="recurringEvent"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-neutral-900">{t("recurring_event_description")}</p>
|
||||
<label htmlFor="recurringEvent" className="text-neutral-900">
|
||||
{t("recurring_event_description")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible
|
||||
open={recurringEventDefined}
|
||||
data-testid="recurring-event-collapsible"
|
||||
onOpenChange={() => setRecurringEventDefined(!recurringEventDefined)}>
|
||||
<CollapsibleContent className="mt-4 text-sm">
|
||||
{recurringEventState && (
|
||||
<div data-testid="recurring-event-collapsible" className="mt-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||
<input
|
||||
@@ -96,24 +83,28 @@ export default function RecurringEventController({
|
||||
min="1"
|
||||
max="20"
|
||||
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
defaultValue={recurringEvent?.interval || 1}
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
setRecurringEventInterval(parseInt(event?.target.value));
|
||||
recurringEvent.interval = parseInt(event?.target.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
interval: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventFrequency]}
|
||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||
isSearchable={false}
|
||||
className="w-18 block min-w-0 rounded-sm sm:text-sm"
|
||||
onChange={(e) => {
|
||||
if (e?.value) {
|
||||
setRecurringEventFrequency(parseInt(e?.value));
|
||||
recurringEvent.freq = parseInt(e?.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -124,21 +115,24 @@ export default function RecurringEventController({
|
||||
min="1"
|
||||
max="20"
|
||||
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
defaultValue={recurringEvent?.count || 12}
|
||||
defaultValue={recurringEventState.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
recurringEvent.count = parseInt(event?.target.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
const newVal = {
|
||||
...recurringEventState,
|
||||
count: parseInt(event?.target.value),
|
||||
};
|
||||
formMethods.setValue("recurringEvent", newVal);
|
||||
setRecurringEventState(newVal);
|
||||
}}
|
||||
/>
|
||||
<p className="mr-2 text-neutral-900">
|
||||
{t(`${RRuleFrequency[recurringEventFrequency].toString().toLowerCase()}`, {
|
||||
count: recurringEventCount,
|
||||
{t("occurrence", {
|
||||
count: recurringEventState.count,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,18 +2,20 @@ import { Fragment } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import AdditionalCalendarSelector from "@components/AdditionalCalendarSelector";
|
||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
import { List } from "@components/List";
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
import SkeletonLoader from "@components/apps/SkeletonLoader";
|
||||
|
||||
import DisconnectIntegration from "./DisconnectIntegration";
|
||||
import IntegrationListItem from "./IntegrationListItem";
|
||||
@@ -21,6 +23,7 @@ import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections";
|
||||
|
||||
type Props = {
|
||||
onChanged: () => unknown | Promise<unknown>;
|
||||
fromOnboarding?: boolean;
|
||||
};
|
||||
|
||||
function CalendarSwitch(props: {
|
||||
@@ -92,10 +95,44 @@ function CalendarSwitch(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations", { variant: "calendar", onlyInstalled: false }]);
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<List>
|
||||
{data.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
imageSrc={item.imageSrc}
|
||||
description={item.description}
|
||||
actions={
|
||||
<InstallAppButton
|
||||
type={item.type}
|
||||
render={(buttonProps) => (
|
||||
<Button color="secondary" {...buttonProps}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
onChanged={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectedCalendarsList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
||||
|
||||
const { fromOnboarding } = props;
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
@@ -124,17 +161,22 @@ function ConnectedCalendarsList(props: Props) {
|
||||
onOpenChange={props.onChanged}
|
||||
/>
|
||||
}>
|
||||
<ul className="space-y-2 p-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="px-4 pt-4 text-sm text-neutral-500">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-2 p-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</IntegrationListItem>
|
||||
) : (
|
||||
<Alert
|
||||
@@ -163,83 +205,82 @@ function ConnectedCalendarsList(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
export function CalendarListContainer(props: {
|
||||
heading?: boolean;
|
||||
items?: inferQueryOutput<"viewer.integrations">["items"];
|
||||
fromOnboarding?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations"]);
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<List>
|
||||
{data.calendar.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
imageSrc={item.imageSrc}
|
||||
description={item.description}
|
||||
actions={
|
||||
<InstallAppButton
|
||||
type={item.type}
|
||||
render={(buttonProps) => (
|
||||
<Button color="secondary" {...buttonProps}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
onChanged={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarListContainer(props: { heading?: false }) {
|
||||
const { t } = useLocale();
|
||||
const { heading = true } = props;
|
||||
const { heading = true, fromOnboarding } = props;
|
||||
const utils = trpc.useContext();
|
||||
const onChanged = () =>
|
||||
Promise.allSettled([
|
||||
utils.invalidateQueries(["viewer.integrations"]),
|
||||
utils.invalidateQueries(["viewer.integrations", { variant: "calendar", onlyInstalled: true }], {
|
||||
exact: true,
|
||||
}),
|
||||
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
||||
]);
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const installedCalendars = trpc.useQuery([
|
||||
"viewer.integrations",
|
||||
{ variant: "calendar", onlyInstalled: true },
|
||||
]);
|
||||
const mutation = trpc.useMutation("viewer.setDestinationCalendar");
|
||||
|
||||
return (
|
||||
<>
|
||||
{heading && (
|
||||
<ShellSubHeading
|
||||
className="mt-10 mb-0"
|
||||
title={
|
||||
<SubHeadingTitleWithConnections
|
||||
title="Calendars"
|
||||
numConnections={query.data?.connectedCalendars.length}
|
||||
/>
|
||||
}
|
||||
subtitle={t("configure_how_your_event_types_interact")}
|
||||
actions={
|
||||
<div className="sm:min-w-80 block max-w-full">
|
||||
<DestinationCalendarSelector
|
||||
onChange={mutation.mutate}
|
||||
isLoading={mutation.isLoading}
|
||||
value={query.data?.destinationCalendar?.externalId}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ConnectedCalendarsList onChanged={onChanged} />
|
||||
{!!query.data?.connectedCalendars.length && (
|
||||
<ShellSubHeading
|
||||
className="mt-6"
|
||||
title={<SubHeadingTitleWithConnections title={t("connect_an_additional_calendar")} />}
|
||||
/>
|
||||
)}
|
||||
<CalendarList onChanged={onChanged} />
|
||||
</>
|
||||
<QueryCell
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader className="mt-10" />}
|
||||
success={({ data }) => {
|
||||
return (
|
||||
<>
|
||||
{(!!data.connectedCalendars.length || !!installedCalendars.data?.items.length) && (
|
||||
<>
|
||||
{heading && (
|
||||
<ShellSubHeading
|
||||
className="mt-10 mb-0"
|
||||
title={
|
||||
<SubHeadingTitleWithConnections
|
||||
title="Calendars"
|
||||
numConnections={data.connectedCalendars.length}
|
||||
/>
|
||||
}
|
||||
subtitle={t("configure_how_your_event_types_interact")}
|
||||
actions={
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||
<div className="sm:min-w-80 block max-w-full">
|
||||
<DestinationCalendarSelector
|
||||
onChange={mutation.mutate}
|
||||
isLoading={mutation.isLoading}
|
||||
value={data.destinationCalendar?.externalId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!!data.connectedCalendars.length && (
|
||||
<div className="sm:min-w-80 inline max-w-full">
|
||||
<AdditionalCalendarSelector isLoading={mutation.isLoading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ConnectedCalendarsList onChanged={onChanged} fromOnboarding={fromOnboarding} />
|
||||
</>
|
||||
)}
|
||||
{fromOnboarding && (
|
||||
<>
|
||||
{!!query.data?.connectedCalendars.length && (
|
||||
<ShellSubHeading
|
||||
className="mt-4"
|
||||
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
|
||||
/>
|
||||
)}
|
||||
<CalendarList onChanged={onChanged} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
|
||||
export default function DisconnectIntegration(props: {
|
||||
@@ -13,43 +16,35 @@ export default function DisconnectIntegration(props: {
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
const { id } = props;
|
||||
const { t } = useLocale();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
async () => {
|
||||
const res = await fetch("/api/integrations", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ id: props.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
return res.json();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.deleteCredential", {
|
||||
onSettled: async () => {
|
||||
await props.onOpenChange(modalOpen);
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await props.onOpenChange(modalOpen);
|
||||
},
|
||||
onSuccess(data) {
|
||||
showToast(data.message, "success");
|
||||
setModalOpen(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
onSuccess: () => {
|
||||
showToast("Integration deleted successfully", "success");
|
||||
setModalOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
throw new Error("Something went wrong");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title="Disconnect Integration"
|
||||
confirmBtnText="Yes, disconnect integration"
|
||||
title={t("remove_app")}
|
||||
confirmBtnText={t("yes_remove_app")}
|
||||
cancelBtnText="Cancel"
|
||||
onConfirm={() => {
|
||||
mutation.mutate();
|
||||
mutation.mutate({ id });
|
||||
}}>
|
||||
Are you sure you want to disconnect this integration?
|
||||
{t("are_you_sure_you_want_to_remove_this_app")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
{props.render({
|
||||
|
||||
@@ -35,7 +35,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
defaultValues,
|
||||
});
|
||||
const selectedInputType = useWatch({ name: "type", control });
|
||||
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value)!;
|
||||
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value);
|
||||
|
||||
const onCancel = () => {
|
||||
props.onCancel();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonation: boolean }) => {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between pt-9 pl-2 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">
|
||||
{t("user_impersonation_heading")}
|
||||
</h2>
|
||||
<Badge className="ml-2 text-xs" variant={!disableImpersonation ? "success" : "gray"}>
|
||||
{!disableImpersonation ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("user_impersonation_description")}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
!disableImpersonation
|
||||
? mutation.mutate({ disableImpersonation: true })
|
||||
: mutation.mutate({ disableImpersonation: false })
|
||||
}>
|
||||
{!disableImpersonation ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisableUserImpersonation;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent, useEffect } from "react";
|
||||
import { SyntheticEvent, useMemo, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
@@ -11,12 +10,10 @@ import ModalContainer from "@components/ui/ModalContainer";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type MembershipRoleOption = {
|
||||
label: string;
|
||||
value: MembershipRole;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
isOpen: boolean;
|
||||
currentMember: MembershipRole;
|
||||
@@ -25,18 +22,32 @@ export default function MemberChangeRoleModal(props: {
|
||||
initialRole: MembershipRole;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
options.forEach((option, i) => {
|
||||
options[i].label = t(option.value.toLowerCase());
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const { t } = useLocale();
|
||||
|
||||
const [role, setRole] = useState(
|
||||
options.find((option) => option.value === props.initialRole || MembershipRole.MEMBER)!
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t("member"),
|
||||
value: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
label: t("admin"),
|
||||
value: MembershipRole.ADMIN,
|
||||
},
|
||||
{
|
||||
label: t("owner"),
|
||||
value: MembershipRole.OWNER,
|
||||
},
|
||||
].filter(({ value }) => value !== MembershipRole.OWNER || props.currentMember === MembershipRole.OWNER);
|
||||
}, [t, props.currentMember]);
|
||||
|
||||
const [role, setRole] = useState<MembershipRoleOption>(
|
||||
options.find((option) => option.value === props.initialRole) || {
|
||||
label: t("member"),
|
||||
value: MembershipRole.MEMBER,
|
||||
}
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
|
||||
@@ -76,7 +87,7 @@ export default function MemberChangeRoleModal(props: {
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
options={options}
|
||||
value={role}
|
||||
onChange={(option) => option && setRole(option)}
|
||||
id="role"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { UserIcon } from "@heroicons/react/outline";
|
||||
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import React, { useState, useEffect, SyntheticEvent, useMemo } from "react";
|
||||
import React, { useState, SyntheticEvent, useMemo } from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
|
||||
import { TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
@@ -74,7 +74,7 @@ export default function MemberListItem(props: Props) {
|
||||
<div className="flex w-full flex-col justify-between sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={WEBSITE_URL + "/" + props.member.username + "/avatar.png"}
|
||||
imageSrc={WEBAPP_URL + "/" + props.member.username + "/avatar.png"}
|
||||
alt={name || ""}
|
||||
className="h-9 w-9 rounded-full"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
|
||||
function SkeletonLoaderTeamList({ className }: { className?: string }) {
|
||||
return (
|
||||
<>
|
||||
<ul className="-mx-4 animate-pulse divide-y divide-neutral-200 rounded-sm border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoaderTeamList;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-3 py-4">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-start space-x-2 px-2">
|
||||
<SkeletonText width="10" height="10" className="rounded-full"></SkeletonText>
|
||||
<div className="space-y-2">
|
||||
<SkeletonText height="4" width="32"></SkeletonText>
|
||||
<SkeletonText height="4" width="16"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 pr-4 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="12" height="4"></SkeletonText>
|
||||
<SkeletonText width="4" height="4"></SkeletonText>
|
||||
<SkeletonText width="4" height="4"></SkeletonText>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -118,7 +118,6 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
|
||||
)}
|
||||
</div>
|
||||
{props.team?.id && props.role !== MembershipRole.MEMBER && (
|
||||
// eslint-disable-next-line @next/next/link-passhref
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<div className="mt-5 hidden space-y-1 sm:block">
|
||||
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
|
||||
|
||||
@@ -5,13 +5,12 @@ import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
import React from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Text from "@components/ui/Text";
|
||||
|
||||
type TeamType = TeamPageProps["team"];
|
||||
type MembersType = TeamType["members"];
|
||||
@@ -37,7 +36,6 @@ const Team = ({ team }: TeamPageProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/link-passhref
|
||||
<Link key={member.id} href={`/${member.username}`}>
|
||||
<div className={classes}>
|
||||
<ArrowRightIcon
|
||||
@@ -53,14 +51,14 @@ const Team = ({ team }: TeamPageProps) => {
|
||||
<div>
|
||||
<Avatar
|
||||
alt={member.name || ""}
|
||||
imageSrc={WEBSITE_URL + "/" + member.username + "/avatar.png"}
|
||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||
className="-mt-4 h-12 w-12"
|
||||
/>
|
||||
<section className="mt-2 w-full space-y-1">
|
||||
<Text variant="title">{member.name}</Text>
|
||||
<Text variant="subtitle" className="">
|
||||
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
||||
<p className="font-medium text-neutral-900 dark:text-white">{member.name}</p>
|
||||
<p className="text-sm font-normal text-neutral-500 dark:text-white">
|
||||
{member.bio || t("user_from_team", { user: member.name, team: team.name })}
|
||||
</Text>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FC, Fragment } from "react";
|
||||
|
||||
type AdminRequiredProps = {
|
||||
as?: keyof JSX.IntrinsicElements;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const AdminRequired: FC<AdminRequiredProps> = ({ children, as, ...rest }) => {
|
||||
|
||||
@@ -4,9 +4,7 @@ import { Trans } from "next-i18next";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
||||
type Props = {};
|
||||
|
||||
function ImpersonatingBanner({}: Props) {
|
||||
function ImpersonatingBanner() {
|
||||
const { t } = useLocale();
|
||||
const { data } = useSession();
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||
|
||||
interface Props extends React.PropsWithChildren<any> {
|
||||
wide?: boolean;
|
||||
scroll?: boolean;
|
||||
noPadding?: boolean;
|
||||
isOpen: boolean;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export default function ModalContainer(props: Props) {
|
||||
export default function ModalContainer(
|
||||
props: PropsWithChildren<{
|
||||
wide?: boolean;
|
||||
scroll?: boolean;
|
||||
noPadding?: boolean;
|
||||
isOpen: boolean;
|
||||
onExit: () => void;
|
||||
}>
|
||||
) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onExit}>
|
||||
<DialogContent>
|
||||
<div
|
||||
className={classNames(
|
||||
"inline-block transform bg-white text-left align-bottom transition-all sm:align-middle",
|
||||
"inline-block w-full transform bg-white text-left align-bottom transition-all sm:align-middle",
|
||||
{
|
||||
"sm:w-full sm:max-w-lg ": !props.wide,
|
||||
"sm:w-4xl sm:max-w-4xl": props.wide,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const PoweredByCal = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -2,25 +2,38 @@ import { ChevronDownIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import React, { FC } from "react";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@calcom/ui/Dropdown";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
export type ActionType = {
|
||||
id: string;
|
||||
icon?: SVGComponent;
|
||||
iconClassName?: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
color?: "primary" | "secondary";
|
||||
} & ({ href?: never; onClick: () => any } | { href?: string; onClick?: never }) & {
|
||||
actions?: ActionType[];
|
||||
};
|
||||
} & (
|
||||
| { href: string; onClick?: never; actions?: never }
|
||||
| { href?: never; onClick: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void; actions?: never }
|
||||
| { actions?: ActionType[]; href?: never; onClick?: never }
|
||||
);
|
||||
|
||||
interface Props {
|
||||
actions: ActionType[];
|
||||
}
|
||||
|
||||
const DropdownActions = ({ actions, actionTrigger }: { actions: ActionType[]; actionTrigger?: any }) => {
|
||||
const defaultAction = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const DropdownActions = ({
|
||||
actions,
|
||||
actionTrigger,
|
||||
}: {
|
||||
actions: ActionType[];
|
||||
actionTrigger?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Dropdown>
|
||||
{!actionTrigger ? (
|
||||
@@ -40,7 +53,8 @@ const DropdownActions = ({ actions, actionTrigger }: { actions: ActionType[]; ac
|
||||
className="w-full rounded-none font-normal"
|
||||
href={action.href}
|
||||
StartIcon={action.icon}
|
||||
onClick={action.onClick}
|
||||
startIconClassName={action.iconClassName}
|
||||
onClick={action.onClick || defaultAction}
|
||||
data-testid={action.id}>
|
||||
{action.label}
|
||||
</Button>
|
||||
@@ -67,8 +81,9 @@ const TableActions: FC<Props> = ({ actions }) => {
|
||||
key={action.id}
|
||||
data-testid={action.id}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
onClick={action.onClick || defaultAction}
|
||||
StartIcon={action.icon}
|
||||
startIconClassName={action.iconClassName}
|
||||
{...(action?.actions ? { EndIcon: ChevronDownIcon } : null)}
|
||||
disabled={action.disabled}
|
||||
color={action.color || "secondary"}>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Body: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-gray-900 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Body;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Body from "./Body";
|
||||
|
||||
export default Body;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Caption: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-sm text-gray-500 dark:text-white leading-tight", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Caption;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Caption from "./Caption";
|
||||
|
||||
export default Caption;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Caption2: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-xs italic text-gray-500 dark:text-white leading-tight", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Caption2;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Caption2 from "./Caption2";
|
||||
|
||||
export default Caption2;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Footnote: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-xs font-medium text-gray-500 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Footnote;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Footnote from "./Footnote";
|
||||
|
||||
export default Footnote;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Headline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("font-cal text-xl text-gray-900 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Headline;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Headline from "./Headline";
|
||||
|
||||
export default Headline;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Largetitle: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("font-cal tracking-wider text-3xl mb-2", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Largetitle;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Largetitle from "./Largetitle";
|
||||
|
||||
export default Largetitle;
|
||||
@@ -1,15 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Overline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames(
|
||||
"text-sm capitalize font-medium text-gray-900 dark:text-white",
|
||||
props?.className
|
||||
);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Overline;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Overline from "./Overline";
|
||||
|
||||
export default Overline;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Subheadline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-xl text-gray-500 dark:text-white leading-relaxed", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Subheadline;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Subheadline from "./Subheadline";
|
||||
|
||||
export default Subheadline;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Subtitle: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-sm font-normal text-neutral-500 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Subtitle;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Subtitle from "./Subtitle";
|
||||
|
||||
export default Subtitle;
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* @deprecated create new a new set of components, waiting for designs
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
import Body from "./Body";
|
||||
import Caption from "./Caption";
|
||||
import Caption2 from "./Caption2";
|
||||
import Footnote from "./Footnote";
|
||||
import Headline from "./Headline";
|
||||
import Largetitle from "./Largetitle";
|
||||
import Overline from "./Overline";
|
||||
import Subheadline from "./Subheadline";
|
||||
import Subtitle from "./Subtitle";
|
||||
import Title from "./Title";
|
||||
import Title2 from "./Title2";
|
||||
import Title3 from "./Title3";
|
||||
|
||||
type Props = {
|
||||
variant?:
|
||||
| "overline"
|
||||
| "caption"
|
||||
| "body"
|
||||
| "caption2"
|
||||
| "footnote"
|
||||
| "headline"
|
||||
| "largetitle"
|
||||
| "subheadline"
|
||||
| "subtitle"
|
||||
| "title"
|
||||
| "title2"
|
||||
| "title3";
|
||||
children: any;
|
||||
text?: string;
|
||||
tx?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type TextProps = {
|
||||
children: any;
|
||||
text?: string;
|
||||
tx?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* static let largeTitle: Font
|
||||
* A font with the large title text style.
|
||||
*
|
||||
* static let title: Font
|
||||
* A font with the title text style.
|
||||
*
|
||||
* static let title2: Font
|
||||
* Create a font for second level hierarchical headings.
|
||||
*
|
||||
* static let title3: Font
|
||||
* Create a font for third level hierarchical headings.
|
||||
*
|
||||
* static let headline: Font
|
||||
* A font with the headline text style.
|
||||
*
|
||||
* static let subheadline: Font
|
||||
* A font with the subheadline text style.
|
||||
*
|
||||
* static let body: Font
|
||||
* A font with the body text style.
|
||||
*
|
||||
* static let callout: Font
|
||||
* A font with the callout text style.
|
||||
*
|
||||
* static let caption: Font
|
||||
* A font with the caption text style.
|
||||
*
|
||||
* static let caption2: Font
|
||||
* Create a font with the alternate caption text style.
|
||||
*
|
||||
* static let footnote: Font
|
||||
* A font with the footnote text style.
|
||||
*/
|
||||
|
||||
const Text: React.FunctionComponent<Props> = (props: Props) => {
|
||||
switch (props?.variant) {
|
||||
case "overline":
|
||||
return (
|
||||
<Overline text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Overline>
|
||||
);
|
||||
case "body":
|
||||
return (
|
||||
<Body text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Body>
|
||||
);
|
||||
case "caption":
|
||||
return (
|
||||
<Caption text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Caption>
|
||||
);
|
||||
case "caption2":
|
||||
return (
|
||||
<Caption2 text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Caption2>
|
||||
);
|
||||
case "footnote":
|
||||
return (
|
||||
<Footnote text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Footnote>
|
||||
);
|
||||
case "headline":
|
||||
return (
|
||||
<Headline text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Headline>
|
||||
);
|
||||
case "largetitle":
|
||||
return (
|
||||
<Largetitle text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Largetitle>
|
||||
);
|
||||
case "subheadline":
|
||||
return (
|
||||
<Subheadline text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Subheadline>
|
||||
);
|
||||
case "subtitle":
|
||||
return (
|
||||
<Subtitle text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Subtitle>
|
||||
);
|
||||
case "title":
|
||||
return (
|
||||
<Title text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Title>
|
||||
);
|
||||
case "title2":
|
||||
return (
|
||||
<Title2 text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Title2>
|
||||
);
|
||||
case "title3":
|
||||
return (
|
||||
<Title3 text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Title3>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Body text={props?.text} tx={props?.tx} className={props?.className}>
|
||||
{props.children}
|
||||
</Body>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Text;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Title: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("font-medium text-neutral-900 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Title;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Title from "./Title";
|
||||
|
||||
export default Title;
|
||||
@@ -1,12 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Title2: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-base font-normal text-gray-900 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Title2;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Title2 from "./Title2";
|
||||
|
||||
export default Title2;
|
||||
@@ -1,15 +0,0 @@
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Title3: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames(
|
||||
"text-xs font-semibold leading-tight text-gray-900 dark:text-white",
|
||||
props?.className
|
||||
);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
export default Title3;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user