Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be799e35d5 | ||
|
|
e35b9155d2 | ||
|
|
d1d467d28d | ||
|
|
b2e5ac4cda | ||
|
|
4d2f134dc7 | ||
|
|
aa166190e9 | ||
|
|
0e9d754f64 | ||
|
|
3b7f18cb5d | ||
|
|
63660a37f9 | ||
|
|
805419d58b | ||
|
|
0923b83eaf | ||
|
|
f40b035ff8 | ||
|
|
2b7f7a06ac | ||
|
|
baab6ecb2e | ||
|
|
9861156edb | ||
|
|
df3e3e8237 | ||
|
|
ef36a93a55 | ||
|
|
6fc3e17b47 | ||
|
|
a9ad9a6a39 | ||
|
|
1b09b7efd4 | ||
|
|
f43bfd308b | ||
|
|
4dec3fb97c | ||
|
|
ec07f84d1e | ||
|
|
07965b2a32 | ||
|
|
4c2f6df94d | ||
|
|
48916cf85e | ||
|
|
5e81d9fb56 | ||
|
|
77bf376c94 | ||
|
|
200a704756 | ||
|
|
6dd63ee3da | ||
|
|
e99b018d3b | ||
|
|
8f020bf1d0 | ||
|
|
78e2db6d70 | ||
|
|
ff6a7bf2a6 | ||
|
|
0433b0a69a | ||
|
|
ee552d8d64 | ||
|
|
dda5750bbc | ||
|
|
18389f459d | ||
|
|
fd16d734fb | ||
|
|
55152141ee | ||
|
|
75b817531e | ||
|
|
9162d90504 | ||
|
|
bf2843c17e | ||
|
|
6a28396499 | ||
|
|
1a0a318a55 | ||
|
|
99b821edcd | ||
|
|
ded0b1410a | ||
|
|
58d1c28e9d | ||
|
|
7ec5f01647 | ||
|
|
6f073762fa | ||
|
|
ebd4750f2d | ||
|
|
b1e4c23adc | ||
|
|
03b8643d1e | ||
|
|
318ec316d5 | ||
|
|
bf7c86ffe5 | ||
|
|
33e36e5320 | ||
|
|
39199e515e | ||
|
|
44b8cedef0 | ||
|
|
c7d220e998 | ||
|
|
78f0b095ae | ||
|
|
9a0b1832c3 | ||
|
|
c8ef235916 | ||
|
|
9760a2606e | ||
|
|
e916c67415 | ||
|
|
f6684c001c | ||
|
|
9e5b1f121f | ||
|
|
179da11f4b | ||
|
|
4bbca06bea | ||
|
|
c5263efce2 | ||
|
|
578c3d64ef | ||
|
|
dbca46d677 | ||
|
|
1f953c5ca0 | ||
|
|
a677ffb2a5 | ||
|
|
0ff6d4557a | ||
|
|
7bcc5c4548 | ||
|
|
dbfc1477aa | ||
|
|
b1d637d1a6 | ||
|
|
c3c7f82841 | ||
|
|
8d40e7cfd2 | ||
|
|
46266781b5 | ||
|
|
5fa336c195 | ||
|
|
0b53cd6ffc | ||
|
|
9ad4cba8cf | ||
|
|
9ff4d7e024 | ||
|
|
012af52f1b | ||
|
|
6e1c165b31 | ||
|
|
83d2c924ca | ||
|
|
ebc098186d | ||
|
|
c7288bb822 | ||
|
|
5587867598 | ||
|
|
62f6373102 | ||
|
|
61f29d4a86 | ||
|
|
93ab0cfd09 | ||
|
|
8f495f7b67 | ||
|
|
c9d5af6591 | ||
|
|
adffb00817 | ||
|
|
afe730e502 | ||
|
|
4cd43087e9 | ||
|
|
371fd48c15 | ||
|
|
657cf7e180 | ||
|
|
6b0ac96b38 | ||
|
|
2c30976e99 | ||
|
|
a7212660a4 | ||
|
|
5e4e98e002 | ||
|
|
7bbfde607a | ||
|
|
f179c5b818 | ||
|
|
03c27ebd62 | ||
|
|
db2886d0d4 | ||
|
|
5db762e987 | ||
|
|
f9cc71d120 | ||
|
|
6db129e3bf | ||
|
|
c6b1d99270 | ||
|
|
34cd41be67 | ||
|
|
8ca627040b | ||
|
|
13528ada2e | ||
|
|
3c7380c3a9 | ||
|
|
9d951a5802 | ||
|
|
b70550fc83 | ||
|
|
6a12ff7984 | ||
|
|
279d9766d8 | ||
|
|
85b61bed01 | ||
|
|
c2ca848f0a | ||
|
|
c890e8d06d | ||
|
|
7d6a6bf812 | ||
|
|
a4684042a6 | ||
|
|
56da9c7c5f | ||
|
|
7967c6e46b | ||
|
|
8d96b79a5b | ||
|
|
d0325c13d1 | ||
|
|
5ea08c237a | ||
|
|
b3f7e8e322 | ||
|
|
b7d4e3885e | ||
|
|
3c324eb776 | ||
|
|
c16e71aa01 | ||
|
|
5fa3f45fed | ||
|
|
450d0e7f05 | ||
|
|
b5745fd0e5 | ||
|
|
bac12d0cbd | ||
|
|
f17833552e | ||
|
|
15bdcaff88 | ||
|
|
29507557d6 | ||
|
|
30c1dd036d | ||
|
|
8f24b92666 | ||
|
|
7c99004225 | ||
|
|
8bce6055c7 | ||
|
|
049e433781 | ||
|
|
3ac8caecbf | ||
|
|
e394cfdcd8 | ||
|
|
be99035f49 | ||
|
|
6c8e624496 | ||
|
|
5369d4123e | ||
|
|
3a055018f6 | ||
|
|
6ac670b366 | ||
|
|
db35a7c68f | ||
|
|
a1d8fb0b46 | ||
|
|
7170fadff8 | ||
|
|
fa5e6b42de | ||
|
|
1f02a5c718 | ||
|
|
c55b17bf58 | ||
|
|
4975493090 | ||
|
|
785a014f3c | ||
|
|
dd941e62ef | ||
|
|
eec2d8ceed | ||
|
|
b546d9e33e | ||
|
|
201d17264c | ||
|
|
2cbf46a323 | ||
|
|
833791bc9a | ||
|
|
00c9df7602 | ||
|
|
669199e273 | ||
|
|
93e2246b18 | ||
|
|
f639405c20 | ||
|
|
240fc67d72 | ||
|
|
eef405ef41 | ||
|
|
1469b856a6 | ||
|
|
53bbbf793d | ||
|
|
a91f251b98 | ||
|
|
d95c3a1459 | ||
|
|
92bfb3e5b9 | ||
|
|
fbc4eb5457 | ||
|
|
4a74abcb34 | ||
|
|
09c8f55682 | ||
|
|
3f3ffa2ba8 | ||
|
|
654cfbb653 | ||
|
|
ad35d3d567 | ||
|
|
70a31874f0 | ||
|
|
fe8b0fc5f8 | ||
|
|
2983fb4c12 | ||
|
|
03cc4855a8 | ||
|
|
c82a9853d2 | ||
|
|
d732a984c2 | ||
|
|
826583f404 | ||
|
|
b6dd0d5804 | ||
|
|
df2c3c42bf | ||
|
|
7122a4a91d | ||
|
|
5be669a479 | ||
|
|
8629d92a64 | ||
|
|
840b6c498a | ||
|
|
7a8b9e5f32 | ||
|
|
fe50f04075 | ||
|
|
e1e369de9e | ||
|
|
9b2ad3f350 | ||
|
|
83dfafb8a1 | ||
|
|
2cd7ddcdbe | ||
|
|
2fa9ea4501 | ||
|
|
6fff27b5ad | ||
|
|
0794224288 | ||
|
|
e3e1de3c11 | ||
|
|
52759333fd | ||
|
|
e27a2bafcd | ||
|
|
87befdbdf4 | ||
|
|
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 |
@@ -21,7 +21,8 @@ DAILY_SCALE_PLAN=''
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS='{}'
|
||||
GOOGLE_API_CREDENTIALS=
|
||||
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||
|
||||
+14
-1
@@ -77,16 +77,29 @@ NEXT_PUBLIC_HELPSCOUT_KEY=
|
||||
# Inbox to send user feedback
|
||||
SEND_FEEDBACK_EMAIL=
|
||||
|
||||
# Sengrid
|
||||
# Used for email reminders in workflows
|
||||
SENDGRID_API_KEY=
|
||||
SENDGRID_EMAIL=
|
||||
|
||||
# Twilio
|
||||
# Used to send SMS reminders in workflows
|
||||
TWILIO_SID=
|
||||
TWILIO_TOKEN=
|
||||
TWILIO_MESSAGING_SID=
|
||||
|
||||
# This is used so we can bypass emails in auth flows for E2E testing
|
||||
# Set it to "1" if you need to run E2E tests locally
|
||||
NEXT_PUBLIC_IS_E2E=
|
||||
|
||||
# Used for internal billing system
|
||||
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=
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID=
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID=
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"♻️ autoupdate":
|
||||
- apps/web/public/static/locales/**/*.json
|
||||
|
||||
automerge:
|
||||
- apps/web/public/static/locales/**/*.json
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Add bugs to Bug Hunting
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- labeled
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
add-to-project:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v0.1.0
|
||||
with:
|
||||
project-url: https://github.com/orgs/calcom/projects/1
|
||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
labeled: 🐛 bug, 🎨 needs design, 🙋🏻♂️help wanted, 🚨 urgent
|
||||
@@ -3,15 +3,11 @@ on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**.tsx?"
|
||||
jobs:
|
||||
types:
|
||||
name: Check types
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
check-types:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
@@ -19,10 +15,10 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
- name: Use Node 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: 16.x
|
||||
cache: "yarn"
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn type-check
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
jobs:
|
||||
cron:
|
||||
cron-bookingReminder:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
jobs:
|
||||
cron:
|
||||
cron-downgradeUsers:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - scheduleEmailReminders
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
jobs:
|
||||
cron-scheduleEmailReminders:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/scheduleEmailReminders \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - scheduleSMSReminders
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “At minute 0, 15, 30, and 45.” (see https://crontab.guru)
|
||||
- cron: "0,15,30,45 * * * *"
|
||||
jobs:
|
||||
cron-scheduleSMSReminders:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/scheduleSMSReminders \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Staging DB Snapshot
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1-5" # At 04:00 on every day-of-week from Monday through Friday.
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
db-staging-snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install Snaplet CLI
|
||||
run: curl -sL https://app.snaplet.dev/get-cli/ | bash
|
||||
- name: Restore Snapshot
|
||||
run: snaplet snapshot restore -y
|
||||
env:
|
||||
SNAPLET_DATABASE_URL: ${{ secrets.SNAPLET_DATA_TARGET_DB_URL }}
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_DATABASE_ID: ${{ secrets.SNAPLET_DATABASE_ID }}
|
||||
@@ -0,0 +1,99 @@
|
||||
name: E2E App-Store Apps
|
||||
on:
|
||||
push:
|
||||
branches: [ feature/event-routing ]
|
||||
pull_request_target: # So we can test on forks
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- apps/api/**
|
||||
- apps/console/**
|
||||
- apps/docs/**
|
||||
- apps/swagger/**
|
||||
- apps/website/**
|
||||
- apps/web/public/**
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 20
|
||||
name: E2E App-Store Apps
|
||||
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 app-e2e-quick
|
||||
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-core
|
||||
path: packages/app-store/**/playwright/results
|
||||
@@ -1,27 +1,16 @@
|
||||
name: E2E test
|
||||
name: E2E Embed tests and booking flow(for non-embed as well)
|
||||
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/**
|
||||
paths:
|
||||
- apps/web/**
|
||||
- packages/embeds/**
|
||||
jobs:
|
||||
test:
|
||||
e2e-embed:
|
||||
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 }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
@@ -33,6 +22,9 @@ jobs:
|
||||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
@@ -69,10 +61,10 @@ jobs:
|
||||
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 }}
|
||||
- name: Use Node 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: 16.x
|
||||
cache: "yarn"
|
||||
|
||||
- name: Cache playwright binaries
|
||||
@@ -89,9 +81,8 @@ jobs:
|
||||
- name: Install playwright deps
|
||||
# if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: yarn playwright install --with-deps
|
||||
- run: yarn embed-tests-prepare
|
||||
- run: yarn workspace @calcom/embed-core embed-tests-update-snapshots:ci
|
||||
- run: yarn workspace @calcom/embed-react embed-tests-update-snapshots:ci
|
||||
- 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() }}
|
||||
|
||||
+13
-11
@@ -3,17 +3,13 @@ on:
|
||||
pull_request_target: # So we can test on forks
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- apps/web/public/static/locales/**
|
||||
paths:
|
||||
- apps/web/**
|
||||
- packages/**
|
||||
jobs:
|
||||
test:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
@@ -25,9 +21,15 @@ jobs:
|
||||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
@@ -61,10 +63,10 @@ jobs:
|
||||
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 }}
|
||||
- name: Use Node 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: 16.x
|
||||
cache: "yarn"
|
||||
|
||||
- name: Cache playwright binaries
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -3,13 +3,12 @@ on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**.tsx?"
|
||||
- "**.jsx?"
|
||||
jobs:
|
||||
lint:
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
@@ -18,10 +17,10 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
- name: Use Node 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: 16.x
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install deps
|
||||
@@ -36,10 +35,11 @@ jobs:
|
||||
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
|
||||
|
||||
- name: Annotate Code Linting Results
|
||||
uses: ataylorme/eslint-annotate-action@1.2.0
|
||||
uses: ataylorme/eslint-annotate-action@v2
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
report-json: "lint-results/eslint_report.json"
|
||||
only-pr-files: false
|
||||
|
||||
- name: Upload ESLint report
|
||||
if: ${{ always() }}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: "Meta Workflow: Require Conditional Status Checks"
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
required-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Ensure All Conditional Checks Have Passed
|
||||
uses: blend/require-conditional-status-checks@2022.02.04
|
||||
with:
|
||||
timeout: 15m
|
||||
github-token: ${{ github.token }}
|
||||
interval: 20s
|
||||
# Question marks aren't working
|
||||
# @see https://github.com/blend/require-conditional-status-checks/pull/11
|
||||
checks-yaml: |
|
||||
- job: lint
|
||||
paths:
|
||||
- /**/*.jsx
|
||||
- /**/*.js
|
||||
- /**/*.tsx
|
||||
- /**/*.ts
|
||||
- job: check-types
|
||||
paths:
|
||||
- /**/*.tsx
|
||||
- /**/*.ts
|
||||
- job: e2e
|
||||
paths:
|
||||
- /apps/web/**
|
||||
- /packages/**
|
||||
- job: e2e-embed
|
||||
paths:
|
||||
- /apps/web/**
|
||||
- /packages/embeds/**
|
||||
@@ -3,7 +3,6 @@ on:
|
||||
schedule:
|
||||
- cron: "15 */4 * * *"
|
||||
workflow_dispatch: ~
|
||||
|
||||
jobs:
|
||||
submodule-sync:
|
||||
name: Submodule update
|
||||
@@ -11,11 +10,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: run action
|
||||
uses: releasehub-com/github-action-create-pr-parent-submodule@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
parent_repository: "calcom/cal.com"
|
||||
checkout_branch: "main"
|
||||
pr_against_branch: "main"
|
||||
owner: "calcom"
|
||||
submodules: recursive
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Commit
|
||||
run: |
|
||||
git config user.email "actions@github.com"
|
||||
git config user.name "github-actions"
|
||||
git commit -am "Auto updated submodule references" && git push || echo "No changes to commit"
|
||||
|
||||
@@ -72,3 +72,7 @@ dist
|
||||
|
||||
# Linting
|
||||
lint-results
|
||||
|
||||
# Snaplet
|
||||
.snaplet/snapshots
|
||||
.snaplet/structure.d.ts
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
|
||||
yarn app-store:build && git add packages/app-store/*.generated.*
|
||||
|
||||
+1
-2
@@ -4,5 +4,4 @@ version = 1
|
||||
autoupdate_label = "♻️ autoupdate"
|
||||
|
||||
[approve]
|
||||
auto_approve_usernames = ["dependabot"]
|
||||
|
||||
auto_approve_usernames = ["dependabot", "github-actions"]
|
||||
|
||||
@@ -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 as c } 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]: c.uuid(x[key]) };
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
function generateSlug(x: string) {
|
||||
return c.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 generateUsername = (x: string) => `${c.firstName(x)}-${c.lastName(x)}${c.int(x, { min: 2, max: 99 })}`;
|
||||
|
||||
const config: Transform = () => ({
|
||||
public: {
|
||||
ApiKey: ({ row }) => ({
|
||||
hashedKey: c.uuid(row.hashedKey),
|
||||
}),
|
||||
App: ({ row }) => ({
|
||||
keys: replaceSensitiveKeys(row.keys),
|
||||
}),
|
||||
Attendee: ({ row }) => ({
|
||||
email: c.email(row.email),
|
||||
name: c.fullName(row.name),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
}),
|
||||
Credential: ({ row }) => ({
|
||||
key: typeof row.key === "string" ? c.uuid(row.key) : replaceSensitiveKeys(row.key),
|
||||
}),
|
||||
EventType: ({ row }) => ({
|
||||
slug: generateSlug(row.slug),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
eventName: c.words(row.eventName, { max: 3 }),
|
||||
}),
|
||||
ResetPasswordRequest: ({ row }) => ({
|
||||
email: c.email(row.email),
|
||||
}),
|
||||
Schedule: ({ row }) => ({
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
}),
|
||||
Team: ({ row }) => ({
|
||||
bio: c.sentence(row.bio),
|
||||
name: c.words(row.name, { max: 2 }),
|
||||
slug: generateSlug(row.slug),
|
||||
}),
|
||||
users: ({ row }) =>
|
||||
row.role !== "ADMIN"
|
||||
? {
|
||||
bio: c.sentence(row.bio),
|
||||
email: c.email(row.email),
|
||||
name: c.fullName(row.name),
|
||||
password: c.password(row.password),
|
||||
timeZone: c.timezone(row.timeZone),
|
||||
username: generateUsername(row.username),
|
||||
}
|
||||
: row,
|
||||
VerificationToken: ({ row }) => ({
|
||||
token: c.uuid(row.token),
|
||||
}),
|
||||
Account: ({ row }) => ({
|
||||
access_token: c.uuid(row.access_token),
|
||||
refresh_token: c.uuid(row.refresh_token),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export default config;
|
||||
Vendored
-4
@@ -22,10 +22,6 @@
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
// Try start the task on folder open
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+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
-7
@@ -1,6 +1,6 @@
|
||||
# Contributing to Cal.com
|
||||
|
||||
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -8,22 +8,19 @@ Contributions are what make the open source community such an amazing place to b
|
||||
|
||||
### Legend
|
||||
|
||||
✅ = has knowledge
|
||||
✅ = has knowledge
|
||||
|
||||
🥇 = is their main priority
|
||||
|
||||
⚠️ = is the only one with knowledge
|
||||
⚠️ = is the only one with knowledge
|
||||
|
||||
👀 = has no knowledge but wants to be onboarded
|
||||
👀 = 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
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
## About The Project
|
||||
|
||||
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/134363898-4b29e18f-3e61-42b7-95bc-10891056249d.gif">
|
||||
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/176390354-f1bc7069-0341-437a-9bb8-eb41092b4016.gif">
|
||||
|
||||
# Scheduling infrastructure for absolutely everyone
|
||||
|
||||
@@ -94,7 +94,7 @@ Here is what you need to be able to run Cal.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone the repo into a public GitHub repository (to comply with AGPLv3. To clone in a private repository, [acquire a commercial license](https://cal.com/sales))
|
||||
1. Clone the repo into a public GitHub repository (or fork https://github.com/calcom/cal.com/fork). If you plan to distribute the code, keep the source code public to comply with [AGPLv3](https://github.com/calcom/cal.com/blob/main/LICENSE). To clone in a private repository, [acquire a commercial license](https://cal.com/sales))
|
||||
|
||||
```sh
|
||||
git clone https://github.com/calcom/cal.com.git
|
||||
@@ -167,7 +167,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
</details>
|
||||
|
||||
1. Set a 32 character random string in your .env file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
|
||||
1. Set up the database using the Prisma schema (found in `apps/web/prisma/schema.prisma`)
|
||||
1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
|
||||
|
||||
```sh
|
||||
yarn workspace @calcom/prisma db-deploy
|
||||
@@ -189,7 +189,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
|
||||
1. Click on the `User` model to add a new user record.
|
||||
1. Fill out the fields `email`, `username`, `password`, and set `metadata` to empty `{}` (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
|
||||
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `apps/web/prisma/schema.prisma` file.
|
||||
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file.
|
||||
1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
||||
|
||||
### E2E-Testing
|
||||
@@ -278,11 +278,17 @@ The Docker configuration can be found [in our docker repository](https://github.
|
||||
|
||||
You can deploy Cal on [Railway](https://railway.app/) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal on their platform.
|
||||
|
||||
### Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_SECRET,NEXTAUTH_URL,CALENDSO_ENCRYPTION_KEY,NEXT_PUBLIC_LICENSE_CONSENT,CALCOM_LICENSE_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com)
|
||||
|
||||
<!-- ROADMAP -->
|
||||
|
||||
## Roadmap
|
||||
|
||||
See the [roadmap project](https://github.com/orgs/calcom/projects/1) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||
<a href="https://cal.com/roadmap"><img src="https://user-images.githubusercontent.com/8019099/176388295-25081ca4-207e-4468-8079-37b195fa8e59.png" alt="Cal.com Roadmap" /></a>
|
||||
|
||||
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
|
||||
@@ -418,6 +424,31 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
||||
1. Select all events for the webhook you interested, e.g. `sleep_created`
|
||||
1. Copy the webhook secret (`sec...`) to `VITAL_WEBHOOK_SECRET` in the .env.appStore file.
|
||||
|
||||
## Workflows
|
||||
|
||||
### Setting up SendGrid for Email reminders
|
||||
|
||||
1. Create a SendGrid account (https://signup.sendgrid.com/)
|
||||
2. Go to Settings -> API keys and create an API key
|
||||
3. Copy API key to your .env file into the SENDGRID_API_KEY field
|
||||
4. Go to Settings -> Sender Authentication and verify a single sender
|
||||
5. Copy the verified E-Mail to your .env file into the SENDGRID_EMAIL field
|
||||
|
||||
### Setting up Twilio for SMS reminders
|
||||
|
||||
1. Create a Twilio account (https://www.twilio.com/try-twilio)
|
||||
2. Click ‘Get a Twilio phone number’
|
||||
3. Copy Account SID to your .env file into the TWILIO_SID field
|
||||
4. Copy Auth Token to your .env file into the TWILIO_TOKEN field
|
||||
5. Create a messaging service (Develop -> Messaging -> Services)
|
||||
6. Choose any name for the messaging service
|
||||
7. Click 'Add Senders'
|
||||
8. Choose phone number as sender type
|
||||
9. Add the listed phone number
|
||||
9. Leave all other fields as they are
|
||||
10. Complete setup and click ‘View my new Messaging Service’
|
||||
11. Copy Messaging Service SID to your .env file into the TWILIO_MESSAGING_SID field
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
@@ -437,7 +468,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>
|
||||
|
||||
<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.
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Security
|
||||
|
||||
Contact: security@cal.com
|
||||
|
||||
Based on [https://supabase.com/.well-known/security.txt](https://supabase.com/.well-known/security.txt)
|
||||
|
||||
At Cal.com, we consider the security of our systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present.
|
||||
|
||||
If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our clients and our systems.
|
||||
|
||||
## Out of scope vulnerabilities:
|
||||
|
||||
- Clickjacking on pages with no sensitive actions.
|
||||
- Unauthenticated/logout/login CSRF.
|
||||
- Attacks requiring MITM or physical access to a user's device.
|
||||
- Any activity that could lead to the disruption of our service (DoS).
|
||||
- Content spoofing and text injection issues without showing an attack vector/without being able to modify HTML/CSS.
|
||||
- Email spoofing
|
||||
- Missing DNSSEC, CAA, CSP headers
|
||||
- Lack of Secure or HTTP only flag on non-sensitive cookies
|
||||
- Deadlinks
|
||||
|
||||
## Please do the following:
|
||||
|
||||
- E-mail your findings to [security@cal.com](mailto:security@cal.com).
|
||||
- Do not run automated scanners on our infrastructure or dashboard. If you wish to do this, contact us and we will set up a sandbox for you.
|
||||
- Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data,
|
||||
- Do not reveal the problem to others until it has been resolved,
|
||||
- Do not use attacks on physical security, social engineering, distributed denial of service, spam or applications of third parties,
|
||||
- Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. Usually, the IP address or the URL of the affected system and a description of the vulnerability will be sufficient, but complex vulnerabilities may require further explanation.
|
||||
|
||||
## What we promise:
|
||||
|
||||
- We will respond to your report within 3 business days with our evaluation of the report and an expected resolution date,
|
||||
- If you have followed the instructions above, we will not take any legal action against you in regard to the report,
|
||||
- We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission,
|
||||
- We will keep you informed of the progress towards resolving the problem,
|
||||
- In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise), and
|
||||
- We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved.
|
||||
@@ -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
-1
Submodule apps/api updated: e1bcd38e61...a8e8acd053
+1
-1
Submodule apps/console updated: 0b2cf888ab...8a796ab11d
@@ -4,19 +4,20 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "PORT=4000 next",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/docs.json",
|
||||
"start": "PORT=4000 next start",
|
||||
"build": "next build"
|
||||
"type-check": "tsc --pretty --noEmit"
|
||||
},
|
||||
"author": "Cal.com, Inc.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iframe-resizer-react": "^1.1.0",
|
||||
"next": "^12.1.6",
|
||||
"next": "^12.2.0",
|
||||
"nextra": "^1.1.0",
|
||||
"nextra-theme-docs": "^1.2.2",
|
||||
"react": "^18.1.0",
|
||||
|
||||
@@ -25,7 +25,7 @@ To remove a product from your account that isn't listed in your Google Account,
|
||||
6. In the third page (Test Users), add the Google account(s) you'll using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
|
||||
7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option.
|
||||
8. Select Web Application as the Application Type.
|
||||
9. Under Authorized redirect URI's, select Add URI and then add the URI `<CALENDSO URL>/api/integrations/googlecalendar/callback` replacing CALENDSO URL with the URI at which your application runs.
|
||||
9. Under Authorized redirect URI's, select Add URI and then add the two URIs `<CALENDSO URL>/api/integrations/googlecalendar/callback` and `<CALENDSO URL>/api/auth/callback/google` replacing CALENDSO URL with the URI at which your application runs.
|
||||
10. The key will be created and you will be redirected back to the Credentials page. Select the newly generated client ID under OAuth 2.0 Client IDs.
|
||||
11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the .env file as the value for GOOGLE_API_CREDENTIALS key.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ You need a PostgresDB database hosted somewhere. [Heroku](https://www.heroku.com
|
||||
git clone https://github.com/<your-fork>/cal.com.git
|
||||
```
|
||||
|
||||
2. Copy the `.env.example` file in `apps/web`, rename it to `.env` and fill it with your settings ([See manual setup](https://github.com/calcom/cal.com#manual) and [Obtaining the Google API Credentials](https://github.com/calcom/cal.com#obtaining-the-google-api-credentials))
|
||||
2. Copy the `.env.example` file in `apps/web`, rename it to `.env` and fill it with your settings ([See manual setup](https://github.com/calcom/cal.com#manual-setup) and [Obtaining the Google API Credentials](https://github.com/calcom/cal.com#obtaining-the-google-api-credentials))
|
||||
|
||||
3. Install packages with `yarn`
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SEO_IMG_DEFAULT, DOCS_URL } from "@calcom/lib/constants";
|
||||
|
||||
const themeConfig = {
|
||||
github: "https://github.com/calcom/cal.com",
|
||||
docsRepositoryBase: "https://github.com/calcom/cal.com/blob/main/apps/docs/pages",
|
||||
@@ -18,11 +20,11 @@ const themeConfig = {
|
||||
content="Explore advice and explanations for all of our features, and discover new tips and tricks to get the most out of your subscription."
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content="https://cal.com/og-image.png" />
|
||||
<meta name="twitter:image" content={SEO_IMG_DEFAULT} />
|
||||
<meta name="twitter:site:domain" content="docs.cal.com" />
|
||||
<meta name="twitter:url" content="https://docs.cal.com" />
|
||||
<meta name="twitter:url" content={DOCS_URL} />
|
||||
<meta name="og:title" content="Cal.com Documentation" />
|
||||
<meta name="og:image" content="https://cal.com/og-image.png" />
|
||||
<meta name="og:image" content={SEO_IMG_DEFAULT} />
|
||||
<meta name="apple-mobile-web-app-title" content="Cal.com Docs" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "PORT=4200 next dev",
|
||||
"build": "next build",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "PORT=4200 next dev",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/swagger.json",
|
||||
"start": "PORT=4200 next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.5.1",
|
||||
"isarray": "2.0.5",
|
||||
"next": "12.1.6",
|
||||
"next": "12.2.0",
|
||||
"openapi-snippet": "^0.13.0",
|
||||
"react": "18.1.0",
|
||||
"react-dom": "18.1.0",
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
{
|
||||
"extends": "@calcom/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"../../packages/types/*.d.ts",
|
||||
"../../packages/types/next-auth.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
}));
|
||||
return (
|
||||
<Select
|
||||
name={"additionalCalendar"}
|
||||
name="additionalCalendar"
|
||||
placeholder={t("connect_additional_calendar")}
|
||||
options={options}
|
||||
styles={{
|
||||
@@ -65,6 +65,10 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "2px",
|
||||
"@media only screen and (min-width: 640px)": {
|
||||
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||
maxWidth: "320px",
|
||||
},
|
||||
};
|
||||
},
|
||||
}}
|
||||
|
||||
+241
-183
@@ -12,15 +12,20 @@ import { ChevronLeftIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import { Button, SkeletonButton } from "@calcom/ui";
|
||||
import LicenseRequired from "@ee/components/LicenseRequired";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Shell from "@components/Shell";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
export default function App({
|
||||
const Component = ({
|
||||
name,
|
||||
type,
|
||||
logo,
|
||||
@@ -36,25 +41,19 @@ export default function App({
|
||||
email,
|
||||
tos,
|
||||
privacy,
|
||||
}: {
|
||||
name: string;
|
||||
type: AppType["type"];
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
author: string;
|
||||
pro?: boolean;
|
||||
price?: number;
|
||||
commission?: number;
|
||||
feeType?: AppType["feeType"];
|
||||
docs?: string;
|
||||
website?: string;
|
||||
email: string; // required
|
||||
tos?: string;
|
||||
privacy?: string;
|
||||
}) {
|
||||
isProOnly,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t } = useLocale();
|
||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: () => {
|
||||
showToast("App successfully installed", "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || "App could not be installed", "error");
|
||||
},
|
||||
});
|
||||
|
||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
@@ -90,176 +89,235 @@ 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-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")}
|
||||
</a>
|
||||
</Link>
|
||||
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
||||
<div className="flex">
|
||||
<img className="h-16 w-16 rounded-sm" src={logo} alt={name} />
|
||||
<header className="px-4 py-2">
|
||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
||||
<h2 className="text-sm text-gray-500">
|
||||
<span className="capitalize">{categories[0]}</span> • {t("published_by", { author })}
|
||||
</h2>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
||||
{!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>
|
||||
) : (
|
||||
return (
|
||||
<div className="-mx-4 md:-mx-8">
|
||||
<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")}
|
||||
</a>
|
||||
</Link>
|
||||
<div className="items-center justify-between py-4 sm:flex sm:py-8">
|
||||
<div className="flex">
|
||||
<img className="h-16 w-16 rounded-sm" src={logo} alt={name} />
|
||||
<header className="px-4 py-2">
|
||||
<div className="flex items-center">
|
||||
<h1 className="font-cal text-xl text-gray-900">{name}</h1>
|
||||
{isProOnly && user?.plan === "FREE" ? (
|
||||
<Badge className="ml-2" variant="default">
|
||||
PRO
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h2 className="text-sm text-gray-500">
|
||||
<span className="capitalize">{categories[0]}</span> • {t("published_by", { author })}
|
||||
</h2>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 sm:text-right">
|
||||
{!isLoading ? (
|
||||
isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={CheckIcon} color="secondary" disabled>
|
||||
{installedAppCount > 0
|
||||
? t("active_install", { count: installedAppCount })
|
||||
: t("globally_install")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
<Button data-testid="install-app-button" {...buttonProps}>
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
)}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button StartIcon={PlusIcon} {...props} data-testid="install-app-button">
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<SkeletonButton width="24" height="10" />
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<small className="block text-right">
|
||||
{feeType === "usage-based"
|
||||
? commission + "% + " + priceInDollar + "/booking"
|
||||
: priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* reintroduce once we show permissions and features
|
||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
|
||||
</div>
|
||||
|
||||
<div className="justify-between px-8 py-10 md:flex">
|
||||
<div className="prose-sm prose mb-6">{body}</div>
|
||||
<div className="md:max-w-80 flex-1 md:ml-8">
|
||||
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
|
||||
<div className="space-x-2">
|
||||
{categories.map((category) => (
|
||||
<Link href={"/apps/categories/" + category} key={category}>
|
||||
<a>
|
||||
<Badge variant="success">{category}</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<h4 className="mt-8 font-medium text-gray-900 ">{t("pricing")}</h4>
|
||||
<small>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button data-testid="install-app-button" {...props}>
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<SkeletonButton width="24" height="10" />
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<small className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</small>
|
||||
<h4 className="mt-8 mb-2 font-medium text-gray-900 ">{t("learn_more")}</h4>
|
||||
<ul className="prose -ml-1 -mr-1 text-xs leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={docs}>
|
||||
<BookOpenIcon className="mr-1 -mt-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={website}>
|
||||
<ExternalLinkIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<MailIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={tos}>
|
||||
<DocumentTextIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<ShieldCheckIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-6" />
|
||||
<small className="leading-1 block text-gray-500">
|
||||
Every app published on the Cal.com App Store is open source and thoroughly tested via peer
|
||||
reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are
|
||||
published by Cal.com. If you encounter inappropriate content or behaviour please report it.
|
||||
</small>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<FlagIcon className="inline h-3 w-3" /> Report App
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
</>
|
||||
{/* reintroduce once we show permissions and features
|
||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
|
||||
</div>
|
||||
|
||||
<div className="justify-between px-8 py-10 md:flex">
|
||||
<div className="prose-sm prose mb-6">{body}</div>
|
||||
<div className="md:max-w-80 flex-1 md:ml-8">
|
||||
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>
|
||||
<div className="space-x-2">
|
||||
{categories.map((category) => (
|
||||
<Link href={"/apps/categories/" + category} key={category}>
|
||||
<a>
|
||||
<Badge variant="success">{category}</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<h4 className="mt-8 font-medium text-gray-900 ">{t("pricing")}</h4>
|
||||
<small>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
<h4 className="mt-8 mb-2 font-medium text-gray-900 ">{t("learn_more")}</h4>
|
||||
<ul className="prose -ml-1 -mr-1 text-xs leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={docs}>
|
||||
<BookOpenIcon className="mr-1 -mt-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={website}>
|
||||
<ExternalLinkIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<MailIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={tos}>
|
||||
<DocumentTextIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<ShieldCheckIcon className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-6" />
|
||||
<small className="leading-1 block text-gray-500">
|
||||
Every app published on the Cal.com App Store is open source and thoroughly tested via peer
|
||||
reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are
|
||||
published by Cal.com. If you encounter inappropriate content or behaviour please report it.
|
||||
</small>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<FlagIcon className="inline h-3 w-3" /> Report App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App(props: {
|
||||
name: string;
|
||||
type: AppType["type"];
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
author: string;
|
||||
pro?: boolean;
|
||||
price?: number;
|
||||
commission?: number;
|
||||
feeType?: AppType["feeType"];
|
||||
docs?: string;
|
||||
website?: string;
|
||||
email: string; // required
|
||||
tos?: string;
|
||||
privacy?: string;
|
||||
licenseRequired: AppType["licenseRequired"];
|
||||
isProOnly: AppType["isProOnly"];
|
||||
}) {
|
||||
return (
|
||||
<Shell large isPublic>
|
||||
{props.licenseRequired ? (
|
||||
<LicenseRequired>
|
||||
<Component {...props} />
|
||||
</LicenseRequired>
|
||||
) : (
|
||||
<Component {...props} />
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const tabs = [
|
||||
export default function BookingsShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} />
|
||||
<NavTabs tabs={tabs} />
|
||||
<main>{children}</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import Head from "next/head";
|
||||
|
||||
import { useBrandColors } from "@calcom/embed-core/embed-iframe";
|
||||
|
||||
@@ -238,54 +238,32 @@ const BrandColor = ({
|
||||
? darkVal
|
||||
: "#" + darkVal
|
||||
: fallBackHex(darkVal, true);
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-highlight-color",
|
||||
embedBrandingColors.highlightColor || "#10B981" // green--500
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-lightest-color",
|
||||
embedBrandingColors.lightestColor || "#E1E1E1" // gray--200
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-lighter-color",
|
||||
embedBrandingColors.lighterColor || "#ACACAC" // gray--400
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-light-color",
|
||||
embedBrandingColors.lightColor || "#888888" // gray--500
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-median-color",
|
||||
embedBrandingColors.medianColor || "#494949" // gray--600
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-dark-color",
|
||||
embedBrandingColors.darkColor || "#313131" // gray--800
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--booking-darker-color",
|
||||
embedBrandingColors.darkerColor || "#292929" // gray--900
|
||||
);
|
||||
document.documentElement.style.setProperty("--brand-color", lightVal);
|
||||
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(lightVal, true));
|
||||
document.documentElement.style.setProperty("--brand-color-dark-mode", darkVal);
|
||||
document.documentElement.style.setProperty(
|
||||
"--brand-text-color-dark-mode",
|
||||
getContrastingTextColor(darkVal, true)
|
||||
);
|
||||
}, [
|
||||
embedBrandingColors.highlightColor,
|
||||
embedBrandingColors.lightestColor,
|
||||
embedBrandingColors.lighterColor,
|
||||
embedBrandingColors.lightColor,
|
||||
embedBrandingColors.medianColor,
|
||||
embedBrandingColors.darkColor,
|
||||
embedBrandingColors.darkerColor,
|
||||
lightVal,
|
||||
darkVal,
|
||||
]);
|
||||
return null;
|
||||
return (
|
||||
<Head>
|
||||
<style>
|
||||
{`body {
|
||||
/* green--500*/
|
||||
--booking-highlight-color: ${embedBrandingColors.highlightColor || "#10B981"};
|
||||
/* gray--200 */
|
||||
--booking-lightest-color: ${embedBrandingColors.lightestColor || "#E1E1E1"};
|
||||
/* gray--400 */
|
||||
--booking-lighter-color: ${embedBrandingColors.lighterColor || "#ACACAC"};
|
||||
/* gray--500 */
|
||||
--booking-light-color: ${embedBrandingColors.lightColor || "#888888"};
|
||||
/* gray--600 */
|
||||
--booking-median-color: ${embedBrandingColors.medianColor || "#494949"};
|
||||
/* gray--800 */
|
||||
--booking-dark-color: ${embedBrandingColors.darkColor || "#313131"};
|
||||
/* gray--900 */
|
||||
--booking-darker-color: ${embedBrandingColors.darkerColor || "#292929"};
|
||||
--brand-color: ${lightVal};
|
||||
--brand-text-color: ${getContrastingTextColor(lightVal, true)};
|
||||
--brand-color-dark-mode: ${darkVal};
|
||||
--brand-text-color-dark-mode: ${getContrastingTextColor(darkVal, true)};
|
||||
`}
|
||||
</style>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandColor;
|
||||
|
||||
@@ -61,15 +61,17 @@ const DestinationCalendarSelector = ({
|
||||
query.data.connectedCalendars.map((selectedCalendar) => ({
|
||||
key: selectedCalendar.credentialId,
|
||||
label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`,
|
||||
options: (selectedCalendar.calendars ?? []).map((cal) => ({
|
||||
label: cal.name || "",
|
||||
value: `${cal.integration}:${cal.externalId}`,
|
||||
})),
|
||||
options: (selectedCalendar.calendars ?? [])
|
||||
.filter((cal) => cal.readOnly === false)
|
||||
.map((cal) => ({
|
||||
label: cal.name || "",
|
||||
value: `${cal.integration}:${cal.externalId}`,
|
||||
})),
|
||||
})) ?? [];
|
||||
return (
|
||||
<div className="relative" title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||
<Select
|
||||
name={"primarySelectedCalendar"}
|
||||
name="primarySelectedCalendar"
|
||||
placeholder={!hidePlaceholder ? `${t("select_destination_calendar")}:` : undefined}
|
||||
options={options}
|
||||
styles={{
|
||||
@@ -89,6 +91,10 @@ const DestinationCalendarSelector = ({
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "2px",
|
||||
"@media only screen and (min-width: 640px)": {
|
||||
...(defaultStyles["@media only screen and (min-width: 640px)"] as object),
|
||||
maxWidth: "320px",
|
||||
},
|
||||
};
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ArrowLeftIcon, ChevronRightIcon, CodeIcon, EyeIcon, SunIcon } from "@he
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { useRouter } from "next/router";
|
||||
import { forwardRef, MutableRefObject, useRef, useState } from "react";
|
||||
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
||||
import { components, ControlProps } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -467,12 +467,14 @@ const tabs = [
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
||||
<div>
|
||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
||||
</div>
|
||||
<TextArea
|
||||
data-testid="embed-code"
|
||||
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
|
||||
name="embed-code"
|
||||
className="h-[36rem]"
|
||||
className="h-[calc(100%-50px)]"
|
||||
readOnly
|
||||
value={
|
||||
`<!-- Cal ${embedType} embed code begins -->\n` +
|
||||
@@ -486,7 +488,8 @@ ${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."
|
||||
@@ -519,7 +522,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
||||
data-testid="embed-react"
|
||||
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
|
||||
name="embed-react"
|
||||
className="h-[36rem]"
|
||||
className="h-[calc(100%-50px)]"
|
||||
readOnly
|
||||
value={`/* First make sure that you have installed the package */
|
||||
|
||||
@@ -529,7 +532,8 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
||||
/* If you are using npm */
|
||||
// npm install @calcom/embed-react
|
||||
${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, previewState })}
|
||||
`}></TextArea>
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}),
|
||||
@@ -628,7 +632,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const embedCodeRef = 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([
|
||||
@@ -793,11 +804,11 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<ArrowLeftIcon className="mr-4 w-4"></ArrowLeftIcon>
|
||||
<ArrowLeftIcon className="mr-4 w-4" />
|
||||
</button>
|
||||
{embed.title}
|
||||
</h3>
|
||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")}></hr>
|
||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")} />
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
@@ -909,7 +920,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></Switch>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -930,7 +942,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
});
|
||||
}}
|
||||
defaultValue={FloatingPopupPositionOptions[0]}
|
||||
options={FloatingPopupPositionOptions}></Select>
|
||||
options={FloatingPopupPositionOptions}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -951,7 +964,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></ColorPicker>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -973,13 +987,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></ColorPicker>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<hr className="mt-4"></hr>
|
||||
<hr className="mt-4" />
|
||||
<div className="mt-4 font-medium">
|
||||
<Collapsible
|
||||
open={isBookingCustomizationOpen}
|
||||
@@ -1013,7 +1028,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}></Select>
|
||||
options={ThemeOptions}
|
||||
/>
|
||||
</Label>
|
||||
{[
|
||||
{ name: "brandColor", title: "Brand Color" },
|
||||
@@ -1056,7 +1072,8 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
embedType={embedType}
|
||||
calLink={calLink}
|
||||
previewState={previewState}
|
||||
ref={embedCodeRef}></tab.Component>
|
||||
ref={refOfEmbedCodesRefs.current[tab.name]}
|
||||
/>
|
||||
) : (
|
||||
<tab.Component
|
||||
embedType={embedType}
|
||||
@@ -1066,17 +1083,18 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={router.query.tabName == "embed-preview" ? "block" : "hidden"}></div>
|
||||
<div className={router.query.tabName == "embed-preview" ? "block" : "hidden"} />
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
{tab.type === "code" ? (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
if (!embedCodeRef.current) {
|
||||
const currentTabCodeEl = refOfEmbedCodesRefs.current[tab.name].current;
|
||||
if (!currentTabCodeEl) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(embedCodeRef.current.value);
|
||||
navigator.clipboard.writeText(currentTabCodeEl.value);
|
||||
showToast(t("code_copied"), "success");
|
||||
}}>
|
||||
{t("copy_code")}
|
||||
@@ -1149,10 +1167,9 @@ export const EmbedButton = ({
|
||||
className={className}
|
||||
{...props}
|
||||
data-test-eventtype-id={eventTypeId}
|
||||
data-testid={"event-type-embed"}
|
||||
data-testid="event-type-embed"
|
||||
onClick={() => openEmbedModal()}>
|
||||
<CodeIcon
|
||||
className={classNames("h-4 w-4 ltr:mr-2 rtl:ml-2", dark ? "" : "text-neutral-500")}></CodeIcon>
|
||||
<CodeIcon className={classNames("h-4 w-4 ltr:mr-2 rtl:ml-2", dark ? "" : "text-neutral-500")} />
|
||||
{t("Embed")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,13 @@ 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,
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
* We intend to not cache i18n query
|
||||
**/
|
||||
context: { skipBatch: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function ImageUploader({
|
||||
</div>
|
||||
)}
|
||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||
<label className="mt-8 rounded-sm border border-gray-300 bg-white px-3 py-1 text-xs font-medium leading-4 text-gray-700 hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1 dark:border-gray-800 dark:bg-transparent dark:text-white dark:hover:bg-gray-900">
|
||||
<label className="mt-8 rounded-sm border border-gray-300 bg-white px-3 py-1 text-xs font-medium leading-4 text-gray-700 hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1 dark:border-gray-800 dark:bg-transparent dark:text-white dark:hover:bg-gray-900">
|
||||
<input
|
||||
onInput={onInputFile}
|
||||
type="file"
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { SwitchVerticalIcon } from "@heroicons/react/outline";
|
||||
import { SearchIcon } from "@heroicons/react/solid";
|
||||
import {
|
||||
KBarProvider,
|
||||
KBarPortal,
|
||||
KBarPositioner,
|
||||
KBarAnimator,
|
||||
KBarSearch,
|
||||
KBarResults,
|
||||
useMatches,
|
||||
useKBar,
|
||||
} from "kbar";
|
||||
import { useRouter } from "next/router";
|
||||
import { Command, CornerDownLeft } from "react-feather";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { isMac } from "@calcom/lib/isMac";
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
|
||||
type shortcutArrayType = {
|
||||
shortcuts?: string[];
|
||||
};
|
||||
|
||||
export const KBarRoot = ({ children }: { children: React.ReactNode }) => {
|
||||
const router = useRouter();
|
||||
|
||||
// grab link to events
|
||||
// quick nested actions would be extremely useful
|
||||
const actions = [
|
||||
// {
|
||||
// id: "toggle-idle",
|
||||
// name: "Test Function",
|
||||
// section: "Status",
|
||||
// shortcut: ["t", "f"],
|
||||
// keywords: "set yourself away bookings",
|
||||
// perform: () => alert("Hello World"),
|
||||
// },
|
||||
|
||||
{
|
||||
id: "workflows",
|
||||
name: "Workflows",
|
||||
section: "Workflows",
|
||||
shortcut: ["w", "f"],
|
||||
keywords: "workflows automation",
|
||||
perform: () => router.push("/workflows"),
|
||||
},
|
||||
{
|
||||
id: "event-types",
|
||||
name: "Event Types",
|
||||
section: "Event Types",
|
||||
shortcut: ["e", "t"],
|
||||
keywords: "event types",
|
||||
perform: () => router.push("/event-types"),
|
||||
},
|
||||
{
|
||||
id: "app-store",
|
||||
name: "App Store",
|
||||
section: "Apps",
|
||||
shortcut: ["a", "s"],
|
||||
keywords: "app store",
|
||||
perform: () => router.push("/apps"),
|
||||
},
|
||||
{
|
||||
id: "upcoming-bookings",
|
||||
name: "Upcoming Bookings",
|
||||
section: "Bookings",
|
||||
shortcut: ["u", "b"],
|
||||
keywords: "upcoming bookings",
|
||||
perform: () => router.push("/bookings/upcoming"),
|
||||
},
|
||||
{
|
||||
id: "recurring-bookings",
|
||||
name: "Recurring Bookings",
|
||||
section: "Bookings",
|
||||
shortcut: ["r", "b"],
|
||||
keywords: "recurring bookings",
|
||||
perform: () => router.push("/bookings/recurring"),
|
||||
},
|
||||
{
|
||||
id: "past-bookings",
|
||||
name: "Past Bookings",
|
||||
section: "Bookings",
|
||||
shortcut: ["p", "b"],
|
||||
keywords: "past bookings",
|
||||
perform: () => router.push("/bookings/past"),
|
||||
},
|
||||
{
|
||||
id: "cancelled-bookings",
|
||||
name: "Cancelled Bookings",
|
||||
section: "Bookings",
|
||||
shortcut: ["c", "b"],
|
||||
keywords: "cancelled bookings",
|
||||
perform: () => router.push("/bookings/cancelled"),
|
||||
},
|
||||
{
|
||||
id: "schedule",
|
||||
name: "Schedule",
|
||||
section: "Availability",
|
||||
shortcut: ["s", "a"],
|
||||
keywords: "schedule availability",
|
||||
perform: () => router.push("/availability"),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
name: "Profile",
|
||||
section: "Profile Settings",
|
||||
shortcut: ["p", "s"],
|
||||
keywords: "setting profile",
|
||||
perform: () => router.push("/settings/profile"),
|
||||
},
|
||||
{
|
||||
id: "avatar",
|
||||
name: "Change Avatar",
|
||||
section: "Profile Settings",
|
||||
shortcut: ["c", "a"],
|
||||
keywords: "remove change modify avatar",
|
||||
perform: () => router.push("/settings/profile"),
|
||||
},
|
||||
{
|
||||
id: "timezone",
|
||||
name: "Change Timezone",
|
||||
section: "Profile Settings",
|
||||
shortcut: ["c", "t"],
|
||||
keywords: "change modify timezone",
|
||||
perform: () => router.push("/settings/profile"),
|
||||
},
|
||||
{
|
||||
id: "brand-color",
|
||||
name: "Change Brand Color",
|
||||
section: "Profile Settings",
|
||||
shortcut: ["b", "c"],
|
||||
keywords: "change modify brand color",
|
||||
perform: () => router.push("/settings/profile"),
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
name: "Teams",
|
||||
shortcut: ["t", "s"],
|
||||
keywords: "add manage modify team",
|
||||
perform: () => router.push("/settings/teams"),
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
name: "Change Password",
|
||||
section: "Security Settings",
|
||||
shortcut: ["c", "p"],
|
||||
keywords: "change modify password",
|
||||
perform: () => router.push("/settings/security"),
|
||||
},
|
||||
{
|
||||
id: "two-factor",
|
||||
name: "Two Factor Authentication",
|
||||
section: "Security Settings",
|
||||
shortcut: ["t", "f", "a"],
|
||||
keywords: "two factor authentication",
|
||||
perform: () => router.push("/settings/security"),
|
||||
},
|
||||
{
|
||||
id: "impersonation",
|
||||
name: "User Impersonation",
|
||||
section: "Security Settings",
|
||||
shortcut: ["u", "i"],
|
||||
keywords: "user impersonation",
|
||||
perform: () => router.push("/settings/security"),
|
||||
},
|
||||
{
|
||||
id: "webhooks",
|
||||
name: "Webhook",
|
||||
section: "Developer Settings",
|
||||
shortcut: ["w", "h"],
|
||||
keywords: "webhook automation",
|
||||
perform: () => router.push("/settings/developer"),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
name: "API Keys",
|
||||
section: "Developer Settings",
|
||||
shortcut: ["a", "p", "i"],
|
||||
keywords: "api keys",
|
||||
perform: () => router.push("/settings/developer"),
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
name: "View and Manage Billing",
|
||||
section: "Billing",
|
||||
shortcut: ["m", "b"],
|
||||
keywords: "billing view manage",
|
||||
perform: () => router.push("/settings/billing"),
|
||||
},
|
||||
];
|
||||
|
||||
return <KBarProvider actions={actions}>{children}</KBarProvider>;
|
||||
};
|
||||
|
||||
export const KBarContent = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<KBarPortal>
|
||||
<KBarPositioner>
|
||||
<KBarAnimator className="z-10 w-full max-w-screen-sm overflow-hidden rounded-sm bg-white shadow-lg">
|
||||
<div className="flex items-center justify-center border-b">
|
||||
<SearchIcon className="mx-3 h-4 w-4 text-gray-500" />
|
||||
<KBarSearch className="w-full rounded-sm py-2.5 focus-visible:outline-none" />
|
||||
</div>
|
||||
<RenderResults />
|
||||
<div className="hidden items-center space-x-1 border-t px-2 py-1.5 text-xs text-gray-500 sm:flex">
|
||||
<SwitchVerticalIcon className="h-4 w-4" /> <span className="pr-2">{t("navigate")}</span>
|
||||
<CornerDownLeft className="h-4 w-4" />
|
||||
<span className="pr-2">{t("open")}</span>
|
||||
{isMac ? <Command className="h-3 w-3" /> : "CTRL"}
|
||||
<span className="pr-1">+ K </span>
|
||||
<span className="pr-2">{t("close")}</span>
|
||||
</div>
|
||||
</KBarAnimator>
|
||||
<div className="z-1 fixed inset-0 bg-gray-600 bg-opacity-75" />
|
||||
</KBarPositioner>
|
||||
</KBarPortal>
|
||||
);
|
||||
};
|
||||
|
||||
export const KBarTrigger = () => {
|
||||
const { query } = useKBar();
|
||||
return (
|
||||
<>
|
||||
<Tooltip side="right" content={isMac ? "⌘ + K" : "CTRL + K"}>
|
||||
<button
|
||||
color="minimal"
|
||||
onClick={query.toggle}
|
||||
className="group flex text-sm font-medium text-neutral-500 hover:text-neutral-900">
|
||||
<SearchIcon className="h-5 w-5 flex-shrink-0 text-neutral-400 group-hover:text-neutral-500" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayShortcuts = (item: shortcutArrayType) => {
|
||||
const shortcuts = item.shortcuts;
|
||||
|
||||
return (
|
||||
<span className="space-x-1">
|
||||
{shortcuts?.map((shortcut) => {
|
||||
return (
|
||||
<kbd key={shortcut} className="rounded-sm border bg-white px-2 py-1 text-black hover:bg-gray-100">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
function RenderResults() {
|
||||
const { results } = useMatches();
|
||||
console.log(results);
|
||||
|
||||
return (
|
||||
<KBarResults
|
||||
items={results}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<div className="bg-white p-4 text-xs uppercase text-gray-500">{item}</div>
|
||||
) : (
|
||||
<div
|
||||
// For seeing keyboard up & down navigation in action, we need visual feedback based on "active" prop
|
||||
style={{
|
||||
background: active ? "rgb(245,245,245)" : "#fff",
|
||||
borderLeft: active ? "2px solid black" : "2px solid transparent",
|
||||
}}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-sm hover:cursor-pointer">
|
||||
<span>{item.name}</span>
|
||||
<DisplayShortcuts shortcuts={item.shortcut} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
import { LOGO_ICON, LOGO } from "@calcom/lib/constants";
|
||||
|
||||
export default function Logo({ small, icon }: { small?: boolean; icon?: boolean }) {
|
||||
return (
|
||||
<h1 className="inline">
|
||||
<strong>
|
||||
{icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mx-auto w-9" alt="Cal" title="Cal" src="/cal-com-icon-white.svg" />
|
||||
<img className="mx-auto w-9" alt="Cal" title="Cal" src={LOGO_ICON} />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={small ? "h-4 w-auto" : "h-5 w-auto"}
|
||||
alt="Cal"
|
||||
title="Cal"
|
||||
src="/calendso-logo-white-word.svg"
|
||||
/>
|
||||
<img className={small ? "h-4 w-auto" : "h-5 w-auto"} alt="Cal" title="Cal" src={LOGO} />
|
||||
)}
|
||||
</strong>
|
||||
</h1>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface NavTabProps {
|
||||
tabName?: string;
|
||||
icon?: SVGComponent;
|
||||
adminRequired?: boolean;
|
||||
className?: string;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
}
|
||||
@@ -58,7 +59,7 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
: noop;
|
||||
|
||||
const Component = tab.adminRequired ? AdminRequired : Fragment;
|
||||
|
||||
const className = tab.className || "";
|
||||
return (
|
||||
<Component key={tab.name}>
|
||||
<Link key={tab.name} href={href} {...linkProps}>
|
||||
@@ -68,7 +69,8 @@ const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||
"group inline-flex items-center border-b-2 py-4 px-1 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// This component is abstracted from /event-types/[type] for common usecase.
|
||||
import { PencilIcon } from "@heroicons/react/solid";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function PencilEdit({
|
||||
value,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onChange = () => {},
|
||||
placeholder = "",
|
||||
readOnly = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [editIcon, setEditIcon] = useState(true);
|
||||
const onDivClick = !readOnly
|
||||
? () => {
|
||||
return setEditIcon(false);
|
||||
}
|
||||
: // eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
() => {};
|
||||
return (
|
||||
<div className="group relative min-h-[28px] cursor-pointer" onClick={onDivClick}>
|
||||
{editIcon ? (
|
||||
<>
|
||||
<h1
|
||||
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
|
||||
className="inline-block pl-0 text-gray-900 focus:text-black group-hover:text-gray-500">
|
||||
{value}
|
||||
</h1>
|
||||
{!readOnly ? (
|
||||
<PencilIcon className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ marginBottom: -11 }}>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
style={{ top: -6, fontSize: 22 }}
|
||||
required
|
||||
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
onBlur={(e) => {
|
||||
setEditIcon(true);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+155
-94
@@ -1,4 +1,5 @@
|
||||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import { CollectionIcon } from "@heroicons/react/solid";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
MoonIcon,
|
||||
ViewGridIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
LightningBoltIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { SessionContextValue, signOut, useSession } from "next-auth/react";
|
||||
@@ -20,6 +22,7 @@ import React, { Fragment, ReactNode, useEffect, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { WEBAPP_URL, JOIN_SLACK, ROADMAP } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, {
|
||||
@@ -34,15 +37,15 @@ 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 useTheme from "@lib/hooks/useTheme";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import { KBarRoot, KBarContent, KBarTrigger } from "@components/Kbar";
|
||||
import Loader from "@components/Loader";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import Badge from "@components/ui/Badge";
|
||||
import ImpersonatingBanner from "@components/ui/ImpersonatingBanner";
|
||||
|
||||
import pkg from "../package.json";
|
||||
@@ -122,6 +125,12 @@ const Layout = ({
|
||||
}: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => {
|
||||
const isEmbed = useIsEmbed();
|
||||
const router = useRouter();
|
||||
const { data: routingForms } = trpc.useQuery([
|
||||
"viewer.appById",
|
||||
{
|
||||
appId: "routing_forms",
|
||||
},
|
||||
]);
|
||||
|
||||
const { t } = useLocale();
|
||||
const navigation = [
|
||||
@@ -143,11 +152,26 @@ const Layout = ({
|
||||
icon: ClockIcon,
|
||||
current: router.asPath.startsWith("/availability"),
|
||||
},
|
||||
routingForms
|
||||
? {
|
||||
name: "Routing Forms",
|
||||
href: "/apps/routing_forms/forms",
|
||||
icon: CollectionIcon,
|
||||
current: router.asPath.startsWith("/apps/routing_forms/"),
|
||||
}
|
||||
: null,
|
||||
{
|
||||
name: t("workflows"),
|
||||
href: "/workflows",
|
||||
icon: LightningBoltIcon,
|
||||
current: router.asPath.startsWith("/workflows"),
|
||||
pro: true,
|
||||
},
|
||||
{
|
||||
name: t("apps"),
|
||||
href: "/apps",
|
||||
icon: ViewGridIcon,
|
||||
current: router.asPath.startsWith("/apps"),
|
||||
current: router.asPath.startsWith("/apps") && !router.asPath.startsWith("/apps/routing_forms/"),
|
||||
child: [
|
||||
{
|
||||
name: t("app_store"),
|
||||
@@ -192,11 +216,16 @@ const Layout = ({
|
||||
<div className="flex w-14 flex-col lg:w-56">
|
||||
<div className="flex h-0 flex-1 flex-col border-r border-gray-200 bg-white">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pt-3 pb-4 lg:pt-5">
|
||||
<Link href="/event-types">
|
||||
<a className="px-4 md:hidden lg:inline">
|
||||
<Logo small />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="items-center justify-between md:hidden lg:flex">
|
||||
<Link href="/event-types">
|
||||
<a className="px-4">
|
||||
<Logo small />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="px-4">
|
||||
<KBarTrigger />
|
||||
</div>
|
||||
</div>
|
||||
{/* logo icon for tablet */}
|
||||
<Link href="/event-types">
|
||||
<a className="text-center md:inline lg:hidden">
|
||||
@@ -204,48 +233,58 @@ const Layout = ({
|
||||
</a>
|
||||
</Link>
|
||||
<nav className="mt-2 flex-1 space-y-1 bg-white px-2 lg:mt-5">
|
||||
{navigation.map((item) => (
|
||||
<Fragment key={item.name}>
|
||||
<Link href={item.href}>
|
||||
<a
|
||||
aria-label={item.name}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "bg-neutral-100 text-neutral-900"
|
||||
: "text-neutral-500 hover:bg-gray-50 hover:text-neutral-900",
|
||||
"group flex items-center rounded-sm px-2 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<item.icon
|
||||
{navigation.map((item) =>
|
||||
!item ? null : (
|
||||
<Fragment key={item.name}>
|
||||
<Link href={item.href}>
|
||||
<a
|
||||
aria-label={item.name}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "text-neutral-500"
|
||||
: "text-neutral-400 group-hover:text-neutral-500",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
? "bg-neutral-100 text-neutral-900"
|
||||
: "text-neutral-500 hover:bg-gray-50 hover:text-neutral-900",
|
||||
"group flex items-center rounded-sm px-2 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current
|
||||
? "text-neutral-500"
|
||||
: "text-neutral-400 group-hover:text-neutral-500",
|
||||
"h-5 w-5 flex-shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
{item.pro && (
|
||||
<span className="ml-1">
|
||||
{plan === "FREE" && <Badge variant="default">PRO</Badge>}
|
||||
</span>
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
{item.child &&
|
||||
router.asPath.startsWith(item.href) &&
|
||||
item.child.map((item) => {
|
||||
return (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
item.current
|
||||
? "text-neutral-900"
|
||||
: "text-neutral-500 hover:text-neutral-900",
|
||||
"group hidden items-center rounded-sm px-2 py-2 pl-10 text-sm font-medium lg:flex"
|
||||
)}>
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</a>
|
||||
</Link>
|
||||
{item.child &&
|
||||
router.asPath.startsWith(item.href) &&
|
||||
item.child.map((item) => {
|
||||
return (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
item.current
|
||||
? "text-neutral-900"
|
||||
: "text-neutral-500 hover:text-neutral-900",
|
||||
"group hidden items-center rounded-sm px-2 py-2 pl-10 text-sm font-medium lg:flex"
|
||||
)}>
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
)}
|
||||
<span className="group flex items-center rounded-sm px-2 py-2 text-sm font-medium text-neutral-500 hover:bg-gray-50 hover:text-neutral-900 lg:hidden">
|
||||
<KBarTrigger />
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
<TrialBanner />
|
||||
@@ -272,6 +311,7 @@ const Layout = ({
|
||||
)}
|
||||
|
||||
<div className="flex w-0 flex-1 flex-col overflow-hidden">
|
||||
<ImpersonatingBanner />
|
||||
<main
|
||||
className={classNames(
|
||||
"relative z-0 flex-1 overflow-y-auto focus:outline-none",
|
||||
@@ -288,9 +328,12 @@ const Layout = ({
|
||||
<Logo />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 self-center">
|
||||
<div className="flex items-center gap-2 self-center">
|
||||
<span className="group flex items-center rounded-full p-2.5 text-sm font-medium text-neutral-500 hover:bg-gray-50 hover:text-neutral-900 lg:hidden">
|
||||
<KBarTrigger />
|
||||
</span>
|
||||
<button className="rounded-full bg-white p-2 text-gray-400 hover:bg-gray-50 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
|
||||
<span className="sr-only">{t("view_notifications")}</span>
|
||||
<span className="sr-only">{t("settings")}</span>
|
||||
<Link href="/settings/profile">
|
||||
<a>
|
||||
<CogIcon className="h-6 w-6" aria-hidden="true" />
|
||||
@@ -307,7 +350,6 @@ const Layout = ({
|
||||
props.flexChildrenContainer && "flex flex-1 flex-col",
|
||||
!props.large && "py-8"
|
||||
)}>
|
||||
<ImpersonatingBanner />
|
||||
{!!props.backPath && (
|
||||
<div className="mx-3 mb-8 sm:mx-8">
|
||||
<Button
|
||||
@@ -322,23 +364,21 @@ const Layout = ({
|
||||
<div
|
||||
className={classNames(
|
||||
props.large && "bg-gray-100 py-8 lg:mb-8 lg:pt-16 lg:pb-7",
|
||||
"block min-h-[80px] justify-between px-4 sm:flex sm:px-6 md:px-8"
|
||||
"block justify-between px-4 sm:flex sm:px-6 md:px-8"
|
||||
)}>
|
||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="mb-8 w-full">
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<div className="mb-1 h-6 w-24 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className="mb-1 h-6 w-32 animate-pulse rounded-md bg-gray-200"></div>
|
||||
<div className="mb-1 h-6 w-24 animate-pulse rounded-md bg-gray-200" />
|
||||
<div className="mb-1 h-6 w-32 animate-pulse rounded-md bg-gray-200" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
|
||||
{props.heading}
|
||||
</h1>
|
||||
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">
|
||||
{props.subtitle}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -358,8 +398,11 @@ const Layout = ({
|
||||
style={isEmbed ? { display: "none" } : {}}
|
||||
className="bottom-nav fixed bottom-0 z-30 flex w-full bg-white shadow md:hidden">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
{navigation.flatMap((item, itemIdx) =>
|
||||
item.href === "/settings/profile" ? (
|
||||
{navigation.flatMap((item, itemIdx) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return item.href === "/settings/profile" ? (
|
||||
[]
|
||||
) : (
|
||||
<Link key={item.name} href={item.href}>
|
||||
@@ -378,11 +421,11 @@ const Layout = ({
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="block truncate">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
{/* add padding to content for mobile navigation*/}
|
||||
@@ -417,7 +460,6 @@ type LayoutProps = {
|
||||
export default function Shell(props: LayoutProps) {
|
||||
const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic);
|
||||
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
|
||||
const { isReady, Theme } = useTheme("light");
|
||||
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
@@ -425,14 +467,11 @@ export default function Shell(props: LayoutProps) {
|
||||
const i18n = useViewerI18n();
|
||||
const { status } = useSession();
|
||||
|
||||
const isLoading =
|
||||
i18n.status === "loading" ||
|
||||
query.status === "loading" ||
|
||||
isRedirectingToOnboarding ||
|
||||
loading ||
|
||||
!isReady;
|
||||
const isLoading = isRedirectingToOnboarding || loading;
|
||||
|
||||
if (isLoading) {
|
||||
// Don't show any content till translations are loaded.
|
||||
// As they are cached infintely, this status would be loading just once for the app's lifetime until refresh
|
||||
if (i18n.status === "loading") {
|
||||
return (
|
||||
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
|
||||
<Loader />
|
||||
@@ -443,11 +482,11 @@ export default function Shell(props: LayoutProps) {
|
||||
if (!session && !props.isPublic) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
<KBarRoot>
|
||||
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
|
||||
<MemoizedLayout plan={user?.plan} status={status} {...props} isLoading={isLoading} />
|
||||
</>
|
||||
<KBarContent />
|
||||
</KBarRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -455,6 +494,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
const { t } = useLocale();
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
const mutation = trpc.useMutation("viewer.away", {
|
||||
onSettled() {
|
||||
utils.invalidateQueries("viewer.me");
|
||||
@@ -462,10 +502,23 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
});
|
||||
const utils = trpc.useContext();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const onHelpItemSelect = () => {
|
||||
setHelpOpen(false);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
// Prevent rendering dropdown if user isn't available.
|
||||
// We don't want to show nameless user.
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Dropdown onOpenChange={() => setHelpOpen(false)}>
|
||||
<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(
|
||||
@@ -476,25 +529,29 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={WEBAPP_URL + "/" + user?.username + "/avatar.png"}
|
||||
alt={user?.username || "Nameless User"}
|
||||
src={WEBAPP_URL + "/" + user.username + "/avatar.png"}
|
||||
alt={user.username || "Nameless User"}
|
||||
/>
|
||||
}
|
||||
{!user?.away && (
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-green-500"></div>
|
||||
{!user.away && (
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-green-500" />
|
||||
)}
|
||||
{user?.away && (
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-yellow-500"></div>
|
||||
{user.away && (
|
||||
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-yellow-500" />
|
||||
)}
|
||||
</span>
|
||||
{!small && (
|
||||
<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
|
||||
@@ -505,32 +562,32 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portalled={true}>
|
||||
<DropdownMenuContent portalled={true} onInteractOutside={() => setMenuOpen(false)}>
|
||||
{helpOpen ? (
|
||||
<HelpMenuItem closeHelp={() => setHelpOpen(false)} />
|
||||
<HelpMenuItem onHelpItemSelect={() => onHelpItemSelect()} />
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
onClick={() => {
|
||||
mutation.mutate({ away: !user?.away });
|
||||
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
|
||||
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")}
|
||||
{user.away ? t("set_as_free") : t("set_as_away")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{user?.username && (
|
||||
{user.username && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
target="_blank"
|
||||
@@ -545,7 +602,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
href="https://cal.com/slack"
|
||||
href={JOIN_SLACK}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900">
|
||||
@@ -559,16 +616,20 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
<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>
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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>
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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>
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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>
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{t("join_our_slack")}
|
||||
@@ -578,7 +639,7 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://cal.com/roadmap"
|
||||
href={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>
|
||||
|
||||
@@ -9,7 +9,7 @@ export type SwatchProps = {
|
||||
const Swatch = (props: SwatchProps) => {
|
||||
const { size, backgroundColor, onClick } = props;
|
||||
return (
|
||||
<div className="border-2 border-gray-200 p-1 shadow-sm">
|
||||
<div className="border-2 border-gray-200 p-1">
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{ backgroundColor }}
|
||||
@@ -18,7 +18,8 @@ const Swatch = (props: SwatchProps) => {
|
||||
size === "sm" && "h-6 w-6 rounded-sm",
|
||||
size === "base" && "h-16 w-16 rounded-sm",
|
||||
size === "lg" && "h-24 w-24 rounded-sm"
|
||||
)}></div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function AllApps({ apps }: { apps: App[] }) {
|
||||
logo={app.logo}
|
||||
rating={app.rating}
|
||||
reviews={app.reviews}
|
||||
isProOnly={app.isProOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,10 @@ import Link from "next/link";
|
||||
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
interface AppCardProps {
|
||||
logo: string;
|
||||
name: string;
|
||||
@@ -10,9 +14,11 @@ interface AppCardProps {
|
||||
description: string;
|
||||
rating: number;
|
||||
reviews?: number;
|
||||
isProOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function AppCard(props: AppCardProps) {
|
||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
||||
return (
|
||||
<Link href={"/apps/" + props.slug}>
|
||||
<a
|
||||
@@ -32,7 +38,14 @@ export default function AppCard(props: AppCardProps) {
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<h3 className="font-medium">{props.name}</h3>
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-medium">{props.name}</h3>
|
||||
{props.isProOnly && user?.plan === "FREE" ? (
|
||||
<Badge className="ml-2" variant="default">
|
||||
PRO
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{/* TODO: add reviews <div className="flex text-sm text-gray-800">
|
||||
<span>{props.rating} stars</span> <StarIcon className="ml-1 mt-0.5 h-4 w-4 text-yellow-600" />
|
||||
<span className="pl-1 text-gray-500">{props.reviews} reviews</span>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ShellSubHeading } from "@components/Shell";
|
||||
function SkeletonLoader({ className }: { className?: string }) {
|
||||
return (
|
||||
<>
|
||||
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100"></div>} {...{ className }} />
|
||||
<ShellSubHeading title={<div className="h-6 w-32 rounded-sm bg-gray-100" />} {...{ 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 />
|
||||
@@ -24,16 +24,16 @@ function SkeletonItem() {
|
||||
<li className="group flex w-full items-center justify-between p-3">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-start space-x-2">
|
||||
<SkeletonText width="10" height="10"></SkeletonText>
|
||||
<SkeletonText width="10" height="10" />
|
||||
<div className="space-y-2">
|
||||
<SkeletonText height="4" width="32"></SkeletonText>
|
||||
<SkeletonText height="4" width="16"></SkeletonText>
|
||||
<SkeletonText height="4" width="32" />
|
||||
<SkeletonText height="4" width="16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="32" height="11"></SkeletonText>
|
||||
<SkeletonText width="32" height="11" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -30,6 +30,7 @@ const TrendingAppsSlider = <T extends App>({ items }: { items: T[] }) => {
|
||||
logo={app.logo}
|
||||
rating={app.rating}
|
||||
reviews={app.reviews}
|
||||
isProOnly={app.isProOnly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
@@ -34,7 +34,7 @@ export default function SAMLLogin(props: Props) {
|
||||
<div className="mt-5">
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid={"saml"}
|
||||
data-testid="saml"
|
||||
className="flex w-full justify-center"
|
||||
onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -65,7 +65,7 @@ export function NewScheduleButton({ name = "new-schedule" }: { name?: string })
|
||||
type="text"
|
||||
id="name"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
placeholder={t("default_schedule_name")}
|
||||
{...register("name")}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { DuplicateIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import dayjs, { Dayjs, ConfigType } from "dayjs";
|
||||
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 } from "react-select";
|
||||
|
||||
import dayjs, { Dayjs, ConfigType } from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/Dropdown";
|
||||
@@ -19,9 +17,6 @@ import { TimeRange } from "@lib/types/schedule";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/** Begin Time Increments For Select */
|
||||
const increment = 15;
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ function SkeletonItem() {
|
||||
<li className="group flex w-full items-center justify-between px-2 py-[23px] sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText width="32" height="4"></SkeletonText>
|
||||
<SkeletonText width="32" height="2"></SkeletonText>
|
||||
<SkeletonText width="32" height="4" />
|
||||
<SkeletonText width="32" height="2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText width="12" height="6"></SkeletonText>
|
||||
<SkeletonText width="12" height="6" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -37,8 +37,8 @@ export const AvailabilitySelectSkeletonLoader = () => {
|
||||
<li className="group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText width="32" height="4"></SkeletonText>
|
||||
<SkeletonText width="4" height="4"></SkeletonText>
|
||||
<SkeletonText width="32" height="4" />
|
||||
<SkeletonText width="4" height="4" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||
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";
|
||||
@@ -24,10 +25,12 @@ type AvailableTimesProps = {
|
||||
schedulingType: SchedulingType | null;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
slots?: Slot[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
slots = [],
|
||||
isLoading,
|
||||
date,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
@@ -57,8 +60,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto md:h-[364px]">
|
||||
{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;
|
||||
@@ -99,10 +102,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}>
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && <p className={`text-sm`}>{t("booking_full")}</p>}
|
||||
{!!seatsPerTimeSlot && <p className="text-sm">{t("booking_full")}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Link href={bookingUrl}>
|
||||
<Link href={bookingUrl} prefetch={false}>
|
||||
<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",
|
||||
@@ -129,11 +132,26 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
} 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 { parseRecurringEvent } from "@calcom/lib";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
@@ -28,7 +27,7 @@ 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 { extractRecurringDates } from "@lib/parseDate";
|
||||
import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
@@ -41,7 +40,7 @@ type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringCount?: number;
|
||||
recurringBookings?: BookingItem[];
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
@@ -127,7 +126,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("cancel_all_remaining")
|
||||
: t("cancel"),
|
||||
href: `/cancel/${booking.uid}`,
|
||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/cancel/${booking.uid}${
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? "?allRemainingBookings=true"
|
||||
: ""
|
||||
}`,
|
||||
icon: XIcon,
|
||||
},
|
||||
{
|
||||
@@ -202,13 +207,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
let recurringStrings: string[] = [];
|
||||
let recurringDates: Date[] = [];
|
||||
const today = new Date();
|
||||
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: booking.startTime,
|
||||
recurringEvent: parseRecurringEvent(booking.eventType.recurringEvent),
|
||||
recurringCount: booking.recurringCount,
|
||||
},
|
||||
if (booking.recurringBookings && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||
[recurringStrings, recurringDates] = extractRecurringDates(
|
||||
booking.recurringBookings,
|
||||
user?.timeZone,
|
||||
i18n
|
||||
);
|
||||
if (booking.status === BookingStatus.PENDING) {
|
||||
@@ -307,7 +309,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{booking.recurringCount &&
|
||||
{booking.recurringBookings &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
(booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
@@ -326,7 +328,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
: getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringCount: booking.recurringCount,
|
||||
recurringCount: booking.recurringBookings.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import { PeriodType } from "@prisma/client";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
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/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";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
dayjs.extend(dayjsBusinessTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type DatePickerProps = {
|
||||
weekStart: string;
|
||||
onDatePicked: (pickedDate: Dayjs) => void;
|
||||
workingHours: WorkingHours[];
|
||||
eventLength: number;
|
||||
date: Dayjs | null;
|
||||
periodType: PeriodType;
|
||||
periodStartDate: Date | null;
|
||||
periodEndDate: Date | null;
|
||||
periodDays: number | null;
|
||||
periodCountCalendarDays: boolean | null;
|
||||
minimumBookingNotice: number;
|
||||
};
|
||||
|
||||
function DatePicker({
|
||||
weekStart,
|
||||
onDatePicked,
|
||||
workingHours,
|
||||
eventLength,
|
||||
date,
|
||||
periodType = PeriodType.UNLIMITED,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodDays,
|
||||
periodCountCalendarDays,
|
||||
minimumBookingNotice,
|
||||
}: DatePickerProps): JSX.Element {
|
||||
const { i18n } = useLocale();
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs | null>(date);
|
||||
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
|
||||
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
|
||||
const [month, setMonth] = useState<string>("");
|
||||
const [year, setYear] = useState<string>("");
|
||||
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
|
||||
const [daysFromState, setDays] = useState<
|
||||
| {
|
||||
disabled: boolean;
|
||||
date: number;
|
||||
}[]
|
||||
| null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
|
||||
setBrowsingDate(date || dayjs().tz(timeZone()));
|
||||
}
|
||||
}, [date, browsingDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (browsingDate) {
|
||||
setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" }));
|
||||
setYear(browsingDate.format("YYYY"));
|
||||
setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs()));
|
||||
setDays(null);
|
||||
}
|
||||
}, [browsingDate, i18n.language]);
|
||||
|
||||
const isDisabled = (
|
||||
day: number,
|
||||
{
|
||||
browsingDate,
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}: Omit<DatePickerProps, "weekStart" | "onDatePicked" | "date"> & {
|
||||
browsingDate: Dayjs;
|
||||
}
|
||||
) => {
|
||||
const date = browsingDate.startOf("day").date(day);
|
||||
return (
|
||||
isOutOfBounds(date, {
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
}) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
eventLength,
|
||||
}).length
|
||||
);
|
||||
};
|
||||
|
||||
const isDisabledRef = useRef(
|
||||
memoize(isDisabled, (day, { browsingDate }) => {
|
||||
// Make a composite cache key
|
||||
return day + "_" + browsingDate.toString();
|
||||
})
|
||||
);
|
||||
|
||||
const days = (() => {
|
||||
if (!browsingDate) {
|
||||
return [];
|
||||
}
|
||||
if (daysFromState) {
|
||||
return daysFromState;
|
||||
}
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = browsingDate.date(1).day();
|
||||
if (weekStart === "Monday") {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||
}
|
||||
|
||||
const days = Array(weekdayOfFirst).fill(null);
|
||||
|
||||
const isDisabledMemoized = isDisabledRef.current;
|
||||
|
||||
const daysInMonth = browsingDate.daysInMonth();
|
||||
const daysInitialOffset = days.length;
|
||||
|
||||
// Build UI with All dates disabled
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({
|
||||
disabled: true,
|
||||
date: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Update dates with their availability
|
||||
doWorkAsync({
|
||||
batch: 1,
|
||||
name: "DatePicker",
|
||||
length: daysInMonth,
|
||||
callback: (i: number) => {
|
||||
const day = i + 1;
|
||||
days[daysInitialOffset + i] = {
|
||||
disabled: isDisabledMemoized(day, {
|
||||
browsingDate,
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}),
|
||||
date: day,
|
||||
};
|
||||
},
|
||||
batchDone: () => {
|
||||
setDays([...days]);
|
||||
},
|
||||
});
|
||||
|
||||
return days;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
})();
|
||||
|
||||
if (!browsingDate) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setBrowsingDate(browsingDate?.add(1, "month"));
|
||||
};
|
||||
|
||||
const decrementMonth = () => {
|
||||
setBrowsingDate(browsingDate?.subtract(1, "month"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mt-8 sm:mt-0 sm:min-w-[455px] " +
|
||||
(date
|
||||
? "w-full sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
|
||||
: "w-full sm:pl-4")
|
||||
}>
|
||||
<div className="mb-4 flex text-xl font-light">
|
||||
<span className="w-1/2 dark:text-white">
|
||||
<strong className="text-bookingdarker dark:text-white">{month}</strong>{" "}
|
||||
<span className="text-bookinglight">{year}</span>
|
||||
</span>
|
||||
<div className="w-1/2 text-right dark:text-gray-400">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={classNames(
|
||||
"group p-1 ltr:mr-2 rtl:ml-2",
|
||||
isFirstMonth && "text-bookinglighter dark:text-gray-600"
|
||||
)}
|
||||
disabled={isFirstMonth}
|
||||
data-testid="decrementMonth">
|
||||
<ChevronLeftIcon className="h-5 w-5 group-hover:text-black dark:group-hover:text-white" />
|
||||
</button>
|
||||
<button className="group p-1" onClick={incrementMonth} data-testid="incrementMonth">
|
||||
<ChevronRightIcon className="h-5 w-5 group-hover:text-black dark:group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-bookinglightest grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
|
||||
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
|
||||
<div key={weekDay} className="text-bookinglight my-4 text-xs uppercase tracking-widest">
|
||||
{weekDay}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2 text-center">
|
||||
{days.map((day, idx) => (
|
||||
<div
|
||||
key={day === null ? `e-${idx}` : `day-${day.date}`}
|
||||
style={{
|
||||
paddingTop: "100%",
|
||||
}}
|
||||
className="relative w-full">
|
||||
{day === null ? (
|
||||
<div key={`e-${idx}`} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onDatePicked(browsingDate.date(day.date))}
|
||||
disabled={day.disabled}
|
||||
style={
|
||||
day.disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }
|
||||
}
|
||||
className={classNames(
|
||||
"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 disabled:bg-gray-100 disabled:dark:bg-gray-600 disabled:dark:text-white"
|
||||
: ""
|
||||
)}
|
||||
data-testid="day"
|
||||
data-disabled={day.disabled}>
|
||||
{day.date}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatePicker;
|
||||
@@ -53,7 +53,7 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone }) => {
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
className="focus:border-brand mt-1 mb-2 block w-full rounded-md border-gray-300 shadow-sm focus:ring-black sm:text-sm"
|
||||
className="focus:border-brand mt-1 mb-2 block w-full rounded-md border-gray-300 focus:ring-black sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -15,15 +15,15 @@ import {
|
||||
import { EventType } from "@prisma/client";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { TFunction } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppStoreLocationType, LocationObject, LocationType } from "@calcom/app-store/locations";
|
||||
import dayjs, { Dayjs } from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useEmbedStyles,
|
||||
@@ -31,16 +31,14 @@ import {
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { CAL_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import Loader from "@calcom/ui/Loader";
|
||||
import DatePicker from "@calcom/ui/booker/DatePicker";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
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";
|
||||
@@ -55,15 +53,10 @@ import { HeadSeo } from "@components/seo/head-seo";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||
|
||||
import type { Slot } from "@server/routers/viewer/slots";
|
||||
|
||||
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(customParseFormat);
|
||||
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
export const locationKeyToString = (location: LocationObject, t: TFunction) => {
|
||||
@@ -95,23 +88,19 @@ export const locationKeyToString = (location: LocationObject, t: TFunction) => {
|
||||
}
|
||||
};
|
||||
|
||||
const GoBackToPreviousPage = ({ slug }: { slug: string }) => {
|
||||
const GoBackToPreviousPage = ({ t }: { t: TFunction }) => {
|
||||
const router = useRouter();
|
||||
const [previousPage, setPreviousPage] = useState<string>();
|
||||
useEffect(() => {
|
||||
setPreviousPage(document.referrer);
|
||||
}, []);
|
||||
|
||||
return previousPage === `${WEBAPP_URL}/${slug}` ? (
|
||||
const path = router.asPath.split("/");
|
||||
path.pop(); // Remove the last item (where we currently are)
|
||||
path.shift(); // Removes first item e.g. if we were visitng "/teams/test/30mins" the array will new look like ["teams","test"]
|
||||
const slug = path.join("/");
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<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>
|
||||
<button title={t("profile")} onClick={() => router.replace(`${WEBSITE_URL}/${slug}`)}>
|
||||
<ArrowLeftIcon className="h-4 w-4 text-black transition-opacity hover:cursor-pointer dark:text-white" />
|
||||
<p className="sr-only">Go Back</p>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -119,26 +108,40 @@ const useSlots = ({
|
||||
eventTypeId,
|
||||
startTime,
|
||||
endTime,
|
||||
timeZone,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
startTime?: Dayjs;
|
||||
endTime?: Dayjs;
|
||||
timeZone?: string;
|
||||
}) => {
|
||||
const { data, isLoading } = trpc.useQuery([
|
||||
"viewer.slots.getSchedule",
|
||||
{
|
||||
eventTypeId,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
},
|
||||
]);
|
||||
const { data, isLoading, isIdle } = trpc.useQuery(
|
||||
[
|
||||
"viewer.public.slots.getSchedule",
|
||||
{
|
||||
eventTypeId,
|
||||
startTime: startTime?.toISOString() || "",
|
||||
endTime: endTime?.toISOString() || "",
|
||||
timeZone,
|
||||
},
|
||||
],
|
||||
{ enabled: !!startTime && !!endTime }
|
||||
);
|
||||
|
||||
return { slots: data?.slots || {}, isLoading };
|
||||
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.slots) {
|
||||
setCachedSlots((c) => ({ ...c, ...data?.slots }));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// The very first time isIdle is set if auto-fetch is disabled, so isIdle should also be considered a loading state.
|
||||
return { slots: cachedSlots, isLoading: isLoading || isIdle };
|
||||
};
|
||||
|
||||
const SlotPicker = ({
|
||||
eventType,
|
||||
timezoneDropdown,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
recurringEventCount,
|
||||
@@ -146,62 +149,77 @@ const SlotPicker = ({
|
||||
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 } = useDateSelected({ timeZone });
|
||||
|
||||
const { i18n } = useLocale();
|
||||
const [startDate, setStartDate] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (dayjs(selectedDate).startOf("month").isAfter(dayjs())) {
|
||||
setStartDate(dayjs(selectedDate).startOf("month").toDate());
|
||||
}
|
||||
}, [selectedDate]);
|
||||
if (!router.isReady) return;
|
||||
|
||||
const { slots, isLoading } = useSlots({
|
||||
// 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, month, date, timeZone]);
|
||||
|
||||
const { i18n, isLocaleReady } = useLocale();
|
||||
const { slots: _1 } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
startTime: startDate,
|
||||
endTime: dayjs(startDate).endOf("month").toDate(),
|
||||
startTime: selectedDate?.startOf("day"),
|
||||
endTime: selectedDate?.endOf("day"),
|
||||
timeZone,
|
||||
});
|
||||
const { slots: _2, isLoading } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
startTime: browsingDate?.startOf("month"),
|
||||
endTime: browsingDate?.endOf("month"),
|
||||
timeZone,
|
||||
});
|
||||
|
||||
const [times, setTimes] = useState<Slot[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && slots[yyyymmdd(selectedDate)]) {
|
||||
setTimes(slots[yyyymmdd(selectedDate)]);
|
||||
}
|
||||
}, [selectedDate, slots]);
|
||||
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")
|
||||
}
|
||||
locale={i18n.language}
|
||||
className={classNames(
|
||||
"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={setSelectedDate}
|
||||
onMonthChange={setStartDate}
|
||||
onChange={(newDate) => {
|
||||
setDate(newDate.format("YYYY-MM-DD"));
|
||||
}}
|
||||
onMonthChange={(newMonth) => {
|
||||
setMonth(newMonth.format("YYYY-MM"));
|
||||
}}
|
||||
browsingDate={browsingDate}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
|
||||
<div className="mt-4 ml-1 block sm:hidden">{timezoneDropdown}</div>
|
||||
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
slots={times}
|
||||
date={dayjs(selectedDate)}
|
||||
isLoading={isLoading}
|
||||
slots={slots[selectedDate.format("YYYY-MM-DD")]}
|
||||
date={selectedDate}
|
||||
timeFormat={timeFormat}
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
@@ -218,9 +236,11 @@ const SlotPicker = ({
|
||||
function TimezoneDropdown({
|
||||
onChangeTimeFormat,
|
||||
onChangeTimeZone,
|
||||
timeZone,
|
||||
}: {
|
||||
onChangeTimeFormat: (newTimeFormat: string) => void;
|
||||
onChangeTimeZone: (newTimeZone: string) => void;
|
||||
timeZone?: string;
|
||||
}) {
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
|
||||
@@ -230,8 +250,8 @@ function TimezoneDropdown({
|
||||
}, []);
|
||||
|
||||
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||
timeZone(newTimeZone);
|
||||
onChangeTimeZone(newTimeZone);
|
||||
localStorageTimeZone(newTimeZone);
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
@@ -242,13 +262,15 @@ function TimezoneDropdown({
|
||||
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()}
|
||||
{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" />
|
||||
)}
|
||||
<p className="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 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</p>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} onToggle24hClock={handleToggle24hClock} />
|
||||
@@ -257,55 +279,32 @@ function TimezoneDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
const useDateSelected = ({ timeZone }: { timeZone?: string }) => {
|
||||
const dateQuerySchema = z.object({
|
||||
rescheduleUid: z.string().optional().default(""),
|
||||
date: z.string().optional().default(""),
|
||||
timeZone: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
const useRouterQuery = <T extends string>(name: T) => {
|
||||
const router = useRouter();
|
||||
const [selectedDate, _setSelectedDate] = useState<Date>();
|
||||
const query = z.object({ [name]: z.string().optional() }).parse(router.query);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
if (date.isValid()) {
|
||||
setSelectedDate(date.toDate());
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const setSelectedDate = (newDate: Date) => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
date: dayjs(newDate).tz(timeZone, true).format("YYYY-MM-DDZZ"),
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
_setSelectedDate(newDate);
|
||||
const setQuery = (newValue: string | number | null | undefined) => {
|
||||
router.replace({ query: { ...router.query, [name]: newValue } }, undefined, { shallow: true });
|
||||
};
|
||||
|
||||
return { selectedDate, setSelectedDate };
|
||||
return { [name]: query[name], setQuery } as {
|
||||
[K in T]: string | undefined;
|
||||
} & { setQuery: typeof setQuery };
|
||||
};
|
||||
|
||||
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";
|
||||
@@ -317,8 +316,12 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAvailableTimesVisible(!!router.query.date);
|
||||
}, [router.query.date]);
|
||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAvailableTimesVisible(!!query.date);
|
||||
}, [query.date]);
|
||||
|
||||
// TODO: Improve this;
|
||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||
@@ -353,14 +356,19 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
: recurringEventCount
|
||||
? "max-w-4xl"
|
||||
: "max-w-3xl";
|
||||
|
||||
if (Object.keys(i18n).length === 0) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const timezoneDropdown = (
|
||||
<TimezoneDropdown onChangeTimeFormat={setTimeFormat} onChangeTimeZone={setTimeZone} />
|
||||
const timezoneDropdown = useMemo(
|
||||
() => (
|
||||
<TimezoneDropdown
|
||||
onChangeTimeFormat={setTimeFormat}
|
||||
timeZone={timeZone}
|
||||
onChangeTimeZone={setTimeZone}
|
||||
/>
|
||||
),
|
||||
[timeZone]
|
||||
);
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
const slug = rawSlug.join("/");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -369,8 +377,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||
name={profile.name || undefined}
|
||||
username={profile.slug || undefined}
|
||||
// avatar={profile.image || undefined}
|
||||
username={slug || undefined}
|
||||
/>
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
<div>
|
||||
@@ -381,132 +388,129 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
? 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: `${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-gray-600 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
{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" />
|
||||
)}
|
||||
<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-gray-600 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
{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)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
</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 && (
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</div>
|
||||
)}
|
||||
{!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 [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>
|
||||
)}
|
||||
<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 && (
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 dark:text-gray-400" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</div>
|
||||
)}
|
||||
{!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>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</div>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
|
||||
<div className="md:hidden">
|
||||
{/* Temp disabled booking?.startTime && rescheduleUid && (
|
||||
<div className="md:hidden">
|
||||
{/* Temp disabled booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-8 text-gray-600 dark:text-white"
|
||||
@@ -520,138 +524,138 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:flex sm:p-4 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 && (
|
||||
<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 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 && (
|
||||
<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>
|
||||
)}
|
||||
{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")}
|
||||
<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>
|
||||
)}
|
||||
{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" />
|
||||
)}
|
||||
{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)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
</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 [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>
|
||||
)}
|
||||
<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")}
|
||||
</div>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<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
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</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="-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
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</div>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</div>
|
||||
|
||||
<GoBackToPreviousPage slug={profile.slug || ""} />
|
||||
{!isEmbed && <GoBackToPreviousPage t={t} />}
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-white"
|
||||
@@ -664,18 +668,23 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
<SlotPicker
|
||||
eventType={eventType}
|
||||
timezoneDropdown={timezoneDropdown}
|
||||
timeZone={timeZone}
|
||||
timeFormat={timeFormat}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
/>
|
||||
</div>
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
? (["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
||||
profile.weekStart
|
||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: profile.weekStart /* Allows providing weekStart as number */
|
||||
}
|
||||
eventType={eventType}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { EventTypeCustomInputType, WorkflowActions } from "@prisma/client";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
import dayjs from "dayjs";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
@@ -24,6 +24,7 @@ import { useMutation } from "react-query";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
useIsBackgroundTransparent,
|
||||
@@ -87,6 +88,7 @@ type BookingFormValues = {
|
||||
[key: string]: string | boolean;
|
||||
};
|
||||
rescheduleReason?: string;
|
||||
smsReminderNumber?: string;
|
||||
};
|
||||
|
||||
const BookingPage = ({
|
||||
@@ -278,6 +280,14 @@ const BookingPage = ({
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.optional(),
|
||||
smsReminderNumber: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -316,6 +326,15 @@ const BookingPage = ({
|
||||
case LocationType.UserPhone: {
|
||||
return locationInfo(locationType)?.hostPhoneNumber || "";
|
||||
}
|
||||
case LocationType.Around: {
|
||||
return locationInfo(locationType)?.link || "";
|
||||
}
|
||||
case LocationType.Riverside: {
|
||||
return locationInfo(locationType)?.link || "";
|
||||
}
|
||||
case LocationType.Whereby: {
|
||||
return locationInfo(locationType)?.link || "";
|
||||
}
|
||||
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||
default:
|
||||
return selectedLocation || "";
|
||||
@@ -329,6 +348,7 @@ const BookingPage = ({
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
timeZone: timeZone(),
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: parseInt(recurringEventCount.toString()),
|
||||
},
|
||||
@@ -390,6 +410,8 @@ const BookingPage = ({
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
smsReminderNumber:
|
||||
selectedLocation === LocationType.Phone ? booking.phone : booking.smsReminderNumber,
|
||||
}));
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
@@ -415,6 +437,8 @@ const BookingPage = ({
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
smsReminderNumber:
|
||||
selectedLocation === LocationType.Phone ? booking.phone : booking.smsReminderNumber,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -422,7 +446,22 @@ 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";
|
||||
"focus:border-brand block w-full rounded-sm border-gray-300 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";
|
||||
|
||||
let isSmsReminderNumberNeeded = false;
|
||||
|
||||
if (eventType.workflows.length > 0) {
|
||||
eventType.workflows.forEach((workflowReference) => {
|
||||
if (workflowReference.workflow.steps.length > 0) {
|
||||
workflowReference.workflow.steps.forEach((step) => {
|
||||
if (step.action === WorkflowActions.SMS_ATTENDEE) {
|
||||
isSmsReminderNumberNeeded = true;
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -462,10 +501,13 @@ const BookingPage = ({
|
||||
<AvatarGroup
|
||||
border="border-2 border-white dark:border-gray-800"
|
||||
size={14}
|
||||
items={[{ image: profile.image || "", alt: profile.name || "" }].concat(
|
||||
items={[
|
||||
{ image: profile.image || "", alt: profile.name || "", title: profile.name || "" },
|
||||
].concat(
|
||||
eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name || "",
|
||||
image: user.avatar || "",
|
||||
alt: user.name || "",
|
||||
}))
|
||||
@@ -477,7 +519,7 @@ const BookingPage = ({
|
||||
<h1 className="text-bookingdark mb-4 text-xl font-semibold dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
{eventType.seatsPerTimeSlot && (
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<p
|
||||
className={`${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
@@ -571,7 +613,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">
|
||||
@@ -656,6 +698,12 @@ const BookingPage = ({
|
||||
disabled={disableInput}
|
||||
/>
|
||||
</div>
|
||||
{bookingForm.formState.errors.phone && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<ExclamationCircleIcon className="mr-2 h-3 w-3" />
|
||||
<p>{t("invalid_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{eventType.customInputs
|
||||
@@ -791,6 +839,31 @@ const BookingPage = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isSmsReminderNumberNeeded && selectedLocation !== LocationType.Phone && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="smsReminderNumber"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("number_for_sms_reminders")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<PhoneInput<BookingFormValues>
|
||||
control={bookingForm.control}
|
||||
name="smsReminderNumber"
|
||||
placeholder={t("enter_phone_number")}
|
||||
id="smsReminderNumber"
|
||||
required
|
||||
disabled={disableInput}
|
||||
/>
|
||||
</div>
|
||||
{bookingForm.formState.errors.smsReminderNumber && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<ExclamationCircleIcon className="mr-2 h-3 w-3" />
|
||||
<p>{t("invalid_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="notes"
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -79,7 +80,23 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
const locationFormSchema = z.object({
|
||||
locationType: z.string(),
|
||||
locationAddress: z.string().optional(),
|
||||
locationLink: z.string().url().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()
|
||||
@@ -137,7 +154,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
onChange={(e) =>
|
||||
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
|
||||
}
|
||||
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||
informationIconText={t("display_location_info_badge")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -153,8 +171,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
<input
|
||||
type="text"
|
||||
{...locationFormMethods.register("locationLink")}
|
||||
id="link"
|
||||
required
|
||||
id="link"
|
||||
className="block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
defaultValue={
|
||||
defaultValues
|
||||
@@ -183,7 +201,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => locationFormMethods.setValue("displayLocationPublicly", e.target.checked)}
|
||||
informationIconText={t("display_location_info_badge")}></CheckboxField>
|
||||
informationIconText={t("display_location_info_badge")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -211,10 +230,154 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}
|
||||
/>
|
||||
{locationFormMethods.formState.errors.locationPhoneNumber && (
|
||||
<p className="mt-1 text-sm text-red-500">Invalid input</p>
|
||||
<p className="mt-1 text-sm text-red-500">{t("invalid_number")}</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")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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.co/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")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : selectedLocation === LocationType.Riverside ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||
{t("set_riverside_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")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm">{LocationOptionsToString(selectedLocation, t)}</p>
|
||||
);
|
||||
@@ -235,7 +398,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"></div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left" />
|
||||
</div>
|
||||
{booking && (
|
||||
<>
|
||||
@@ -255,9 +418,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
displayLocationPublicly,
|
||||
};
|
||||
}
|
||||
if (newLocation === LocationType.Link) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
<DialogContent>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<ClockIcon className="m-auto h-6 w-6"></ClockIcon>
|
||||
<ClockIcon className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("send_reschedule_request")} />
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<DialogContent>
|
||||
<DialogContent className="overflow-y-auto">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
|
||||
@@ -200,16 +200,26 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
)}
|
||||
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
|
||||
|
||||
<TextField
|
||||
label={t("url")}
|
||||
required
|
||||
addOnLeading={
|
||||
<InputLeading>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
||||
</InputLeading>
|
||||
}
|
||||
{...register("slug")}
|
||||
/>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
|
||||
<TextField
|
||||
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
|
||||
required
|
||||
addOnLeading={<InputLeading>/{pageSlug}/</InputLeading>}
|
||||
{...register("slug")}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
label={t("url")}
|
||||
required
|
||||
addOnLeading={
|
||||
<InputLeading>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
||||
</InputLeading>
|
||||
}
|
||||
{...register("slug")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextAreaField
|
||||
label={t("description")}
|
||||
@@ -247,7 +257,7 @@ export default function CreateEventTypeButton(props: Props) {
|
||||
<RadioArea.Group
|
||||
{...register("schedulingType")}
|
||||
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
|
||||
className="relative mt-1 flex space-x-6 rounded-sm shadow-sm rtl:space-x-reverse">
|
||||
className="relative mt-1 flex space-x-6 rounded-sm rtl:space-x-reverse">
|
||||
<RadioArea.Item
|
||||
value={SchedulingType.COLLECTIVE}
|
||||
defaultChecked={type === SchedulingType.COLLECTIVE}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function RecurringEventController({
|
||||
type="number"
|
||||
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"
|
||||
className="block w-16 rounded-sm border-gray-300 [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
defaultValue={recurringEventState.interval}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
@@ -114,7 +114,7 @@ export default function RecurringEventController({
|
||||
type="number"
|
||||
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"
|
||||
className="block w-16 rounded-sm border-gray-300 [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
defaultValue={recurringEventState.count}
|
||||
onChange={(event) => {
|
||||
const newVal = {
|
||||
|
||||
@@ -8,10 +8,10 @@ function SkeletonLoader() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mb-4 flex items-center">
|
||||
<SkeletonAvatar width="8" height="8"></SkeletonAvatar>
|
||||
<SkeletonAvatar width="8" height="8" />
|
||||
<div className="space-y-1">
|
||||
<SkeletonText height="4" width="16"></SkeletonText>
|
||||
<SkeletonText height="4" width="24"></SkeletonText>
|
||||
<SkeletonText height="4" width="16" />
|
||||
<SkeletonText height="4" width="24" />
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-neutral-200 border border-gray-200 bg-white sm:mx-0 sm:overflow-hidden">
|
||||
@@ -30,17 +30,17 @@ function SkeletonItem() {
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div>
|
||||
<SkeletonText width="32" height="5"></SkeletonText>
|
||||
<SkeletonText width="32" height="5" />
|
||||
</div>
|
||||
<div className="">
|
||||
<ul className="mt-2 flex space-x-4 rtl:space-x-reverse ">
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></ClockIcon>
|
||||
<SkeletonText width="12" height="4"></SkeletonText>
|
||||
<ClockIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText width="12" height="4" />
|
||||
</li>
|
||||
<li className="flex items-center whitespace-nowrap">
|
||||
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200"></UserIcon>
|
||||
<SkeletonText width="16" height="4"></SkeletonText>
|
||||
<UserIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-gray-200" />
|
||||
<SkeletonText width="16" height="4" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import Button from "@calcom/ui/Button";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { trpc } from "@lib/trpc";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import AdditionalCalendarSelector from "@components/AdditionalCalendarSelector";
|
||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
@@ -153,6 +153,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
externalId={item.primary?.externalId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
||||
{t("disconnect")}
|
||||
@@ -205,13 +206,19 @@ function ConnectedCalendarsList(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarListContainer(props: { heading?: boolean; fromOnboarding?: boolean }) {
|
||||
export function CalendarListContainer(props: {
|
||||
heading?: boolean;
|
||||
items?: inferQueryOutput<"viewer.integrations">["items"];
|
||||
fromOnboarding?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { heading = true, fromOnboarding } = props;
|
||||
const utils = trpc.useContext();
|
||||
const onChanged = () =>
|
||||
Promise.allSettled([
|
||||
utils.invalidateQueries(["viewer.integrations"]),
|
||||
utils.invalidateQueries(["viewer.integrations", { variant: "calendar", onlyInstalled: true }], {
|
||||
exact: true,
|
||||
}),
|
||||
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
||||
]);
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
|
||||
@@ -6,40 +6,34 @@ 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: {
|
||||
/** Integration credential id */
|
||||
id: number;
|
||||
externalId?: string;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
const { id, externalId = "" } = props;
|
||||
const { t } = useLocale();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const mutation = 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}>
|
||||
@@ -49,7 +43,7 @@ export default function DisconnectIntegration(props: {
|
||||
confirmBtnText={t("yes_remove_app")}
|
||||
cancelBtnText="Cancel"
|
||||
onConfirm={() => {
|
||||
mutation.mutate();
|
||||
mutation.mutate({ id, externalId });
|
||||
}}>
|
||||
{t("are_you_sure_you_want_to_remove_this_app")}
|
||||
</ConfirmationDialogContent>
|
||||
|
||||
@@ -74,7 +74,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
@@ -90,7 +90,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
<input
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm shadow-sm"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
|
||||
@@ -74,7 +74,7 @@ const ChangePasswordSection = () => {
|
||||
name="current_password"
|
||||
id="current_password"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
placeholder={t("your_old_password")}
|
||||
/>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@ const ChangePasswordSection = () => {
|
||||
value={newPassword}
|
||||
required
|
||||
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
placeholder={t("super_secure_new_password")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonati
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.i18n"]);
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -175,7 +175,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
minLength={6}
|
||||
inputMode="numeric"
|
||||
onInput={(e) => setTotpCode(e.currentTarget.value)}
|
||||
className="block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
className="block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function MemberChangeRoleModal(props: {
|
||||
value={role}
|
||||
onChange={(option) => option && setRole(option)}
|
||||
id="role"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm sm:text-sm"
|
||||
className="mt-1 block w-full rounded-md border-gray-300 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
@@ -114,7 +114,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||
name="sendInviteEmail"
|
||||
defaultChecked
|
||||
id="sendInviteEmail"
|
||||
className="rounded-sm border-gray-300 text-black shadow-sm sm:text-sm"
|
||||
className="rounded-sm border-gray-300 text-black sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-2 rtl:mr-2">
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function MemberListItem(props: Props) {
|
||||
<div className="mt-2 flex ltr:mr-2 rtl:ml-2 sm:mt-0 sm:justify-center">
|
||||
{/* Tooltip doesn't show... WHY????? */}
|
||||
{props.member.isMissingSeat && (
|
||||
<Tooltip content={t("hidden_team_member_message")}>
|
||||
<Tooltip side="top" content={t("hidden_team_member_message")}>
|
||||
<TeamPill color="red" text={t("hidden")} />
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -100,7 +100,7 @@ export default function MemberListItem(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Tooltip content={t("team_view_user_availability")}>
|
||||
<Tooltip side="top" content={t("team_view_user_availability")}>
|
||||
<Button
|
||||
// Disabled buttons don't trigger Tooltips
|
||||
title={
|
||||
|
||||
@@ -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" />
|
||||
<div className="space-y-2">
|
||||
<SkeletonText height="4" width="32" />
|
||||
<SkeletonText height="4" width="16" />
|
||||
</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 width="4" height="4" />
|
||||
<SkeletonText width="4" height="4" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default function TeamCreate(props: Props) {
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
LinkIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
@@ -98,19 +100,50 @@ export default function TeamListItem(props: Props) {
|
||||
<div className="px-5 py-5">
|
||||
{isInvitee && (
|
||||
<>
|
||||
<Button type="button" color="secondary" onClick={declineInvite}>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button type="button" color="primary" className="ltr:ml-2 rtl:mr-2" onClick={acceptInvite}>
|
||||
{t("accept")}
|
||||
</Button>
|
||||
<div className="hidden sm:block">
|
||||
<Button type="button" color="secondary" onClick={declineInvite}>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button type="button" color="primary" className="ltr:ml-2 rtl:mr-2" onClick={acceptInvite}>
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="block sm:hidden">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="group h-10 w-10 border border-transparent p-0 text-neutral-400 hover:border-gray-200 ">
|
||||
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={CheckIcon}
|
||||
onClick={acceptInvite}>
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
color="warn"
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={XIcon}
|
||||
onClick={declineInvite}>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isInvitee && (
|
||||
<div className="flex rtl:space-x-reverse">
|
||||
<TeamRole role={team.role} />
|
||||
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Tooltip side="top" content={t("copy_link_team")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug);
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function TeamSettings(props: Props) {
|
||||
id="team-url"
|
||||
addOnLeading={
|
||||
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{"team/"}
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/team/
|
||||
</span>
|
||||
}
|
||||
ref={teamUrlRef}
|
||||
@@ -112,7 +112,7 @@ export default function TeamSettings(props: Props) {
|
||||
id="name"
|
||||
placeholder={t("your_team_name")}
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 sm:text-sm"
|
||||
defaultValue={team?.name as string}
|
||||
/>
|
||||
}
|
||||
@@ -131,7 +131,8 @@ export default function TeamSettings(props: Props) {
|
||||
name="about"
|
||||
rows={3}
|
||||
defaultValue={team?.bio as string}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"></textarea>
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</>
|
||||
}
|
||||
@@ -140,7 +141,7 @@ export default function TeamSettings(props: Props) {
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={PhotographIcon}
|
||||
label={"Logo"}
|
||||
label="Logo"
|
||||
htmlFor="avatar"
|
||||
Input={
|
||||
<>
|
||||
@@ -151,7 +152,7 @@ export default function TeamSettings(props: Props) {
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 sm:text-sm"
|
||||
defaultValue={team?.logo ?? undefined}
|
||||
/>
|
||||
<ImageUploader
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
|
||||
{props.team?.id && props.role !== MembershipRole.MEMBER && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<div className="mt-5 hidden space-y-1 sm:block">
|
||||
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
|
||||
<LinkIconButton Icon={ClockIcon}>View Availability</LinkIconButton>
|
||||
<p className="mt-2 text-sm text-gray-500">See your team members availability at a glance.</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function UpgradeToFlexibleProModal(props: Props) {
|
||||
setErrorMessage(null);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<a className="cursor-pointer underline">{"Upgrade Now"}</a>
|
||||
<a className="cursor-pointer underline">Upgrade Now</a>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("Purchase missing seats")} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { LOGO } from "@calcom/lib/constants";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
@@ -20,7 +22,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
||||
<div className={classNames(props.showLogo ? "text-center" : "", "sm:mx-auto sm:w-full sm:max-w-md")}>
|
||||
{props.showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="mx-auto h-6" src="/calendso-logo-white-word.svg" alt="Cal.com Logo" />
|
||||
<img className="mx-auto h-6" src={LOGO} alt="Cal.com Logo" />
|
||||
)}
|
||||
{props.heading && (
|
||||
<h2 className="font-cal mt-6 text-center text-3xl text-neutral-900">{props.heading}</h2>
|
||||
|
||||
@@ -15,6 +15,9 @@ export type AvatarProps = {
|
||||
gravatarFallbackMd5?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use AvatarSSR instead. Once, there is no usage of Avatar, AvatarSSR can be renamed.
|
||||
*/
|
||||
export default function Avatar(props: AvatarProps) {
|
||||
const { imageSrc, gravatarFallbackMd5, size, alt, title } = props;
|
||||
const className = classNames("rounded-full", props.className, size && `h-${size} w-${size}`);
|
||||
@@ -37,7 +40,7 @@ export default function Avatar(props: AvatarProps) {
|
||||
return title ? (
|
||||
<Tooltip.Tooltip delayDuration={300}>
|
||||
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
|
||||
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white shadow-sm">
|
||||
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white">
|
||||
<Tooltip.Arrow />
|
||||
{title}
|
||||
</Tooltip.Content>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { AvatarSSR } from "@components/ui/AvatarSSR";
|
||||
|
||||
export type AvatarGroupProps = {
|
||||
border?: string; // this needs to be the color of the parent container background, i.e.: border-white dark:border-gray-900
|
||||
@@ -23,7 +24,7 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
if (item.image != null) {
|
||||
return (
|
||||
<li key={idx} className="-mr-2 inline-block">
|
||||
<Avatar
|
||||
<AvatarSSR
|
||||
className={props.border}
|
||||
imageSrc={item.image}
|
||||
title={item.title}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { User } from "@prisma/client";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export type AvatarProps = {
|
||||
user: Pick<User, "name" | "username" | "avatar"> & { emailMd5?: string };
|
||||
export type AvatarProps = (
|
||||
| {
|
||||
user: Pick<User, "name" | "username" | "avatar"> & { emailMd5?: string };
|
||||
}
|
||||
| {
|
||||
user?: null;
|
||||
imageSrc: string;
|
||||
}
|
||||
) & {
|
||||
className?: string;
|
||||
size?: number;
|
||||
title?: string;
|
||||
@@ -16,18 +24,38 @@ function defaultAvatarSrc(md5: string) {
|
||||
}
|
||||
|
||||
// An SSR Supported version of Avatar component.
|
||||
// FIXME: title support is missing
|
||||
export function AvatarSSR(props: AvatarProps) {
|
||||
const { user, size } = props;
|
||||
const nameOrUsername = user.name || user.username || "";
|
||||
const className = classNames("rounded-full", props.className, size && `h-${size} w-${size}`);
|
||||
let imgSrc;
|
||||
const alt = props.alt || nameOrUsername;
|
||||
if (user.avatar) {
|
||||
imgSrc = user.avatar;
|
||||
} else if (user.emailMd5) {
|
||||
imgSrc = defaultAvatarSrc(user.emailMd5);
|
||||
const { size, title } = props;
|
||||
|
||||
let imgSrc = "";
|
||||
let alt: string = props.alt;
|
||||
|
||||
if (props.user) {
|
||||
const user = props.user;
|
||||
const nameOrUsername = user.name || user.username || "";
|
||||
alt = alt || nameOrUsername;
|
||||
|
||||
if (user.avatar) {
|
||||
imgSrc = user.avatar;
|
||||
} else if (user.emailMd5) {
|
||||
imgSrc = defaultAvatarSrc(user.emailMd5);
|
||||
}
|
||||
} else {
|
||||
imgSrc = props.imageSrc;
|
||||
}
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return imgSrc ? <img alt={alt} className={className} src={imgSrc}></img> : null;
|
||||
|
||||
const className = classNames("rounded-full", props.className, size && `h-${size} w-${size}`);
|
||||
|
||||
const avatar = imgSrc ? <img alt={alt} className={className} src={imgSrc} /> : null;
|
||||
return title ? (
|
||||
<Tooltip.Tooltip delayDuration={300}>
|
||||
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
|
||||
<Tooltip.Content className="rounded-sm bg-black p-2 text-sm text-white">
|
||||
<Tooltip.Arrow />
|
||||
{title}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Tooltip>
|
||||
) : (
|
||||
<>{avatar}</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
export default function InfoBadge({ content }: { content: string }) {
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={content}>
|
||||
<Tooltip side="top" content={content}>
|
||||
<span title={content}>
|
||||
<InformationCircleIcon className="relative top-px left-1 right-1 mt-px h-4 w-4 text-gray-500" />
|
||||
</span>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { POWERED_BY_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const PoweredByCal = () => {
|
||||
const { t } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
return (
|
||||
<div className={"p-1 text-center text-xs sm:text-right" + (isEmbed ? " max-w-3xl" : "")}>
|
||||
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
|
||||
<Link href={POWERED_BY_URL}>
|
||||
<a target="_blank" className="text-bookinglight opacity-50 hover:opacity-100 dark:text-white">
|
||||
{t("powered_by")}{" "}
|
||||
{
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { CheckIcon, ExternalLinkIcon, PencilAltIcon, StarIcon, XIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import { debounce } from "lodash";
|
||||
import { MutableRefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Input, Label } from "@calcom/ui/form/fields";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { AppRouter } from "@server/routers/_app";
|
||||
import { TRPCClientErrorLike } from "@trpc/client";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
NORMAL = "NORMAL",
|
||||
UPGRADE = "UPGRADE",
|
||||
DOWNGRADE = "DOWNGRADE",
|
||||
}
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
setCurrentUsername: (value: string | undefined) => void;
|
||||
inputUsernameValue: string | undefined;
|
||||
usernameRef: MutableRefObject<HTMLInputElement>;
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
user: Pick<
|
||||
User,
|
||||
| "username"
|
||||
| "name"
|
||||
| "email"
|
||||
| "bio"
|
||||
| "avatar"
|
||||
| "timeZone"
|
||||
| "weekStart"
|
||||
| "hideBranding"
|
||||
| "theme"
|
||||
| "plan"
|
||||
| "brandColor"
|
||||
| "darkBrandColor"
|
||||
| "metadata"
|
||||
| "timeFormat"
|
||||
| "allowDynamicBooking"
|
||||
>;
|
||||
}
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername,
|
||||
inputUsernameValue,
|
||||
setInputUsernameValue,
|
||||
usernameRef,
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
user,
|
||||
} = props;
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
const [usernameChangeCondition, setUsernameChangeCondition] = useState<UsernameChangeStatusEnum | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const userIsPremium =
|
||||
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||
const [premiumUsername, setPremiumUsername] = useState(false);
|
||||
|
||||
const debouncedApiCall = useCallback(
|
||||
debounce(async (username) => {
|
||||
const { data } = await fetchUsername(username);
|
||||
setMarkAsError(!data.available);
|
||||
setPremiumUsername(data.premium);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUsername !== inputUsernameValue) {
|
||||
debouncedApiCall(inputUsernameValue);
|
||||
} else if (inputUsernameValue === "") {
|
||||
setMarkAsError(false);
|
||||
setPremiumUsername(false);
|
||||
setUsernameIsAvailable(false);
|
||||
} else {
|
||||
setPremiumUsername(userIsPremium);
|
||||
setUsernameIsAvailable(false);
|
||||
}
|
||||
}, [inputUsernameValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (usernameIsAvailable || premiumUsername) {
|
||||
const condition = obtainNewUsernameChangeCondition({
|
||||
userIsPremium,
|
||||
isNewUsernamePremium: premiumUsername,
|
||||
});
|
||||
|
||||
setUsernameChangeCondition(condition);
|
||||
}
|
||||
}, [usernameIsAvailable, premiumUsername]);
|
||||
|
||||
const obtainNewUsernameChangeCondition = ({
|
||||
userIsPremium,
|
||||
isNewUsernamePremium,
|
||||
}: {
|
||||
userIsPremium: boolean;
|
||||
isNewUsernamePremium: boolean;
|
||||
}) => {
|
||||
let resultCondition: UsernameChangeStatusEnum;
|
||||
if (!userIsPremium && isNewUsernamePremium) {
|
||||
resultCondition = UsernameChangeStatusEnum.UPGRADE;
|
||||
} else if (userIsPremium && !isNewUsernamePremium) {
|
||||
resultCondition = UsernameChangeStatusEnum.DOWNGRADE;
|
||||
} else {
|
||||
resultCondition = UsernameChangeStatusEnum.NORMAL;
|
||||
}
|
||||
return resultCondition;
|
||||
};
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const updateUsername = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
|
||||
const ActionButtons = (props: { index: string }) => {
|
||||
const { index } = props;
|
||||
return (usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue ? (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="mx-2"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid={`update-username-btn-${index}`}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="mx-2"
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
usernameRef.current.value = currentUsername;
|
||||
}
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
const saveUsername = () => {
|
||||
if (usernameChangeCondition === UsernameChangeStatusEnum.NORMAL) {
|
||||
updateUsername.mutate({
|
||||
username: inputUsernameValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", justifyItems: "center" }}>
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
</div>
|
||||
<div className="mt-1 flex rounded-md">
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center rounded-l-sm border border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
<div style={{ position: "relative", width: "100%" }}>
|
||||
<Input
|
||||
ref={usernameRef}
|
||||
name="username"
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mt-0 rounded-l-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
)}
|
||||
defaultValue={currentUsername}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div
|
||||
className="top-0"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 2,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
}}>
|
||||
<span
|
||||
className={classNames(
|
||||
"mx-2 py-1",
|
||||
premiumUsername ? "text-orange-500" : "",
|
||||
usernameIsAvailable ? "" : ""
|
||||
)}>
|
||||
{premiumUsername ? <StarIcon className="mt-[4px] w-6" /> : <></>}
|
||||
{!premiumUsername && usernameIsAvailable ? <CheckIcon className="mt-[4px] w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xs:hidden">
|
||||
<ActionButtons index="desktop" />
|
||||
</div>
|
||||
</div>
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
{usernameIsAvailable && (
|
||||
<p className={classNames("mt-1 text-xs text-gray-900")}>
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && (
|
||||
<>{t("standard_to_premium_username_description")}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue && (
|
||||
<div className="mt-2 flex justify-end md:hidden">
|
||||
<ActionButtons index="mobile" />
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent>
|
||||
<DialogClose asChild>
|
||||
<div className="fixed top-1 right-1 flex h-8 w-8 justify-center rounded-full hover:bg-gray-200">
|
||||
<XIcon className="w-4" />
|
||||
</div>
|
||||
</DialogClose>
|
||||
<div style={{ display: "flex", flexDirection: "row" }}>
|
||||
<div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<PencilAltIcon className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="mb-4 w-full px-4 pt-1">
|
||||
<DialogHeader title="Confirm username change" />
|
||||
{usernameChangeCondition && usernameChangeCondition !== UsernameChangeStatusEnum.NORMAL && (
|
||||
<p className="-mt-4 mb-4 text-sm text-gray-800">
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE &&
|
||||
t("change_username_standard_to_premium")}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE &&
|
||||
t("change_username_premium_to_standard")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
|
||||
<div className="flex-1 px-2">
|
||||
<p className="text-gray-500">
|
||||
{t("current")} {t("username")}
|
||||
</p>
|
||||
<p className="mt-1" data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<p className="text-gray-500" data-testid="new-username">
|
||||
{t("new")} {t("username")}
|
||||
</p>
|
||||
<p>{inputUsernameValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
{/* redirect to checkout */}
|
||||
{(usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE ||
|
||||
usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE) && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
data-testid="go-to-billing"
|
||||
href={`/api/integrations/stripepayment/subscription?intentUsername=${inputUsernameValue}`}>
|
||||
<>
|
||||
{t("go_to_stripe_billing")} <ExternalLinkIcon className="ml-1 h-4 w-4" />
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
{/* Normal save */}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.NORMAL && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
data-testid="save-username"
|
||||
onClick={() => {
|
||||
saveUsername();
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { PremiumTextfield };
|
||||
@@ -0,0 +1,216 @@
|
||||
import { CheckIcon, PencilAltIcon, XIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import { debounce } from "lodash";
|
||||
import { MutableRefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Input, Label } from "@calcom/ui/form/fields";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { AppRouter } from "@server/routers/_app";
|
||||
import { TRPCClientErrorLike } from "@trpc/client";
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
setCurrentUsername: (value: string | undefined) => void;
|
||||
inputUsernameValue: string | undefined;
|
||||
usernameRef: MutableRefObject<HTMLInputElement>;
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
}
|
||||
|
||||
const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername,
|
||||
inputUsernameValue,
|
||||
setInputUsernameValue,
|
||||
usernameRef,
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
} = props;
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
|
||||
const debouncedApiCall = useCallback(
|
||||
debounce(async (username) => {
|
||||
const { data } = await fetchUsername(username);
|
||||
setMarkAsError(!data.available);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUsername !== inputUsernameValue) {
|
||||
debouncedApiCall(inputUsernameValue);
|
||||
} else if (inputUsernameValue === "") {
|
||||
setMarkAsError(false);
|
||||
setUsernameIsAvailable(false);
|
||||
} else {
|
||||
setUsernameIsAvailable(false);
|
||||
}
|
||||
}, [inputUsernameValue]);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const updateUsername = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
|
||||
const ActionButtons = (props: { index: string }) => {
|
||||
const { index } = props;
|
||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="mx-2"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid={`update-username-btn-${index}`}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="mx-2"
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
usernameRef.current.value = currentUsername;
|
||||
}
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
</div>
|
||||
<div className="mt-1 flex rounded-md">
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center rounded-l-sm border border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
ref={usernameRef}
|
||||
name="username"
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mt-0 rounded-l-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
)}
|
||||
defaultValue={currentUsername}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-0 flex flex-row">
|
||||
<span className={classNames("mx-2 py-1")}>
|
||||
{usernameIsAvailable ? <CheckIcon className="mt-[4px] w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xs:hidden">
|
||||
<ActionButtons index="desktop" />
|
||||
</div>
|
||||
</div>
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
|
||||
<div className="mt-2 flex justify-end md:hidden">
|
||||
<ActionButtons index="mobile" />
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent>
|
||||
<DialogClose asChild>
|
||||
<div className="fixed top-1 right-1 flex h-8 w-8 justify-center rounded-full hover:bg-gray-200">
|
||||
<XIcon className="w-4" />
|
||||
</div>
|
||||
</DialogClose>
|
||||
<div style={{ display: "flex", flexDirection: "row" }}>
|
||||
<div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<PencilAltIcon className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="mb-4 w-full px-4 pt-1">
|
||||
<DialogHeader title={t("confirm_username_change_dialog_title")} />
|
||||
|
||||
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
|
||||
<div className="flex-1 px-2">
|
||||
<p className="text-gray-500">
|
||||
{t("current")} {t("username").toLocaleLowerCase()}
|
||||
</p>
|
||||
<p className="mt-1" data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-500" data-testid="new-username">
|
||||
{t("new")} {t("username").toLocaleLowerCase()}
|
||||
</p>
|
||||
<p>{inputUsernameValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
data-testid="save-username"
|
||||
onClick={() => {
|
||||
updateUsername.mutate({
|
||||
username: inputUsernameValue,
|
||||
});
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { UsernameTextfield };
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
|
||||
import { PremiumTextfield } from "./PremiumTextfield";
|
||||
import { UsernameTextfield } from "./UsernameTextfield";
|
||||
|
||||
export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : PremiumTextfield;
|
||||
@@ -1,64 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface WeekdaySelectProps {
|
||||
defaultValue: number[];
|
||||
onSelect: (selected: number[]) => void;
|
||||
}
|
||||
|
||||
export const WeekdaySelect = (props: WeekdaySelectProps) => {
|
||||
const { onSelect } = props;
|
||||
const [activeDays, setActiveDays] = useState<boolean[]>(
|
||||
Array.from(Array(7).keys()).map((v, i) => (props.defaultValue || []).includes(i))
|
||||
);
|
||||
|
||||
const days = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
useEffect(() => {
|
||||
onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1));
|
||||
}, [onSelect, activeDays]);
|
||||
|
||||
const toggleDay = (idx: number) => {
|
||||
activeDays[idx] = !activeDays[idx];
|
||||
setActiveDays(([] as boolean[]).concat(activeDays));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="weekdaySelect">
|
||||
<div className="inline-flex">
|
||||
{days.map((day, idx) =>
|
||||
activeDays[idx] ? (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleDay(idx);
|
||||
}}
|
||||
className={`
|
||||
bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast
|
||||
h-10 w-10 rounded px-3 py-1 focus:outline-none
|
||||
${activeDays[idx + 1] ? "rounded-r-none" : ""}
|
||||
${activeDays[idx - 1] ? "rounded-l-none" : ""}
|
||||
${idx === 0 ? "rounded-l" : ""}
|
||||
${idx === days.length - 1 ? "rounded-r" : ""}
|
||||
`}>
|
||||
{day}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleDay(idx);
|
||||
}}
|
||||
style={{ marginTop: "1px", marginBottom: "1px" }}
|
||||
className={`h-10 w-10 rounded-none bg-gray-50 px-3 py-1 focus:outline-none ${
|
||||
idx === 0 ? "rounded-l" : "border-l-0"
|
||||
} ${idx === days.length - 1 ? "rounded-r" : ""}`}>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
|
||||
/**
|
||||
* @deprecated use `<Alert severity="error" message="x" />` instead
|
||||
*/
|
||||
export default function ErrorAlert(props: { message: string; className?: string }) {
|
||||
return <Alert severity="error" message={props.message} />;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ const ColorPicker = (props: ColorPickerProps) => {
|
||||
</div>
|
||||
)}
|
||||
<HexColorInput
|
||||
className="ml-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
className="ml-1 block w-full rounded-sm border border-gray-300 px-3 py-2 sm:text-sm"
|
||||
color={color}
|
||||
onChange={(val) => {
|
||||
setColor(val);
|
||||
|
||||
@@ -54,7 +54,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
<span className="text-sm ltr:ml-3 rtl:mr-3">{description}</span>
|
||||
</>
|
||||
)}
|
||||
{informationIconText && <InfoBadge content={informationIconText}></InfoBadge>}
|
||||
{informationIconText && <InfoBadge content={informationIconText} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user