Compare commits
329 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c95fa3de | ||
|
|
669b7798db | ||
|
|
4e01b13133 | ||
|
|
11e7779a58 | ||
|
|
7de35dc4e5 | ||
|
|
0a34512e3b | ||
|
|
9fd078c59d | ||
|
|
11269229ae | ||
|
|
6b171a6f87 | ||
|
|
6fa980f801 | ||
|
|
fd2adae10f | ||
|
|
f0ff048f4d | ||
|
|
7998b6248d | ||
|
|
6e100a6559 | ||
|
|
d4f8030b6b | ||
|
|
bf659c0b16 | ||
|
|
16fba702fb | ||
|
|
87b2ecec26 | ||
|
|
990b106c8b | ||
|
|
aaa48372b1 | ||
|
|
7c6dd7a73b | ||
|
|
8664d217c9 | ||
|
|
559ccb8ca7 | ||
|
|
43fa4f6497 | ||
|
|
55a7cc928f | ||
|
|
4ce9f9110b | ||
|
|
528c620aa7 | ||
|
|
df687009bd | ||
|
|
9befd4abb9 | ||
|
|
a404ca847c | ||
|
|
0ef6d8b452 | ||
|
|
b49553c960 | ||
|
|
86cb961fa8 | ||
|
|
0e5a7cdd55 | ||
|
|
823abfb3da | ||
|
|
debef8119e | ||
|
|
773e9ac57e | ||
|
|
0e9c50a58d | ||
|
|
401016772e | ||
|
|
3c5a84b7b4 | ||
|
|
ac3569b78e | ||
|
|
0ee523643d | ||
|
|
b82f2be391 | ||
|
|
ae09e220d0 | ||
|
|
aafbe626fc | ||
|
|
a002b194da | ||
|
|
d147772d91 | ||
|
|
8e669cf21e | ||
|
|
1a361283ed | ||
|
|
d4b70162e8 | ||
|
|
6318972aa1 | ||
|
|
256305eb6b | ||
|
|
265c634db9 | ||
|
|
5a25e6daee | ||
|
|
68e062c100 | ||
|
|
15e3b28a23 | ||
|
|
d7be47ed10 | ||
|
|
c1fb56b4b7 | ||
|
|
8a87cb4f3b | ||
|
|
6464cee5a1 | ||
|
|
8cb42c44b7 | ||
|
|
4f75b94d88 | ||
|
|
ad46fc121d | ||
|
|
863fc2e5cc | ||
|
|
b7435b5b93 | ||
|
|
307856f8e6 | ||
|
|
6635363521 | ||
|
|
88f10e0586 | ||
|
|
9475c5836d | ||
|
|
a83717363f | ||
|
|
317e3d2ade | ||
|
|
a0c2e57891 | ||
|
|
1790aeb577 | ||
|
|
78523f7a57 | ||
|
|
cc25a772a1 | ||
|
|
5291dade42 | ||
|
|
ed4587b3af | ||
|
|
eefb829f75 | ||
|
|
2feed85a1a | ||
|
|
a5eb3fce28 | ||
|
|
91fca7477d | ||
|
|
98829d23d3 | ||
|
|
dddb494071 | ||
|
|
41382caa6c | ||
|
|
265b76083a | ||
|
|
94f3ae1c64 | ||
|
|
5af159cf4e | ||
|
|
f91de82daf | ||
|
|
e38086b8fe | ||
|
|
eabb096e14 | ||
|
|
605586ea1b | ||
|
|
b6b307605b | ||
|
|
9879f9910a | ||
|
|
22682aa54d | ||
|
|
baba307a9f | ||
|
|
9842aaaf6a | ||
|
|
9efa429294 | ||
|
|
a9df3b9ad0 | ||
|
|
8d6fec79d3 | ||
|
|
356d470e16 | ||
|
|
1043b31cc7 | ||
|
|
69a54d10df | ||
|
|
1649d41dd5 | ||
|
|
d780e39241 | ||
|
|
3eaf48fac2 | ||
|
|
3f1066e1c3 | ||
|
|
c42dce2fdb | ||
|
|
d6a5d1f3da | ||
|
|
a6eed6ffcc | ||
|
|
c28d800aa9 | ||
|
|
b8e8319b23 | ||
|
|
d63a180bb2 | ||
|
|
85d7122e43 | ||
|
|
bd99a06765 | ||
|
|
9e16007c05 | ||
|
|
02c62c18ef | ||
|
|
40377215f6 | ||
|
|
45ecf0c49c | ||
|
|
ade88700fc | ||
|
|
362e8114a0 | ||
|
|
a9b6b5f066 | ||
|
|
06822a2c57 | ||
|
|
1447251c83 | ||
|
|
687af03cc3 | ||
|
|
9e69029943 | ||
|
|
86d292838c | ||
|
|
b753d9e5e3 | ||
|
|
d8dac426eb | ||
|
|
12f6065d84 | ||
|
|
415d5fe8bd | ||
|
|
4d5b5663c0 | ||
|
|
656d58b94f | ||
|
|
c146231b31 | ||
|
|
f08a2271fe | ||
|
|
22c4d29db5 | ||
|
|
a67813ee77 | ||
|
|
a32e002fd7 | ||
|
|
3641d5e46e | ||
|
|
c01004b470 | ||
|
|
78182db99c | ||
|
|
5ffee8646e | ||
|
|
ce8e9c126b | ||
|
|
b5e176a87e | ||
|
|
c4e2b6b458 | ||
|
|
e1f4ba06d8 | ||
|
|
12f72e0283 | ||
|
|
59e25ad04e | ||
|
|
c2c37b701e | ||
|
|
2ce2bb1ca8 | ||
|
|
b74dbdca9f | ||
|
|
60db5823c7 | ||
|
|
f955ccdef9 | ||
|
|
55d77993af | ||
|
|
f1eae5fe77 | ||
|
|
a711670a70 | ||
|
|
f4d2f3b3d1 | ||
|
|
109cc51e7a | ||
|
|
1783dae121 | ||
|
|
3b844583c9 | ||
|
|
a71d97a4ad | ||
|
|
0861d7cc61 | ||
|
|
26f20e2397 | ||
|
|
e615347790 | ||
|
|
cf92deb145 | ||
|
|
dc4331ed17 | ||
|
|
5c4f4bfd90 | ||
|
|
ec6b897191 | ||
|
|
9e2f8de313 | ||
|
|
bee41b242b | ||
|
|
4ce4b141b4 | ||
|
|
f9e315d10a | ||
|
|
0871f5edf8 | ||
|
|
4b05c56a0d | ||
|
|
64a01d33ba | ||
|
|
9539d26ac7 | ||
|
|
c94231f777 | ||
|
|
cfd70172f0 | ||
|
|
7dd6fdde7a | ||
|
|
6f204ca521 | ||
|
|
392c8e8da4 | ||
|
|
2fd25acc3c | ||
|
|
a73187d46b | ||
|
|
c3dc18643e | ||
|
|
7dc4a55319 | ||
|
|
f27b0b3cad | ||
|
|
683713e73f | ||
|
|
69c808333e | ||
|
|
ff5b2b1668 | ||
|
|
6f219fc98c | ||
|
|
82f11b5121 | ||
|
|
8cbf880af6 | ||
|
|
7488f29dc9 | ||
|
|
0927b86831 | ||
|
|
9ef4815ffb | ||
|
|
bcf20914d3 | ||
|
|
fdd4bd2e14 | ||
|
|
e83980a999 | ||
|
|
b794469c05 | ||
|
|
014b74be8c | ||
|
|
f03a2c2a1a | ||
|
|
35dd3f088c | ||
|
|
015b7c18af | ||
|
|
2c9b301b77 | ||
|
|
33a683d4b0 | ||
|
|
32567c8e80 | ||
|
|
adee3fd211 | ||
|
|
58de920951 | ||
|
|
30f97117e8 | ||
|
|
a9ee2ef9ae | ||
|
|
c62e1a0eeb | ||
|
|
effbd44f48 | ||
|
|
521be467a4 | ||
|
|
95b49a5995 | ||
|
|
4474e9dd74 | ||
|
|
d272f32ee3 | ||
|
|
5254297944 | ||
|
|
d97da42950 | ||
|
|
6868474c92 | ||
|
|
4c07faefe7 | ||
|
|
785058558c | ||
|
|
abe4f38a5e | ||
|
|
f70d92df7e | ||
|
|
89e5da15df | ||
|
|
1662c9cf91 | ||
|
|
33273b18d3 | ||
|
|
eb93e778bd | ||
|
|
342ea3e5d2 | ||
|
|
e12c879242 | ||
|
|
6547ef1e86 | ||
|
|
4879479981 | ||
|
|
5bed09218a | ||
|
|
08f83dd85c | ||
|
|
3d3e99272a | ||
|
|
860db6c959 | ||
|
|
30163f0a78 | ||
|
|
60298f6eeb | ||
|
|
e33962686e | ||
|
|
c80992aa1c | ||
|
|
8ac56fbf4a | ||
|
|
378cf25521 | ||
|
|
dc6841e761 | ||
|
|
033c4835f7 | ||
|
|
a04336ba06 | ||
|
|
5318047794 | ||
|
|
079a920c2c | ||
|
|
a30381f229 | ||
|
|
e7314257c3 | ||
|
|
4a07c27da5 | ||
|
|
d7df292296 | ||
|
|
3014df61cc | ||
|
|
8f6689cfc1 | ||
|
|
7a8ad8381e | ||
|
|
f8a4f81991 | ||
|
|
e684824c79 | ||
|
|
3b71c86b1e | ||
|
|
230c82e316 | ||
|
|
b8c4dfb9e1 | ||
|
|
a4fbe7b2b4 | ||
|
|
0372289fe6 | ||
|
|
5478c135d1 | ||
|
|
a3abdac33b | ||
|
|
7779c098dc | ||
|
|
dd9f801872 | ||
|
|
f23e4f2b9d | ||
|
|
dcea723ea4 | ||
|
|
58dde562a3 | ||
|
|
9f2cfffce4 | ||
|
|
dc7b084bdf | ||
|
|
78c78a6981 | ||
|
|
35c450d5ef | ||
|
|
649e79bdc7 | ||
|
|
34300650e4 | ||
|
|
0938f6f4b2 | ||
|
|
c22beb698c | ||
|
|
7ab49acebe | ||
|
|
b23c032a4c | ||
|
|
22b050b9e7 | ||
|
|
6d2f89fc32 | ||
|
|
ee5cc32936 | ||
|
|
b125ece57b | ||
|
|
97cb7bdc83 | ||
|
|
515c548acd | ||
|
|
727793af02 | ||
|
|
9e7cb2c0b8 | ||
|
|
889b3f36ae | ||
|
|
50367c236a | ||
|
|
63930c1817 | ||
|
|
6a4d6c7eba | ||
|
|
a8ec6e7060 | ||
|
|
f709972f86 | ||
|
|
bb1da8150f | ||
|
|
c152e43b82 | ||
|
|
521bb4069a | ||
|
|
2b2fde179a | ||
|
|
420daec147 | ||
|
|
f5a7ed2e36 | ||
|
|
d3d6778c60 | ||
|
|
8ad685653c | ||
|
|
9e785c01fd | ||
|
|
ad3a06384f | ||
|
|
b741559dbc | ||
|
|
235e74440c | ||
|
|
6dfd3f4aba | ||
|
|
2c50781084 | ||
|
|
bcacc1d166 | ||
|
|
790ed3e6b1 | ||
|
|
7574c322c4 | ||
|
|
168db02e1f | ||
|
|
cb4a1e031e | ||
|
|
4d2e556d7d | ||
|
|
2bc4678ef0 | ||
|
|
bb3362f2ef | ||
|
|
82e7e51fca | ||
|
|
3764a9d462 | ||
|
|
4f964533cf | ||
|
|
51752bd2bd | ||
|
|
d194878bb2 | ||
|
|
3add84a279 | ||
|
|
43563bc8d5 | ||
|
|
81a3d82ce7 | ||
|
|
0b74ef35d2 | ||
|
|
a280739f01 | ||
|
|
e1f1386332 | ||
|
|
1c2998fc13 | ||
|
|
8eb3a31af4 | ||
|
|
a047177e72 | ||
|
|
d4f29464f2 | ||
|
|
48bc4c64f4 | ||
|
|
3c089af58a |
28
.env.example
28
.env.example
@@ -1,8 +1,21 @@
|
||||
# Set this value to 'agree' to accept our license:
|
||||
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
|
||||
#
|
||||
# Summary of terms:
|
||||
# - The codebase has to stay open source, whether it was modified or not
|
||||
# - You can not repackage or sell the codebase
|
||||
# - Acquire a commercial license to remove these terms by emailing: license@cal.com
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
|
||||
# DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
|
||||
|
||||
GOOGLE_API_CREDENTIALS='secret'
|
||||
|
||||
BASE_URL='http://localhost:3000'
|
||||
NEXT_PUBLIC_APP_URL='http://localhost:3000'
|
||||
|
||||
JWT_SECRET='secret'
|
||||
|
||||
# @see: https://github.com/calendso/calendso/issues/263
|
||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
||||
@@ -19,6 +32,10 @@ MS_GRAPH_CLIENT_SECRET=
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
|
||||
#Used for the Daily integration
|
||||
DAILY_API_KEY=
|
||||
DAILY_SCALE_PLAN=''
|
||||
|
||||
# E-mail settings
|
||||
|
||||
# Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to
|
||||
@@ -37,6 +54,17 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
# ApiKey for cronjobs
|
||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||
|
||||
# Stripe Config
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
STRIPE_CLIENT_ID= # ca_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
CALENDSO_ENCRYPTION_KEY=
|
||||
|
||||
# Intercom Config
|
||||
NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
|
||||
1
.eslintignore
Normal file
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -25,9 +25,12 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["cypress/**/*.js"],
|
||||
"files": ["playwright/**/*.{js,jsx,tsx,ts}"],
|
||||
"rules": {
|
||||
"no-undef": "off"
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-implicit-any": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,18 +1,19 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report any issues with the platform
|
||||
title: ''
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
Found a bug? Please fill out the sections below. 👍
|
||||
|
||||
### Issue Summary
|
||||
A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
|
||||
A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
@@ -20,6 +21,7 @@ A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
||||
|
||||
### Technical details
|
||||
* Browser version: You can use https://www.whatsmybrowser.org/ to find this out.
|
||||
* Node.js version
|
||||
* Anything else that you think could be an issue.
|
||||
|
||||
- Browser version: You can use https://www.whatsmybrowser.org/ to find this out.
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/feature_request.md
vendored
13
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,36 +1,43 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a feature or idea
|
||||
title: ''
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> Please check if your Feature Request has not been already raised in the [Discussions Tab](https://github.com/calendso/calendso/discussions), as we would like to reduce duplicates. If it has been already raised, simply upvote it 🔼.
|
||||
|
||||
### Is your proposal related to a problem?
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what the problem is.
|
||||
For example, "I'm always frustrated when..."
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Describe the solution you'd like
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what you want to happen.
|
||||
-->
|
||||
|
||||
(Describe your proposed solution here.)
|
||||
|
||||
### Describe alternatives you've considered
|
||||
|
||||
<!--
|
||||
Let us know about other solutions you've tried or researched.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Additional context
|
||||
|
||||
<!--
|
||||
Is there anything else you can add about the proposal?
|
||||
You might want to link to related issues here, if you haven't already.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
@@ -1,9 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
commit-message:
|
||||
prefix: "⬆️"
|
||||
open-pull-requests-limit: 1
|
||||
39
.github/workflows/build.yml
vendored
39
.github/workflows/build.yml
vendored
@@ -4,6 +4,19 @@ jobs:
|
||||
build:
|
||||
name: Build on Node ${{ matrix.node }} and ${{ matrix.os }}
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NODE_ENV: test
|
||||
BASE_URL: http://localhost:3000
|
||||
JWT_SECRET: secret
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -28,5 +41,31 @@ jobs:
|
||||
path: ${{ github.workspace }}/.next/cache
|
||||
key: ${{ runner.os }}-nextjs
|
||||
|
||||
- run: yarn prisma migrate deploy
|
||||
- run: yarn test
|
||||
- run: yarn build
|
||||
|
||||
types:
|
||||
name: Check types
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install deps
|
||||
uses: bahmutov/npm-install@v1
|
||||
|
||||
- run: yarn check-changed-files
|
||||
|
||||
23
.github/workflows/cron-bookingReminder.yml
vendored
Normal file
23
.github/workflows/cron-bookingReminder.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Cron - bookingReminder
|
||||
|
||||
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:
|
||||
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/bookingReminder \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
23
.github/workflows/cron-downgradeUsers.yml
vendored
Normal file
23
.github/workflows/cron-downgradeUsers.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Cron - downgradeUsers
|
||||
|
||||
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:
|
||||
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/downgradeUsers \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
25
.github/workflows/crowdin.yml
vendored
Normal file
25
.github/workflows/crowdin.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Crowdin Action
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@1.4.0
|
||||
with:
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
28
.github/workflows/e2e.yml
vendored
28
.github/workflows/e2e.yml
vendored
@@ -7,8 +7,9 @@ jobs:
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NODE_ENV: test
|
||||
BASE_URL: http://localhost:3000
|
||||
JWT_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: "{}"
|
||||
# GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
# CRON_API_KEY: xxx
|
||||
# CALENDSO_ENCRYPTION_KEY: xxx
|
||||
@@ -50,14 +51,29 @@ jobs:
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ github.workspace }}/.next/cache
|
||||
key: ${{ runner.os }}-nextjs
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-nextjs
|
||||
|
||||
- run: yarn build
|
||||
- run: yarn test
|
||||
- run: yarn prisma migrate deploy
|
||||
- run: yarn db-seed
|
||||
- run: yarn build
|
||||
- run: yarn start &
|
||||
- run: npx wait-port 3000 --timeout 10000
|
||||
- run: yarn cypress run
|
||||
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v2
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/ms-playwright
|
||||
~/.cache/ms-playwright
|
||||
**/node_modules/playwright
|
||||
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Install playwright deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: yarn playwright install-deps
|
||||
|
||||
- run: yarn test-playwright
|
||||
|
||||
- name: Upload videos
|
||||
if: ${{ always() }}
|
||||
@@ -65,5 +81,5 @@ jobs:
|
||||
with:
|
||||
name: videos
|
||||
path: |
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
playwright/screenshots
|
||||
playwright/videos
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,6 +11,9 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
.nyc_output
|
||||
playwright/videos
|
||||
playwright/screenshots
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -51,6 +54,3 @@ yarn-error.log*
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
|
||||
@@ -4,4 +4,5 @@ version = 1
|
||||
autoupdate_label = "♻️ autoupdate"
|
||||
|
||||
[approve]
|
||||
auto_approve_usernames = ["dependabot"]
|
||||
auto_approve_usernames = ["dependabot", "PeerRich", "baileypumfleet"]
|
||||
|
||||
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
# used in tandem with package.json engine to only enable yarn
|
||||
engine-strict=true
|
||||
@@ -3,4 +3,13 @@ node_modules
|
||||
public
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
**/**/public
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
|
||||
.gitignore
|
||||
.npmignore
|
||||
.prettierignore
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
|
||||
@@ -7,4 +7,6 @@ module.exports = {
|
||||
semi: true,
|
||||
printWidth: 110,
|
||||
arrowParens: "always",
|
||||
importOrder: ["^@ee/(.*)$", "^@lib/(.*)$", "^@components/(.*)$", "^@(server|trpc)/(.*)$", "^[./]"],
|
||||
importOrderSeparation: true,
|
||||
};
|
||||
|
||||
1
.vercelignore
Normal file
1
.vercelignore
Normal file
@@ -0,0 +1 @@
|
||||
.github
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -4,6 +4,7 @@
|
||||
"esbenp.prettier-vscode", // prettier plugin
|
||||
"dbaeumer.vscode-eslint", // eslint plugin
|
||||
"bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind
|
||||
"heybourn.headwind" // automatically sort tailwind classes in predictable order, kinda like "prettier for tailwind"
|
||||
"heybourn.headwind", // automatically sort tailwind classes in predictable order, kinda like "prettier for tailwind",
|
||||
"stripe.vscode-stripe" // stripe VSCode extension
|
||||
]
|
||||
}
|
||||
|
||||
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "pwa-chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome against localhost",
|
||||
"url": "http://localhost:8080",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -5,5 +5,9 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"eslint.run": "onSave"
|
||||
"eslint.run": "onSave",
|
||||
"workbench.colorCustomizations": {
|
||||
"titleBar.activeBackground": "#292929",
|
||||
"titleBar.inactiveBackground": "#888888"
|
||||
}
|
||||
}
|
||||
|
||||
38
README.md
38
README.md
@@ -13,7 +13,7 @@
|
||||
<a href="https://cal.com"><strong>Learn more »</strong></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://join.slack.com/t/calendso/shared_invite/zt-mem978vn-RgOEELhA5bcnoGONxDCiHw">Slack</a>
|
||||
<a href="https://cal.com/slack">Slack</a>
|
||||
·
|
||||
<a href="https://cal.com">Website</a>
|
||||
·
|
||||
@@ -26,17 +26,18 @@
|
||||
<a href="https://www.producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://github.com/calendso/calendso/stargazers"><img src="https://img.shields.io/github/stars/calendso/calendso" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
||||
<img src="https://img.shields.io/github/license/calendso/calendso" alt="License">
|
||||
<a href="https://github.com/calendso/calendso/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||
<img src="https://img.shields.io/github/package-json/v/calendso/calendso">
|
||||
<a href="https://github.com/calendso/calendso/pulse"><img src="https://img.shields.io/github/commit-activity/m/calendso/calendso" alt="Commits-per-month"></a>
|
||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
|
||||
<a href="https://cal.crowdin.com/Cal"><img src="https://badges.crowdin.net/e/5a55420475b48696779e30e0208a1899/localized.svg" alt="Translate Slack"></a>
|
||||
</p>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
|
||||
## About The Project
|
||||
|
||||
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/133429837-69ac8554-4c9c-43f9-90dd-c3337002d8ff.png">
|
||||
<img width="100%" alt="booking-screen" src="https://user-images.githubusercontent.com/8019099/134363898-4b29e18f-3e61-42b7-95bc-10891056249d.gif">
|
||||
|
||||
# Scheduling infrastructure for absolutely everyone.
|
||||
|
||||
@@ -82,8 +83,9 @@ Here is what you need to be able to run Cal.
|
||||
|
||||
You will also need Google API credentials. You can get this from the [Google API Console](https://console.cloud.google.com/apis/dashboard). More details on this can be found below under the [Obtaining the Google API Credentials section](#Obtaining-the-Google-API-Credentials).
|
||||
|
||||
### Development Setup
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
#### Quick start with `yarn dx`
|
||||
|
||||
> - **Requires Docker to be installed**
|
||||
@@ -143,7 +145,7 @@ yarn dx
|
||||
|
||||
5. Set up the database using the Prisma schema (found in `prisma/schema.prisma`)
|
||||
```sh
|
||||
npx prisma db push
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
6. Run (in development mode)
|
||||
```sh
|
||||
@@ -158,6 +160,15 @@ yarn dx
|
||||
10. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
||||
11. Set a 32 character random string in your .env file for the CALENDSO_ENCRYPTION_KEY.
|
||||
|
||||
### E2E-Testing
|
||||
|
||||
```bash
|
||||
# In first terminal
|
||||
yarn dx
|
||||
# In second terminal
|
||||
yarn test-playwright
|
||||
```
|
||||
|
||||
### Upgrading from earlier versions
|
||||
|
||||
1. Pull the current version:
|
||||
@@ -206,8 +217,11 @@ yarn dx
|
||||
### Docker
|
||||
|
||||
The Docker configuration for Cal is an effort powered by people within the community. Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
||||
|
||||
If you want to contribute to the Docker repository, [reply here](https://github.com/calendso/docker/discussions/32).
|
||||
|
||||
The Docker configuration can be found [in our docker repository](https://github.com/calendso/docker).
|
||||
|
||||
|
||||
### Railway
|
||||
|
||||
@@ -240,7 +254,7 @@ Contributions are what make the open source community such an amazing place to b
|
||||
2. In the search box, type calendar and select the Google Calendar API search result.
|
||||
3. Enable the selected API.
|
||||
4. Next, go to the [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) from the side pane. Select the app type (Internal or External) and enter the basic app details on the first page.
|
||||
5. In the second page on Scopes, select Add or Remove Scopes. Search for Calendar.event and select the scope with scope value `.../auth/calendar.events`, `.../auth/calendar.readonly`, `.../auth/calendar` and select Update.
|
||||
5. In the second page on Scopes, select Add or Remove Scopes. Search for Calendar.event and select the scope with scope value `.../auth/calendar.events`, `.../auth/calendar.readonly` and select Update.
|
||||
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.
|
||||
@@ -268,16 +282,24 @@ Contributions are what make the open source community such an amazing place to b
|
||||
7. Click "Create".
|
||||
8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
10. Also add the redirect URL given above as a whitelist URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
10. Also add the redirect URL given above as a allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
||||
12. Click "Done".
|
||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||
|
||||
## Obtaining Daily API Credentials
|
||||
|
||||
1. Open [Daily](https://www.daily.co/) and sign into your account.
|
||||
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
3. Copy your API key.
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
5. If you have a [Daily Scale Plan](https://www.daily.co/pricing) can also enable the ability to record Daily video meetings. To do so, set the `DAILY_SCALE_PLAN` environment variable to `'true'`
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the MIT License. See `LICENSE` for more information.
|
||||
Distributed under the AGPLv3 License. See `LICENSE` for more information.
|
||||
|
||||
<!-- ACKNOWLEDGEMENTS -->
|
||||
|
||||
|
||||
165
calendso.yaml
165
calendso.yaml
@@ -7,20 +7,20 @@ info:
|
||||
email: support@cal.com
|
||||
license:
|
||||
name: MIT License
|
||||
url: 'https://opensource.org/licenses/MIT'
|
||||
url: "https://opensource.org/licenses/MIT"
|
||||
version: 1.0.0
|
||||
termsOfService: 'https://cal.com/terms'
|
||||
termsOfService: "https://cal.com/terms"
|
||||
server:
|
||||
url: 'http://localhost:{port}'
|
||||
url: "http://localhost:{port}"
|
||||
description: Local Development Server
|
||||
variables:
|
||||
port:
|
||||
default: '3000'
|
||||
default: "3000"
|
||||
tags:
|
||||
- name: Authentication
|
||||
description: 'Auth routes, powered by Next-Auth.js'
|
||||
description: "Auth routes, powered by Next-Auth.js"
|
||||
externalDocs:
|
||||
url: 'http://next-auth.js.org/'
|
||||
url: "http://next-auth.js.org/"
|
||||
- name: Availability
|
||||
description: Checking and setting user availability
|
||||
- name: Booking
|
||||
@@ -38,15 +38,15 @@ paths:
|
||||
summary: Displays the sign in page
|
||||
tags:
|
||||
- Authentication
|
||||
'/api/auth/signin/:provider':
|
||||
"/api/auth/signin/:provider":
|
||||
post:
|
||||
description: Starts an OAuth signin flow for the specified provider. The POST submission requires CSRF token from /api/auth/csrf.
|
||||
summary: Starts an OAuth signin flow for the specified provider
|
||||
tags:
|
||||
- Authentication
|
||||
'/api/auth/callback/:provider':
|
||||
"/api/auth/callback/:provider":
|
||||
get:
|
||||
description: 'Handles returning requests from OAuth services during sign in. For OAuth 2.0 providers that support the state option, the value of the state parameter is checked against the one that was generated when the sign in flow was started - this uses a hash of the CSRF token which MUST match for both the POST and GET calls during sign in.'
|
||||
description: "Handles returning requests from OAuth services during sign in. For OAuth 2.0 providers that support the state option, the value of the state parameter is checked against the one that was generated when the sign in flow was started - this uses a hash of the CSRF token which MUST match for both the POST and GET calls during sign in."
|
||||
summary: Handles returning requests from OAuth services
|
||||
tags:
|
||||
- Authentication
|
||||
@@ -103,26 +103,26 @@ paths:
|
||||
summary: Reset a user's password
|
||||
tags:
|
||||
- Authentication
|
||||
'/api/availability/{user}?dateFrom={dateFrom}&dateTo={dateTo}':
|
||||
"/api/availability/{user}?dateFrom={dateFrom}&dateTo={dateTo}":
|
||||
get:
|
||||
description: 'Gets the busy times for a particular user, by username.'
|
||||
description: "Gets the busy times for a particular user, by username."
|
||||
summary: Gets the busy times for a user
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
description: ''
|
||||
description: ""
|
||||
minItems: 1
|
||||
uniqueItems: true
|
||||
x-examples:
|
||||
example-1:
|
||||
- start: 'Fri, 03 Sep 2021 17:00:00 GMT'
|
||||
end: 'Fri, 03 Sep 2021 17:40:00 GMT'
|
||||
- start: "Fri, 03 Sep 2021 17:00:00 GMT"
|
||||
end: "Fri, 03 Sep 2021 17:40:00 GMT"
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
@@ -135,7 +135,7 @@ paths:
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
parameters:
|
||||
- schema:
|
||||
@@ -163,13 +163,13 @@ paths:
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
description: ''
|
||||
description: ""
|
||||
minItems: 1
|
||||
uniqueItems: true
|
||||
items:
|
||||
@@ -221,7 +221,7 @@ paths:
|
||||
externalId: c_feunmui1m8el5o1oo885fu48k8@group.calendar.google.com
|
||||
integration: google_calendar
|
||||
name: 1.0 Launch
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
post:
|
||||
description: Adds a selected calendar for the user.
|
||||
@@ -229,7 +229,7 @@ paths:
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -238,7 +238,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
requestBody:
|
||||
content:
|
||||
@@ -256,7 +256,7 @@ paths:
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -265,7 +265,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
requestBody:
|
||||
content:
|
||||
@@ -284,7 +284,7 @@ paths:
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -305,7 +305,7 @@ paths:
|
||||
type: string
|
||||
bufferMins:
|
||||
type: string
|
||||
description: ''
|
||||
description: ""
|
||||
/api/availability/eventtype:
|
||||
post:
|
||||
description: Adds a new event type for the user.
|
||||
@@ -339,7 +339,7 @@ paths:
|
||||
type: array
|
||||
items: {}
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -369,7 +369,7 @@ paths:
|
||||
customInputs:
|
||||
type: array
|
||||
items: {}
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
patch:
|
||||
description: Updates an event type for the user.
|
||||
@@ -403,7 +403,7 @@ paths:
|
||||
type: array
|
||||
items: {}
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -433,7 +433,7 @@ paths:
|
||||
customInputs:
|
||||
type: array
|
||||
items: {}
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
delete:
|
||||
description: Deletes an event type for the user.
|
||||
@@ -441,16 +441,16 @@ paths:
|
||||
tags:
|
||||
- Availability
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties: {}
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
'/api/book/{user}':
|
||||
"/api/book/event":
|
||||
post:
|
||||
description: Creates a booking in the user's calendar.
|
||||
summary: Creates a booking for a user
|
||||
@@ -480,12 +480,19 @@ paths:
|
||||
guests:
|
||||
type: array
|
||||
items: {}
|
||||
users:
|
||||
type: array
|
||||
items: {}
|
||||
user:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
location:
|
||||
type: string
|
||||
paymentUid:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
"204":
|
||||
description: No Content
|
||||
content:
|
||||
application/json:
|
||||
@@ -494,7 +501,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
@@ -528,7 +535,7 @@ paths:
|
||||
confirmed:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
"204":
|
||||
description: No Content
|
||||
content:
|
||||
application/json:
|
||||
@@ -537,7 +544,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
/api/integrations:
|
||||
get:
|
||||
@@ -546,12 +553,12 @@ paths:
|
||||
tags:
|
||||
- Integrations
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
description: ''
|
||||
description: ""
|
||||
type: object
|
||||
x-examples:
|
||||
example-1:
|
||||
@@ -562,7 +569,7 @@ paths:
|
||||
id: 83
|
||||
type: google_calendar
|
||||
key:
|
||||
scope: 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events'
|
||||
scope: "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
|
||||
token_type: Bearer
|
||||
expiry_date: 1630838974808
|
||||
access_token: ya29.a0ARrdaM89R686rUyBBluTuD69oQ6WIIjjMa2xjJ0qe_5u-9ShDL09KNN1mCYoks3NP54FUMzYKmqTzb8nzCJX9jlNKP7X7-gukO4--HUyfOUbFHlHbfQ2Ei05F8AQn_xS0E_awhDgyn2anvrvEw72U3_65Zi4v6Y
|
||||
@@ -660,7 +667,7 @@ paths:
|
||||
- description
|
||||
required:
|
||||
- pageProps
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
@@ -683,7 +690,7 @@ paths:
|
||||
id:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -692,7 +699,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'401':
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
@@ -701,7 +708,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
@@ -773,7 +780,7 @@ paths:
|
||||
theme:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -782,7 +789,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'401':
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
@@ -791,7 +798,41 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
/api/me:
|
||||
get:
|
||||
description: Gets current user's profile.
|
||||
summary: Gets current user's profile.
|
||||
tags:
|
||||
- User
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
@@ -812,7 +853,7 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -821,7 +862,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'401':
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
@@ -830,7 +871,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
@@ -865,7 +906,7 @@ paths:
|
||||
properties:
|
||||
teamId:
|
||||
type: string
|
||||
'/api/{team}':
|
||||
"/api/{team}":
|
||||
delete:
|
||||
description: Deletes a team
|
||||
summary: Deletes a team
|
||||
@@ -873,9 +914,9 @@ paths:
|
||||
- Teams
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
"204":
|
||||
description: No Content
|
||||
'401':
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
@@ -884,7 +925,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
@@ -900,7 +941,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
description: The team which you wish to modify
|
||||
'/api/{team}/invite':
|
||||
"/api/{team}/invite":
|
||||
post:
|
||||
description: Invites someone to a team.
|
||||
summary: Invites someone to a team
|
||||
@@ -926,7 +967,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
description: The team which you wish to send the invite for
|
||||
'/api/{team}/membership':
|
||||
"/api/{team}/membership":
|
||||
get:
|
||||
description: Lists the members of a team.
|
||||
summary: Lists members of a team
|
||||
@@ -934,7 +975,7 @@ paths:
|
||||
- Teams
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
@@ -944,7 +985,7 @@ paths:
|
||||
members:
|
||||
type: array
|
||||
items: {}
|
||||
'401':
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
@@ -953,7 +994,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
@@ -976,14 +1017,14 @@ paths:
|
||||
userId:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties: {}
|
||||
'401':
|
||||
"401":
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
@@ -992,7 +1033,7 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'500':
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
@@ -1009,7 +1050,7 @@ paths:
|
||||
required: true
|
||||
description: The team which you wish to list members of
|
||||
servers:
|
||||
- url: 'https://app.cal.com'
|
||||
- url: "https://app.cal.com"
|
||||
description: Production
|
||||
components:
|
||||
securitySchemes: {}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import React, { Children } from "react";
|
||||
|
||||
const ActiveLink = ({ children, activeClassName, ...props }) => {
|
||||
const { asPath } = useRouter();
|
||||
const child = Children.only(children);
|
||||
const childClassName = child.props.className || "";
|
||||
|
||||
const className =
|
||||
asPath === props.href || asPath === props.as
|
||||
? `${childClassName} ${activeClassName}`.trim()
|
||||
: childClassName;
|
||||
|
||||
return <Link {...props}>{React.cloneElement(child, { className })}</Link>;
|
||||
};
|
||||
|
||||
ActiveLink.defaultProps = {
|
||||
activeClassName: "active",
|
||||
} as Partial<Props>;
|
||||
|
||||
export default ActiveLink;
|
||||
51
components/AddToHomescreen.tsx
Normal file
51
components/AddToHomescreen.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { XIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
export default function AddToHomescreen() {
|
||||
const { t } = useLocale();
|
||||
const [closeBanner, setCloseBanner] = useState(false);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return !closeBanner ? (
|
||||
<div className="fixed sm:hidden bottom-0 inset-x-0 pb-2 sm:pb-5">
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
|
||||
<div className="p-2 rounded-lg shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
|
||||
<div className="flex items-center justify-between flex-wrap">
|
||||
<div className="w-0 flex-1 flex items-center">
|
||||
<span className="flex p-2 rounded-lg bg-opacity-30 bg-brand">
|
||||
<svg
|
||||
className="h-7 w-7 text-indigo-500 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 50 50"
|
||||
enableBackground="new 0 0 50 50">
|
||||
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z" />
|
||||
<path d="M24 7h2v21h-2z" />
|
||||
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="ml-3 text-xs font-medium text-white">
|
||||
<span className="inline">{t("add_to_homescreen")}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="order-2 flex-shrink-0 sm:order-3 sm:ml-2">
|
||||
<button
|
||||
onClick={() => setCloseBanner(true)}
|
||||
type="button"
|
||||
className="-mr-1 flex p-2 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
||||
<span className="sr-only">{t("dismiss")}</span>
|
||||
<XIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
30
components/BookingsShell.tsx
Normal file
30
components/BookingsShell.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
export default function BookingsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
const tabs = [
|
||||
{
|
||||
name: t("upcoming"),
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: t("past"),
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: t("cancelled"),
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavTabs tabs={tabs} linkProps={{ shallow: true }} />
|
||||
<main>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
components/ClientSuspense.tsx
Normal file
9
components/ClientSuspense.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Suspense, SuspenseProps } from "react";
|
||||
|
||||
/**
|
||||
* Wrapper around `<Suspense />` which will render the `fallback` when on server
|
||||
* Can be simply replaced by `<Suspense />` once React 18 is ready.
|
||||
*/
|
||||
export const ClientSuspense = (props: SuspenseProps) => {
|
||||
return <>{typeof window !== "undefined" ? <Suspense {...props} /> : props.fallback}</>;
|
||||
};
|
||||
10
components/CustomBranding.tsx
Normal file
10
components/CustomBranding.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
const BrandColor = ({ val = "#292929" }: { val: string | undefined | null }) => {
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty("--brand-color", val);
|
||||
}, [val]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default BrandColor;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
|
||||
export type DialogProps = React.ComponentProps<typeof DialogPrimitive["Root"]>;
|
||||
export function Dialog(props: DialogProps) {
|
||||
const { children, ...other } = props;
|
||||
return (
|
||||
@@ -25,19 +25,25 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
|
||||
);
|
||||
|
||||
type DialogHeaderProps = {
|
||||
title: React.ReactElement | string;
|
||||
subtitle: React.ReactElement | string;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function DialogHeader({ title, subtitle }: DialogHeaderProps) {
|
||||
export function DialogHeader(props: DialogHeaderProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-gray-900 text-lg font-bold leading-6" id="modal-title">
|
||||
{title}
|
||||
<h3 className="font-cal text-gray-900 text-lg font-bold leading-6" id="modal-title">
|
||||
{props.title}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">{subtitle}</p>
|
||||
</div>
|
||||
{props.subtitle && <div className="text-gray-400 text-sm">{props.subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogFooter(props: { children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-5 flex space-x-2 justify-end">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { GiftIcon } from "@heroicons/react/outline";
|
||||
export default function DonateBanner() {
|
||||
if (location.hostname.endsWith(".cal.com")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-12" />
|
||||
<div className="fixed inset-x-0 bottom-0">
|
||||
<div className="bg-blue-600">
|
||||
<div className="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between flex-wrap">
|
||||
<div className="w-0 flex-1 flex items-center">
|
||||
<span className="flex p-2 rounded-lg bg-blue-600">
|
||||
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="ml-3 font-medium text-white truncate">
|
||||
<span className="md:hidden">Support the ongoing development</span>
|
||||
<span className="hidden md:inline">
|
||||
You're using the free self-hosted version. Support the ongoing development by making
|
||||
a donation.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://cal.com/donate"
|
||||
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50">
|
||||
Donate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
components/EmptyScreen.tsx
Normal file
27
components/EmptyScreen.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
export default function EmptyScreen({
|
||||
Icon,
|
||||
headline,
|
||||
description,
|
||||
}: {
|
||||
Icon: SVGComponent;
|
||||
headline: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-80 border border-dashed rounded-sm flex justify-center items-center flex-col my-6">
|
||||
<div className="bg-white w-[72px] h-[72px] flex justify-center items-center rounded-full">
|
||||
<Icon className="inline-block w-10 h-10 bg-white" />
|
||||
</div>
|
||||
<div className="max-w-[420px] text-center">
|
||||
<h2 className="text-lg font-medium mt-6 mb-1">{headline}</h2>
|
||||
<p className="text-sm leading-6 text-gray-600">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
components/I18nLanguageHandler.tsx
Normal file
28
components/I18nLanguageHandler.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
export function useViewerI18n() {
|
||||
return trpc.useQuery(["viewer.i18n"], {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-switches locale client-side to the logged in user's preference
|
||||
*/
|
||||
const I18nLanguageHandler = (): null => {
|
||||
const { i18n } = useTranslation("common");
|
||||
const locale = useViewerI18n().data?.locale;
|
||||
|
||||
useEffect(() => {
|
||||
if (locale && i18n.language && i18n.language !== locale) {
|
||||
if (typeof i18n.changeLanguage === "function") i18n.changeLanguage(locale);
|
||||
}
|
||||
}, [locale, i18n]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default I18nLanguageHandler;
|
||||
@@ -1,215 +1,164 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import Slider from "./Slider";
|
||||
|
||||
export default function ImageUploader({ target, id, buttonMsg, handleAvatarChange, imageRef }) {
|
||||
const imageFileRef = useRef<HTMLInputElement>();
|
||||
const [imageDataUrl, setImageDataUrl] = useState<string>();
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState();
|
||||
const [rotation] = useState(1);
|
||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { DialogClose, DialogTrigger, Dialog, DialogContent } from "@components/Dialog";
|
||||
import Slider from "@components/Slider";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
type ImageUploaderProps = {
|
||||
id: string;
|
||||
buttonMsg: string;
|
||||
handleAvatarChange: (imageSrc: string) => void;
|
||||
imageSrc?: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
interface FileEvent<T = Element> extends FormEvent<T> {
|
||||
target: EventTarget & T;
|
||||
}
|
||||
|
||||
// This is separate to prevent loading the component until file upload
|
||||
function CropContainer({
|
||||
onCropComplete,
|
||||
imageSrc,
|
||||
}: {
|
||||
imageSrc: string;
|
||||
onCropComplete: (croppedAreaPixels: Area) => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [isImageShown, setIsImageShown] = useState(false);
|
||||
const [shownImage, setShownImage] = useState<string>();
|
||||
const [imageUploadModalOpen, setImageUploadModalOpen] = useState(false);
|
||||
|
||||
const openUploaderModal = () => {
|
||||
imageRef ? (setIsImageShown(true), setShownImage(imageRef)) : setIsImageShown(false);
|
||||
setImageUploadModalOpen(!imageUploadModalOpen);
|
||||
};
|
||||
|
||||
const closeImageUploadModal = () => {
|
||||
setImageUploadModalOpen(false);
|
||||
};
|
||||
|
||||
async function ImageUploadHandler() {
|
||||
const img = await readFile(imageFileRef.current.files[0]);
|
||||
setImageDataUrl(img);
|
||||
CropHandler();
|
||||
}
|
||||
|
||||
const readFile = (file) => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(reader.result), false);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels);
|
||||
}, []);
|
||||
|
||||
const CropHandler = () => {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setImageLoaded(true);
|
||||
};
|
||||
|
||||
const handleZoomSliderChange = ([value]) => {
|
||||
const handleZoomSliderChange = (value: number) => {
|
||||
value < 1 ? setZoom(1) : setZoom(value);
|
||||
};
|
||||
|
||||
const createImage = (url) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener("load", () => resolve(image));
|
||||
image.addEventListener("error", (error) => reject(error));
|
||||
image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
function getRadianAngle(degreeValue) {
|
||||
return (degreeValue * Math.PI) / 180;
|
||||
}
|
||||
|
||||
async function getCroppedImg(imageSrc, pixelCrop, rotation = 0) {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const maxSize = Math.max(image.width, image.height);
|
||||
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
|
||||
|
||||
// set each dimensions to double largest dimension to allow for a safe area for the
|
||||
// image to rotate in without being clipped by canvas context
|
||||
canvas.width = safeArea;
|
||||
canvas.height = safeArea;
|
||||
|
||||
// translate canvas context to a central location on image to allow rotating around the center.
|
||||
ctx.translate(safeArea / 2, safeArea / 2);
|
||||
ctx.rotate(getRadianAngle(rotation));
|
||||
ctx.translate(-safeArea / 2, -safeArea / 2);
|
||||
|
||||
// draw rotated image and store data.
|
||||
ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
|
||||
const data = ctx.getImageData(0, 0, safeArea, safeArea);
|
||||
|
||||
// set canvas width to final desired crop size - this will clear existing context
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
|
||||
// paste generated rotate image with correct offsets for x,y crop values.
|
||||
ctx.putImageData(
|
||||
data,
|
||||
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
|
||||
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
|
||||
);
|
||||
|
||||
// As Base64 string
|
||||
return canvas.toDataURL("image/jpeg");
|
||||
}
|
||||
|
||||
const showCroppedImage = useCallback(async () => {
|
||||
try {
|
||||
const croppedImage = await getCroppedImg(imageDataUrl, croppedAreaPixels, rotation);
|
||||
setIsImageShown(true);
|
||||
setShownImage(croppedImage);
|
||||
setImageLoaded(false);
|
||||
handleAvatarChange(croppedImage);
|
||||
closeImageUploadModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [croppedAreaPixels, rotation]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-4 cursor-pointer inline-flex items-center px-4 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;"
|
||||
onClick={openUploaderModal}>
|
||||
{buttonMsg}
|
||||
</button>
|
||||
|
||||
{imageUploadModalOpen && (
|
||||
<div
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start mb-4">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
|
||||
Upload {target}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="cropper mt-6 flex flex-col justify-center items-center p-8 bg-gray-50">
|
||||
{!imageLoaded && (
|
||||
<div className="flex justify-start items-center bg-gray-500 max-h-20 h-20 w-20 rounded-full">
|
||||
{!isImageShown && (
|
||||
<p className="sm:text-xs text-sm text-white w-full text-center">No {target}</p>
|
||||
)}
|
||||
{isImageShown && (
|
||||
<img className="h-20 w-20 rounded-full" src={shownImage} alt={target} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{imageLoaded && (
|
||||
<div className="crop-container max-h-40 h-40 w-40 rounded-full">
|
||||
<div className="relative h-40 w-40 rounded-full">
|
||||
<Cropper
|
||||
image={imageDataUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1 / 1}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
label="Slide to zoom, drag to reposition"
|
||||
changeHandler={handleZoomSliderChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="mt-8 cursor-pointer inline-flex items-center px-4 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;">
|
||||
Choose a file...
|
||||
</label>
|
||||
<input
|
||||
onChange={ImageUploadHandler}
|
||||
ref={imageFileRef}
|
||||
type="file"
|
||||
id={id}
|
||||
name={id}
|
||||
placeholder="Upload image"
|
||||
className="mt-4 pointer-events-none opacity-0 absolute"
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="button" className="btn btn-primary" onClick={showCroppedImage}>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={closeImageUploadModal} type="button" className="btn btn-white mr-2">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-40 h-40 rounded-full crop-container max-h-40">
|
||||
<div className="relative w-40 h-40 rounded-full">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={(croppedArea, croppedAreaPixels) => onCropComplete(croppedAreaPixels)}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
label={t("slide_zoom_drag_instructions")}
|
||||
changeHandler={handleZoomSliderChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImageUploader({
|
||||
target,
|
||||
id,
|
||||
buttonMsg,
|
||||
handleAvatarChange,
|
||||
...props
|
||||
}: ImageUploaderProps) {
|
||||
const { t } = useLocale();
|
||||
const [imageSrc, setImageSrc] = useState<string | null>();
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>();
|
||||
|
||||
const [{ result }, setFile] = useFileReader({
|
||||
method: "readAsDataURL",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setImageSrc(props.imageSrc);
|
||||
}, [props.imageSrc]);
|
||||
|
||||
const onInputFile = (e: FileEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) {
|
||||
return;
|
||||
}
|
||||
setFile(e.target.files[0]);
|
||||
};
|
||||
|
||||
const showCroppedImage = useCallback(
|
||||
async (croppedAreaPixels) => {
|
||||
try {
|
||||
const croppedImage = await getCroppedImg(
|
||||
result as string /* result is always string when using readAsDataUrl */,
|
||||
croppedAreaPixels
|
||||
);
|
||||
setImageSrc(croppedImage);
|
||||
handleAvatarChange(croppedImage);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[result, handleAvatarChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={
|
||||
(opened) => !opened && setFile(null) // unset file on close
|
||||
}>
|
||||
<DialogTrigger asChild>
|
||||
<div className="flex items-center px-3">
|
||||
<Button color="secondary" type="button" className="py-1 text-xs">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900 font-cal" id="modal-title">
|
||||
{t("upload_target", { target })}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-col items-center justify-center p-8 mt-6 cropper bg-gray-50">
|
||||
{!result && (
|
||||
<div className="flex items-center justify-start w-20 h-20 bg-gray-500 rounded-full max-h-20">
|
||||
{!imageSrc && (
|
||||
<p className="w-full text-sm text-center text-white sm:text-xs">
|
||||
{t("no_target", { target })}
|
||||
</p>
|
||||
)}
|
||||
{imageSrc && <img className="w-20 h-20 rounded-full" src={imageSrc} alt={target} />}
|
||||
</div>
|
||||
)}
|
||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||
<label className="px-3 py-1 mt-8 text-xs font-medium leading-4 text-gray-700 bg-white border border-gray-300 rounded-sm hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900 dark:bg-transparent dark:text-white dark:border-gray-800 dark:hover:bg-gray-900">
|
||||
<input
|
||||
onInput={onInputFile}
|
||||
type="file"
|
||||
name={id}
|
||||
placeholder={t("upload_image")}
|
||||
className="absolute mt-4 opacity-0 pointer-events-none"
|
||||
accept="image/*"
|
||||
/>
|
||||
{t("choose_a_file")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-x-2">
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => showCroppedImage(croppedAreaPixels)}>{t("save")}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
72
components/List.tsx
Normal file
72
components/List.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Link from "next/link";
|
||||
import { createElement } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export function List(props: JSX.IntrinsicElements["ul"]) {
|
||||
return (
|
||||
<ul {...props} className={classNames("sm:overflow-hidden rounded-sm sm:mx-0 -mx-4", props.className)}>
|
||||
{props.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export type ListItemProps = { expanded?: boolean } & ({ href?: never } & JSX.IntrinsicElements["li"]);
|
||||
|
||||
export function ListItem(props: ListItemProps) {
|
||||
const { href, expanded, ...passThroughProps } = props;
|
||||
|
||||
const elementType = href ? "a" : "li";
|
||||
|
||||
const element = createElement(
|
||||
elementType,
|
||||
{
|
||||
...passThroughProps,
|
||||
className: classNames(
|
||||
"items-center bg-white min-w-0 flex-1 flex border-gray-200",
|
||||
expanded ? "my-2 border" : "border -mb-px last:mb-0",
|
||||
props.className,
|
||||
(props.onClick || href) && "hover:bg-neutral-50"
|
||||
),
|
||||
},
|
||||
props.children
|
||||
);
|
||||
|
||||
return href ? (
|
||||
<Link passHref href={href}>
|
||||
{element}
|
||||
</Link>
|
||||
) : (
|
||||
element
|
||||
);
|
||||
}
|
||||
|
||||
export function ListItemTitle<TComponent extends keyof JSX.IntrinsicElements = "span">(
|
||||
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
|
||||
) {
|
||||
const { component = "span", ...passThroughProps } = props;
|
||||
|
||||
return createElement(
|
||||
component,
|
||||
{
|
||||
...passThroughProps,
|
||||
className: classNames("text-sm font-medium text-neutral-900 truncate", props.className),
|
||||
},
|
||||
props.children
|
||||
);
|
||||
}
|
||||
|
||||
export function ListItemText<TComponent extends keyof JSX.IntrinsicElements = "span">(
|
||||
props: JSX.IntrinsicElements[TComponent] & { component?: TComponent }
|
||||
) {
|
||||
const { component = "span", ...passThroughProps } = props;
|
||||
|
||||
return createElement(
|
||||
component,
|
||||
{
|
||||
...passThroughProps,
|
||||
className: classNames("text-sm text-gray-500 truncate", props.className),
|
||||
},
|
||||
props.children
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export default function Loader() {
|
||||
return (
|
||||
<div className="loader border-black dark:border-white">
|
||||
<span className="loader-inner bg-black dark:bg-white"></span>
|
||||
<div className="loader border-brand dark:border-white">
|
||||
<span className="loader-inner bg-brand dark:bg-white"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/* legacy and soon deprecated, please refactor to use <Dialog> only */
|
||||
import { Fragment, ReactNode } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { CheckIcon, InformationCircleIcon } from "@heroicons/react/outline";
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export default function Modal(props: {
|
||||
heading: ReactNode;
|
||||
description: ReactNode;
|
||||
handleClose: () => void;
|
||||
open: boolean;
|
||||
variant?: "success" | "warning";
|
||||
}) {
|
||||
const { variant = "success" } = props;
|
||||
return (
|
||||
<Transition.Root show={props.open} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
open={props.open}
|
||||
onClose={props.handleClose}>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||
<div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mx-auto flex items-center justify-center h-12 w-12 rounded-full",
|
||||
variant === "success" && "bg-green-100",
|
||||
variant === "warning" && "bg-yellow-100"
|
||||
)}>
|
||||
{variant === "success" && (
|
||||
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
|
||||
)}
|
||||
{variant === "warning" && (
|
||||
<InformationCircleIcon className={"h-6 w-6 text-yellow-400"} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
||||
{props.heading}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">{props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<button type="button" className="btn-wide btn-primary" onClick={() => props.handleClose()}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
53
components/NavTabs.tsx
Normal file
53
components/NavTabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ElementType, FC } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
interface Props {
|
||||
tabs: {
|
||||
name: string;
|
||||
href: string;
|
||||
icon?: ElementType;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
}
|
||||
|
||||
const NavTabs: FC<Props> = ({ tabs, linkProps }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<nav className="-mb-px flex space-x-2 sm:space-x-5" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const isCurrent = router.asPath === tab.href;
|
||||
return (
|
||||
<Link {...linkProps} key={tab.name} href={tab.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
"group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm"
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 mr-2 h-5 w-5 hidden sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<hr />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavTabs;
|
||||
@@ -1,68 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { CodeIcon, CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export default function SettingsShell(props) {
|
||||
const router = useRouter();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: "Profile",
|
||||
href: "/settings/profile",
|
||||
icon: UserIcon,
|
||||
current: router.pathname == "/settings/profile",
|
||||
},
|
||||
{
|
||||
name: "Security",
|
||||
href: "/settings/security",
|
||||
icon: KeyIcon,
|
||||
current: router.pathname == "/settings/security",
|
||||
},
|
||||
{ name: "Embed", href: "/settings/embed", icon: CodeIcon, current: router.pathname == "/settings/embed" },
|
||||
{
|
||||
name: "Teams",
|
||||
href: "/settings/teams",
|
||||
icon: UserGroupIcon,
|
||||
current: router.pathname == "/settings/teams",
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
current: router.pathname == "/settings/billing",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:mx-auto">
|
||||
<nav className="-mb-px flex space-x-2 sm:space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<Link key={tab.name} href={tab.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
tab.current
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
|
||||
"group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm"
|
||||
)}
|
||||
aria-current={tab.current ? "page" : undefined}>
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
tab.current ? "text-neutral-900" : "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 mr-2 h-5 w-5 hidden sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<hr />
|
||||
</div>
|
||||
<main className="max-w-4xl">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
components/SettingsShell.tsx
Normal file
42
components/SettingsShell.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
export default function SettingsShell({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: t("profile"),
|
||||
href: "/settings/profile",
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
name: t("security"),
|
||||
href: "/settings/security",
|
||||
icon: KeyIcon,
|
||||
},
|
||||
{
|
||||
name: t("teams"),
|
||||
href: "/settings/teams",
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
name: t("billing"),
|
||||
href: "/settings/billing",
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sm:mx-auto">
|
||||
<NavTabs tabs={tabs} />
|
||||
</div>
|
||||
<main className="max-w-4xl">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { signOut, useSession } from "next-auth/client";
|
||||
// TODO: replace headlessui with radix-ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChatAltIcon,
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExternalLinkIcon,
|
||||
@@ -16,69 +8,184 @@ import {
|
||||
LogoutIcon,
|
||||
PuzzleIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import Logo from "./Logo";
|
||||
import classNames from "@lib/classNames";
|
||||
import { signOut, useSession } from "next-auth/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { User } from "@prisma/client";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
|
||||
export default function Shell(props) {
|
||||
const router = useRouter();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import LicenseBanner from "@ee/components/LicenseBanner";
|
||||
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import Loader from "@components/Loader";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/ui/Dropdown";
|
||||
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
import Logo from "./Logo";
|
||||
|
||||
function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
function useRedirectToLoginIfUnauthenticated() {
|
||||
const [session, loading] = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !session) {
|
||||
router.replace({
|
||||
pathname: "/auth/login",
|
||||
query: {
|
||||
callbackUrl: `${location.pathname}${location.search}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, session]);
|
||||
|
||||
return {
|
||||
loading: loading && !session,
|
||||
};
|
||||
}
|
||||
|
||||
function useRedirectToOnboardingIfNeeded() {
|
||||
const router = useRouter();
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
const [isRedirectingToOnboarding, setRedirecting] = useState(false);
|
||||
useEffect(() => {
|
||||
if (user && shouldShowOnboarding(user)) {
|
||||
setRedirecting(true);
|
||||
}
|
||||
}, [router, user]);
|
||||
useEffect(() => {
|
||||
if (isRedirectingToOnboarding) {
|
||||
router.replace({
|
||||
pathname: "/getting-started",
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRedirectingToOnboarding]);
|
||||
return {
|
||||
isRedirectingToOnboarding,
|
||||
};
|
||||
}
|
||||
|
||||
export function ShellSubHeading(props: {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("block sm:flex justify-between mb-3", props.className)}>
|
||||
<div>
|
||||
<h2 className="flex items-center content-center space-x-2 text-base font-bold leading-6 text-gray-900">
|
||||
{props.title}
|
||||
</h2>
|
||||
{props.subtitle && <p className="mr-4 text-sm text-neutral-500">{props.subtitle}</p>}
|
||||
</div>
|
||||
{props.actions && <div className="flex-shrink-0 mb-4">{props.actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Shell(props: {
|
||||
centered?: boolean;
|
||||
title?: string;
|
||||
heading: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
CTA?: ReactNode;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { loading } = useRedirectToLoginIfUnauthenticated();
|
||||
const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded();
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: "Event Types",
|
||||
name: t("event_types_page_title"),
|
||||
href: "/event-types",
|
||||
icon: LinkIcon,
|
||||
current: router.pathname.startsWith("/event-types"),
|
||||
current: router.asPath.startsWith("/event-types"),
|
||||
},
|
||||
{
|
||||
name: "Bookings",
|
||||
href: "/bookings",
|
||||
name: t("bookings"),
|
||||
href: "/bookings/upcoming",
|
||||
icon: ClockIcon,
|
||||
current: router.pathname.startsWith("/bookings"),
|
||||
current: router.asPath.startsWith("/bookings"),
|
||||
},
|
||||
{
|
||||
name: "Availability",
|
||||
name: t("availability"),
|
||||
href: "/availability",
|
||||
icon: CalendarIcon,
|
||||
current: router.pathname.startsWith("/availability"),
|
||||
current: router.asPath.startsWith("/availability"),
|
||||
},
|
||||
{
|
||||
name: "Integrations",
|
||||
name: t("integrations"),
|
||||
href: "/integrations",
|
||||
icon: PuzzleIcon,
|
||||
current: router.pathname.startsWith("/integrations"),
|
||||
current: router.asPath.startsWith("/integrations"),
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
name: t("settings"),
|
||||
href: "/settings/profile",
|
||||
icon: CogIcon,
|
||||
current: router.pathname.startsWith("/settings"),
|
||||
current: router.asPath.startsWith("/settings"),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => {
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname));
|
||||
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.asPath));
|
||||
});
|
||||
}, [telemetry]);
|
||||
|
||||
if (!loading && !session) {
|
||||
router.replace("/auth/login");
|
||||
}
|
||||
}, [telemetry, router.asPath]);
|
||||
|
||||
const pageTitle = typeof props.heading === "string" ? props.heading : props.title;
|
||||
|
||||
return session ? (
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
const i18n = useViewerI18n();
|
||||
|
||||
if (i18n.status === "loading" || isRedirectingToOnboarding || loading) {
|
||||
// show spinner whilst i18n is loading to avoid language flicker
|
||||
return (
|
||||
<div className="absolute z-50 flex items-center w-full h-screen bg-gray-50">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CustomBranding val={user?.brandColor} />
|
||||
<HeadSeo
|
||||
title={pageTitle}
|
||||
description={props.subtitle}
|
||||
title={pageTitle ?? "Cal.com"}
|
||||
description={props.subtitle ? props.subtitle?.toString() : ""}
|
||||
nextSeoProps={{
|
||||
nofollow: true,
|
||||
noindex: true,
|
||||
@@ -88,19 +195,17 @@ export default function Shell(props) {
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
|
||||
<div className="h-screen flex overflow-hidden bg-gray-100">
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="flex h-screen overflow-hidden bg-gray-100">
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex flex-col w-56">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex flex-col flex-1 h-0 bg-white border-r border-gray-200">
|
||||
<div className="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
|
||||
<Link href="/event-types">
|
||||
<a className="px-4">
|
||||
<Logo small />
|
||||
</a>
|
||||
</Link>
|
||||
<nav className="mt-5 flex-1 px-2 bg-white space-y-1">
|
||||
<nav className="flex-1 px-2 mt-5 space-y-1 bg-white">
|
||||
{navigation.map((item) => (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
@@ -125,51 +230,50 @@ export default function Shell(props) {
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex p-4">
|
||||
<div className="p-2 pt-2 pr-2 m-2 rounded-sm hover:bg-gray-100">
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-0 flex-1 overflow-hidden">
|
||||
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none">
|
||||
<div className="flex flex-col flex-1 w-0 overflow-hidden">
|
||||
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]">
|
||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="md:hidden bg-white shadow p-4 flex justify-between items-center">
|
||||
<nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 md:hidden">
|
||||
<Link href="/event-types">
|
||||
<a>
|
||||
<Logo />
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex gap-3 items-center self-center">
|
||||
<button className="bg-white p-2 rounded-full text-gray-400 hover:text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
<span className="sr-only">View notifications</span>
|
||||
<div className="flex items-center self-center gap-3">
|
||||
<button className="p-2 text-gray-400 bg-white rounded-full hover:text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
<span className="sr-only">{t("view_notifications")}</span>
|
||||
<Link href="/settings/profile">
|
||||
<a>
|
||||
<CogIcon className="h-6 w-6" aria-hidden="true" />
|
||||
<CogIcon className="w-6 h-6" aria-hidden="true" />
|
||||
</a>
|
||||
</Link>
|
||||
</button>
|
||||
<div className="mt-1">
|
||||
<UserDropdown small bottom session={session} />
|
||||
</div>
|
||||
<UserDropdown small />
|
||||
</div>
|
||||
</nav>
|
||||
<div className="py-8">
|
||||
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-bold text-gray-900">{props.heading}</h1>
|
||||
<p className="text-sm text-neutral-500 mr-4">{props.subtitle}</p>
|
||||
<div className={classNames(props.centered && "md:max-w-5xl mx-auto", "py-8")}>
|
||||
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8 min-h-[80px]">
|
||||
<div className="w-full mb-8">
|
||||
<h1 className="mb-1 text-xl font-bold tracking-wide text-gray-900 font-cal">
|
||||
{props.heading}
|
||||
</h1>
|
||||
<p className="mr-4 text-sm text-neutral-500">{props.subtitle}</p>
|
||||
</div>
|
||||
<div className="mb-4 flex-shrink-0">{props.CTA}</div>
|
||||
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
|
||||
</div>
|
||||
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
|
||||
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="bottom-nav md:hidden flex fixed bottom-0 bg-white w-full shadow">
|
||||
<nav className="fixed bottom-0 flex w-full bg-white shadow bottom-nav md:hidden">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
{navigation.flatMap((item, itemIdx) =>
|
||||
item.name === "Settings" ? (
|
||||
item.href === "/settings/profile" ? (
|
||||
[]
|
||||
) : (
|
||||
<Link key={item.name} href={item.href}>
|
||||
@@ -188,172 +292,113 @@ export default function Shell(props) {
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item.name}</span>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* add padding to content for mobile navigation*/}
|
||||
<div className="block md:hidden pt-12" />
|
||||
<div className="block pt-12 md:hidden" />
|
||||
</div>
|
||||
<LicenseBanner />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/me")
|
||||
.then((res) => res.json())
|
||||
.then((responseBody) => {
|
||||
setUser(responseBody.user);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="w-full relative inline-block text-left">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
{user && (
|
||||
<Menu.Button className="group w-full rounded-md text-sm text-left font-medium text-gray-700 focus:outline-none">
|
||||
<span className="flex w-full justify-between items-center">
|
||||
<span className="flex min-w-0 items-center justify-between space-x-3">
|
||||
<Avatar
|
||||
imageSrc={user?.avatar}
|
||||
displayName={user?.name}
|
||||
className={classNames(
|
||||
small ? "w-8 h-8" : "w-10 h-10",
|
||||
"bg-gray-300 rounded-full flex-shrink-0"
|
||||
)}
|
||||
/>
|
||||
{!small && (
|
||||
<span className="flex-1 flex flex-col min-w-0">
|
||||
<span className="text-gray-900 text-sm font-medium truncate">{user?.name}</span>
|
||||
<span className="text-neutral-500 font-normal text-sm truncate">
|
||||
/{user?.username}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!small && (
|
||||
<SelectorIcon
|
||||
className="flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className={classNames(
|
||||
bottom ? "origin-top top-1 right-0" : "origin-bottom bottom-14 left-0",
|
||||
"w-64 z-10 absolute mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none"
|
||||
)}>
|
||||
<div className="py-1">
|
||||
<a href={"/" + user?.username} className="flex px-4 py-2 text-sm text-neutral-500">
|
||||
View public page <ExternalLinkIcon className="ml-1 mt-1 w-3 h-3 text-neutral-400" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="https://cal.com/slack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={classNames(
|
||||
active ? "bg-gray-100 text-gray-900" : "text-neutral-700",
|
||||
"flex px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<svg
|
||||
viewBox="0 0 2447.6 2452.5"
|
||||
className={classNames(
|
||||
"text-neutral-400 group-hover:text-neutral-500",
|
||||
"mt-0.5 mr-3 flex-shrink-0 h-4 w-4"
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipRule="evenodd" fillRule="evenodd">
|
||||
<path
|
||||
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
||||
fill="#9BA6B6"></path>
|
||||
<path
|
||||
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
||||
fill="#9BA6B6"></path>
|
||||
<path
|
||||
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
||||
fill="#9BA6B6"></path>
|
||||
<path
|
||||
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
||||
fill="#9BA6B6"></path>
|
||||
</g>
|
||||
</svg>
|
||||
Join our Slack
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="mailto:feedback@cal.com"
|
||||
className={classNames(
|
||||
active ? "bg-gray-100 text-gray-900" : "text-neutral-700",
|
||||
"flex px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<ChatAltIcon
|
||||
className={classNames(
|
||||
"text-neutral-400 group-hover:text-neutral-500",
|
||||
"mr-2 flex-shrink-0 h-5 w-5"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Feedback
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
onClick={() => signOut({ callbackUrl: "/auth/logout" })}
|
||||
className={classNames(
|
||||
active ? "bg-gray-100 text-gray-900" : "text-gray-700",
|
||||
"flex px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<LogoutIcon
|
||||
className={classNames(
|
||||
"text-neutral-400 group-hover:text-neutral-500",
|
||||
"mr-2 flex-shrink-0 h-5 w-5"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Sign out
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDropdown({ small }: { small?: boolean }) {
|
||||
const { t } = useLocale();
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center space-x-2 cursor-pointer group">
|
||||
<Avatar
|
||||
imageSrc={user?.avatar || ""}
|
||||
alt={user?.username || "Nameless User"}
|
||||
className={classNames(small ? "w-8 h-8" : "w-10 h-10", "bg-gray-300 rounded-full flex-shrink-0")}
|
||||
/>
|
||||
{!small && (
|
||||
<>
|
||||
<span className="flex-grow text-sm">
|
||||
<span className="block font-medium text-gray-900 truncate">
|
||||
{user?.username || "Nameless User"}
|
||||
</span>
|
||||
<span className="block font-normal truncate text-neutral-500">
|
||||
{user?.username ? `cal.com/${user.username}` : "No public page"}
|
||||
</span>
|
||||
</span>
|
||||
<SelectorIcon
|
||||
className="flex-shrink-0 w-5 h-5 text-gray-400 group-hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{user?.username && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/${user.username}`}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||
<ExternalLinkIcon className="w-5 h-5 mr-3 text-gray-500" /> {t("view_public_page")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
href="https://cal.com/slack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900">
|
||||
<svg
|
||||
viewBox="0 0 2447.6 2452.5"
|
||||
className={classNames(
|
||||
"text-gray-500 group-hover:text-gray-700",
|
||||
"mt-0.5 mr-3 flex-shrink-0 h-4 w-4"
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipRule="evenodd" fillRule="evenodd">
|
||||
<path
|
||||
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
||||
fill="#9BA6B6"></path>
|
||||
<path
|
||||
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
||||
fill="#9BA6B6"></path>
|
||||
<path
|
||||
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
||||
fill="#9BA6B6"></path>
|
||||
<path
|
||||
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
||||
fill="#9BA6B6"></path>
|
||||
</g>
|
||||
</svg>
|
||||
{t("join_our_slack")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<HelpMenuItemDynamic />
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
onClick={() => signOut({ callbackUrl: "/auth/logout" })}
|
||||
className="flex px-4 py-2 text-sm cursor-pointer hover:bg-gray-100 hover:text-gray-900">
|
||||
<LogoutIcon
|
||||
className={classNames("text-gray-500 group-hover:text-gray-700", "mr-2 flex-shrink-0 h-5 w-5")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("sign_out")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import React from "react";
|
||||
|
||||
const Slider = ({ value, min, max, step, label, changeHandler }) => (
|
||||
const Slider = ({
|
||||
value,
|
||||
label,
|
||||
changeHandler,
|
||||
...props
|
||||
}: Omit<SliderPrimitive.SliderProps, "value"> & {
|
||||
value: number;
|
||||
label: string;
|
||||
changeHandler: (value: number) => void;
|
||||
}) => (
|
||||
<SliderPrimitive.Root
|
||||
className="slider mt-2"
|
||||
min={min}
|
||||
step={step}
|
||||
max={max}
|
||||
className="mt-2 slider"
|
||||
value={[value]}
|
||||
aria-label={label}
|
||||
onValueChange={changeHandler}>
|
||||
onValueChange={(value: number[]) => changeHandler(value[0] ?? value)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="slider-track">
|
||||
<SliderPrimitive.Range className="slider-range" />
|
||||
</SliderPrimitive.Track>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import React from "react";
|
||||
|
||||
export function Tooltip({
|
||||
children,
|
||||
@@ -9,12 +9,11 @@ export function Tooltip({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: {
|
||||
[x: string]: any;
|
||||
children: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
open: boolean;
|
||||
defaultOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive.Root
|
||||
@@ -24,7 +23,7 @@ export function Tooltip({
|
||||
onOpenChange={onOpenChange}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content
|
||||
className="bg-black text-xs -mt-2 text-white px-1 py-0.5 shadow-lg rounded-sm"
|
||||
className="bg-brand text-xs -mt-2 text-white px-1 py-0.5 shadow-lg rounded-sm"
|
||||
side="top"
|
||||
align="center"
|
||||
{...props}>
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { Dayjs } from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useSlots } from "@lib/hooks/useSlots";
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
import Loader from "@components/Loader";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import React, { FC } from "react";
|
||||
|
||||
const AvailableTimes = ({
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useSlots } from "@lib/hooks/useSlots";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
type AvailableTimesProps = {
|
||||
workingHours: {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}[];
|
||||
timeFormat: string;
|
||||
minimumBookingNotice: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
date: Dayjs;
|
||||
users: {
|
||||
username: string | null;
|
||||
}[];
|
||||
schedulingType: SchedulingType | null;
|
||||
};
|
||||
|
||||
const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
date,
|
||||
eventLength,
|
||||
eventTypeId,
|
||||
@@ -16,6 +37,7 @@ const AvailableTimes = ({
|
||||
users,
|
||||
schedulingType,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
|
||||
@@ -26,66 +48,78 @@ const AvailableTimes = ({
|
||||
workingHours,
|
||||
users,
|
||||
minimumBookingNotice,
|
||||
eventTypeId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
|
||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:-mb-5">
|
||||
<div className="text-gray-600 font-light text-lg mb-4 text-left">
|
||||
<span className="w-1/2 dark:text-white text-gray-600">
|
||||
<strong>{date.format("dddd")}</strong>
|
||||
<span className="text-gray-500">{date.format(", DD MMMM")}</span>
|
||||
<strong>{t(date.format("dddd").toLowerCase())}</strong>
|
||||
<span className="text-gray-500">
|
||||
{date.format(", DD ")}
|
||||
{t(date.format("MMMM").toLowerCase())}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{!loading &&
|
||||
slots?.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
const bookingUrl = {
|
||||
pathname: "book",
|
||||
query: {
|
||||
...router.query,
|
||||
date: slot.time.format(),
|
||||
type: eventTypeId,
|
||||
},
|
||||
};
|
||||
<div className="md:max-h-[364px] overflow-y-auto">
|
||||
{!loading &&
|
||||
slots?.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
pathname: string;
|
||||
query: Record<string, string | number | string[] | undefined>;
|
||||
};
|
||||
const bookingUrl: BookingURL = {
|
||||
pathname: "book",
|
||||
query: {
|
||||
...router.query,
|
||||
date: slot.time.format(),
|
||||
type: eventTypeId,
|
||||
},
|
||||
};
|
||||
|
||||
if (rescheduleUid) {
|
||||
bookingUrl.query.rescheduleUid = rescheduleUid;
|
||||
}
|
||||
if (rescheduleUid) {
|
||||
bookingUrl.query.rescheduleUid = rescheduleUid as string;
|
||||
}
|
||||
|
||||
if (schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
bookingUrl.query.user = slot.users;
|
||||
}
|
||||
if (schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
bookingUrl.query.user = slot.users;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={slot.time.format()}>
|
||||
<Link href={bookingUrl}>
|
||||
<a className="block font-medium mb-4 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-primary-500 dark:border-transparent rounded-sm hover:text-white hover:bg-primary-500 dark:hover:border-black py-4 dark:hover:bg-black">
|
||||
{slot.time.format(timeFormat)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && !error && !slots.length && (
|
||||
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
|
||||
<h1 className="text-xl text-black dark:text-white">All booked today.</h1>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={slot.time.format()}>
|
||||
<Link href={bookingUrl}>
|
||||
<a
|
||||
className="block font-medium mb-2 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-brand dark:border-transparent rounded-sm hover:text-white hover:bg-brand dark:hover:border-black py-4 dark:hover:bg-black"
|
||||
data-testid="time">
|
||||
{slot.time.format(timeFormat)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && !error && !slots.length && (
|
||||
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
|
||||
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <Loader />}
|
||||
{loading && <Loader />}
|
||||
|
||||
{error && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-yellow-700">Could not load the available time slots.</p>
|
||||
{error && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
133
components/booking/BookingListItem.tsx
Normal file
133
components/booking/BookingListItem.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">[number];
|
||||
|
||||
function BookingListItem(booking: BookingItem) {
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const mutation = useMutation(
|
||||
async (confirm: boolean) => {
|
||||
const res = await fetch("/api/book/confirm", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ id: booking.id, confirmed: confirm, language: i18n.language }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new HttpError({ statusCode: res.status });
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.bookings"]);
|
||||
},
|
||||
}
|
||||
);
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: t("reject"),
|
||||
onClick: () => mutation.mutate(false),
|
||||
icon: BanIcon,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label: t("confirm"),
|
||||
onClick: () => mutation.mutate(true),
|
||||
icon: CheckIcon,
|
||||
disabled: mutation.isLoading,
|
||||
color: "primary",
|
||||
},
|
||||
];
|
||||
|
||||
const bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
label: t("cancel"),
|
||||
href: `/cancel/${booking.uid}`,
|
||||
icon: XIcon,
|
||||
},
|
||||
{
|
||||
id: "reschedule",
|
||||
label: t("reschedule"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
icon: ClockIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="hidden px-6 py-4 align-top sm:table-cell whitespace-nowrap">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</div>
|
||||
</td>
|
||||
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
|
||||
<div className="sm:hidden">
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t("unconfirmed")}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{startTime}:{" "}
|
||||
<small className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium leading-6 truncate text-neutral-900 max-w-52 md:max-w-96">
|
||||
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
||||
{booking.title}
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<span className="ml-2 hidden sm:inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t("unconfirmed")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div className="text-sm text-gray-500 truncate max-w-52 md:max-w-96" title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<div className="text-sm text-gray-900 hover:text-blue-500">
|
||||
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
|
||||
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
|
||||
{!booking.confirmed && booking.rejected && (
|
||||
<div className="text-sm text-gray-500">{t("rejected")}</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookingListItem;
|
||||
@@ -1,17 +1,21 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import { useEffect, useState } from "react";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
// Then, include dayjs-business-time
|
||||
import dayjsBusinessTime from "dayjs-business-time";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import getSlots from "@lib/slots";
|
||||
import dayjsBusinessDays from "dayjs-business-days";
|
||||
import classNames from "@lib/classNames";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
dayjs.extend(dayjsBusinessDays);
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import getSlots from "@lib/slots";
|
||||
|
||||
dayjs.extend(dayjsBusinessTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const DatePicker = ({
|
||||
// FIXME prop types
|
||||
function DatePicker({
|
||||
weekStart,
|
||||
onDatePicked,
|
||||
workingHours,
|
||||
@@ -24,7 +28,8 @@ const DatePicker = ({
|
||||
periodDays,
|
||||
periodCountCalendarDays,
|
||||
minimumBookingNotice,
|
||||
}) => {
|
||||
}: any): JSX.Element {
|
||||
const { t } = useLocale();
|
||||
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | null>(
|
||||
@@ -44,11 +49,11 @@ const DatePicker = ({
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth + 1);
|
||||
setSelectedMonth((selectedMonth ?? 0) + 1);
|
||||
};
|
||||
|
||||
const decrementMonth = () => {
|
||||
setSelectedMonth(selectedMonth - 1);
|
||||
setSelectedMonth((selectedMonth ?? 0) - 1);
|
||||
};
|
||||
|
||||
const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
|
||||
@@ -69,9 +74,9 @@ const DatePicker = ({
|
||||
case "rolling": {
|
||||
const periodRollingEndDay = periodCountCalendarDays
|
||||
? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day")
|
||||
: dayjs().tz(organizerTimeZone).businessDaysAdd(periodDays, "days").endOf("day");
|
||||
: dayjs().tz(organizerTimeZone).addBusinessTime(periodDays, "days").endOf("day");
|
||||
return (
|
||||
date.endOf("day").isBefore(dayjs().utcOffsett(date.utcOffset())) ||
|
||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||
date.endOf("day").isAfter(periodRollingEndDay) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
@@ -134,29 +139,35 @@ const DatePicker = ({
|
||||
}>
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4">
|
||||
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||
<strong className="text-gray-900 dark:text-white">{inviteeDate().format("MMMM")}</strong>
|
||||
<span className="text-gray-500"> {inviteeDate().format("YYYY")}</span>
|
||||
<strong className="text-gray-900 dark:text-white">
|
||||
{t(inviteeDate().format("MMMM").toLowerCase())}
|
||||
</strong>{" "}
|
||||
<span className="text-gray-500">{inviteeDate().format("YYYY")}</span>
|
||||
</span>
|
||||
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={
|
||||
"group mr-2 p-1" + (selectedMonth <= dayjs().month() && "text-gray-400 dark:text-gray-600")
|
||||
}
|
||||
disabled={selectedMonth <= dayjs().month()}>
|
||||
className={classNames(
|
||||
"group mr-2 p-1",
|
||||
typeof selectedMonth === "number" &&
|
||||
selectedMonth <= dayjs().month() &&
|
||||
"text-gray-400 dark:text-gray-600"
|
||||
)}
|
||||
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()}
|
||||
data-testid="decrementMonth">
|
||||
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
|
||||
</button>
|
||||
<button className="group p-1" onClick={incrementMonth}>
|
||||
<button className="group p-1" onClick={incrementMonth} data-testid="incrementMonth">
|
||||
<ChevronRightIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-4 text-center border-b border-t dark:border-gray-800 sm:border-0">
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
{["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||
.map((weekDay) => (
|
||||
<div key={weekDay} className="uppercase text-gray-500 text-xs tracking-widest my-4">
|
||||
{weekDay}
|
||||
{t(weekDay.toLowerCase()).substring(0, 3)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -176,16 +187,18 @@ const DatePicker = ({
|
||||
disabled={day.disabled}
|
||||
className={classNames(
|
||||
"absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto",
|
||||
"hover:border hover:border-black dark:hover:border-white",
|
||||
"hover:border hover:border-brand dark:hover:border-white",
|
||||
day.disabled
|
||||
? "text-gray-400 font-light hover:border-0 cursor-default"
|
||||
: "dark:text-white text-primary-500 font-medium",
|
||||
date && date.isSame(inviteeDate().date(day.date), "day")
|
||||
? "bg-black text-white-important"
|
||||
? "bg-brand text-white-important"
|
||||
: !day.disabled
|
||||
? " bg-gray-100 dark:bg-gray-600"
|
||||
: ""
|
||||
)}>
|
||||
)}
|
||||
data-testid="day"
|
||||
data-disabled={day.disabled}>
|
||||
{day.date}
|
||||
</button>
|
||||
)}
|
||||
@@ -194,6 +207,6 @@ const DatePicker = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default DatePicker;
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import getSlots from "../../lib/slots";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(utc);
|
||||
|
||||
type Props = {
|
||||
eventLength: number;
|
||||
minimumBookingNotice?: number;
|
||||
date: Dayjs;
|
||||
workingHours: [];
|
||||
organizerTimeZone: string;
|
||||
};
|
||||
|
||||
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerTimeZone }: Props) => {
|
||||
minimumBookingNotice = minimumBookingNotice || 0;
|
||||
|
||||
const router = useRouter();
|
||||
const { user } = router.query;
|
||||
const [slots, setSlots] = useState([]);
|
||||
const [isFullyBooked, setIsFullyBooked] = useState(false);
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSlots([]);
|
||||
setIsFullyBooked(false);
|
||||
setHasErrors(false);
|
||||
fetch(
|
||||
`/api/availability/${user}?dateFrom=${date.startOf("day").format()}&dateTo=${date
|
||||
.endOf("day")
|
||||
.format()}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then(handleAvailableSlots)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setHasErrors(true);
|
||||
});
|
||||
}, [date]);
|
||||
|
||||
const handleAvailableSlots = (busyTimes: []) => {
|
||||
const times = getSlots({
|
||||
frequency: eventLength,
|
||||
inviteeDate: date,
|
||||
workingHours,
|
||||
minimumBookingNotice,
|
||||
organizerTimeZone,
|
||||
});
|
||||
|
||||
const timesLengthBeforeConflicts: number = times.length;
|
||||
|
||||
// Check for conflicts
|
||||
for (let i = times.length - 1; i >= 0; i -= 1) {
|
||||
busyTimes.every((busyTime): boolean => {
|
||||
const startTime = dayjs(busyTime.start).utc();
|
||||
const endTime = dayjs(busyTime.end).utc();
|
||||
// Check if start times are the same
|
||||
if (times[i].utc().isSame(startTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if time is between start and end times
|
||||
else if (times[i].utc().isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if slot end time is between start and end time
|
||||
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
|
||||
times.splice(i, 1);
|
||||
}
|
||||
// Check if startTime is between slot
|
||||
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
|
||||
times.splice(i, 1);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
|
||||
setIsFullyBooked(true);
|
||||
}
|
||||
// Display available times
|
||||
setSlots(times);
|
||||
};
|
||||
|
||||
return {
|
||||
slots,
|
||||
isFullyBooked,
|
||||
hasErrors,
|
||||
};
|
||||
};
|
||||
|
||||
export default Slots;
|
||||
@@ -1,13 +1,22 @@
|
||||
// TODO: replace headlessui with radix-ui
|
||||
import { Switch } from "@headlessui/react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import { useEffect, useState } from "react";
|
||||
import { is24h, timeZone } from "../../lib/clock";
|
||||
import classNames from "@lib/classNames";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||
|
||||
const TimeOptions = (props) => {
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { is24h, timeZone } from "../../lib/clock";
|
||||
|
||||
type Props = {
|
||||
onSelectTimeZone: (selectedTimeZone: string) => void;
|
||||
onToggle24hClock: (is24hClock: boolean) => void;
|
||||
};
|
||||
|
||||
const TimeOptions: FC<Props> = (props) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [is24hClock, setIs24hClock] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
setIs24hClock(is24h());
|
||||
@@ -25,47 +34,45 @@ const TimeOptions = (props) => {
|
||||
props.onToggle24hClock(is24h(is24hClock));
|
||||
};
|
||||
|
||||
return (
|
||||
selectedTimeZone !== "" && (
|
||||
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 dark:text-white text-gray-600 font-medium">Time Options</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group as="div" className="flex items-center justify-end">
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm dark:text-white text-gray-500">am/pm</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={handle24hClockToggle}
|
||||
return selectedTimeZone !== "" ? (
|
||||
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 dark:text-white text-gray-600 font-medium">{t("time_options")}</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group as="div" className="flex items-center justify-end">
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm dark:text-white text-gray-500">{t("am_pm")}</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={handle24hClockToggle}
|
||||
className={classNames(
|
||||
is24hClock ? "bg-brand" : "dark:bg-gray-600 bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
|
||||
)}>
|
||||
<span className="sr-only">{t("use_setting")}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
is24hClock ? "bg-black" : "dark:bg-gray-600 bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
|
||||
)}>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
is24hClock ? "translate-x-3" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm dark:text-white text-gray-500">24h</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
is24hClock ? "translate-x-3" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm dark:text-white text-gray-500">{t("24_h")}</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz) => setSelectedTimeZone(tz.value)}
|
||||
className="mb-2 shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
className="mb-2 shadow-sm focus:ring-black focus:border-brand mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default TimeOptions;
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
// Get router variables
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { EventType } from "@prisma/client";
|
||||
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, CreditCardIcon, GlobeIcon } from "@heroicons/react/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
|
||||
import DatePicker from "@components/booking/DatePicker";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import PoweredByCalendso from "@components/ui/PoweredByCalendso";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||
import TimeOptions from "@components/booking/TimeOptions";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import AvailableTimes from "@components/booking/AvailableTimes";
|
||||
import DatePicker from "@components/booking/DatePicker";
|
||||
import TimeOptions from "@components/booking/TimeOptions";
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import PoweredByCal from "@components/ui/PoweredByCal";
|
||||
|
||||
import { AvailabilityPageProps } from "../../../pages/[user]/[type]";
|
||||
import { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
type AvailabilityPageProps = {
|
||||
eventType: EventType;
|
||||
profile: {
|
||||
name: string;
|
||||
image: string;
|
||||
theme?: string;
|
||||
};
|
||||
workingHours: [];
|
||||
};
|
||||
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPageProps) => {
|
||||
const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
const themeLoaded = useTheme(profile.theme);
|
||||
const { isReady } = useTheme(profile.theme);
|
||||
const { t } = useLocale();
|
||||
|
||||
const selectedDate = useMemo(() => {
|
||||
const dateString = asStringOrNull(router.query.date);
|
||||
@@ -89,20 +89,21 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
|
||||
};
|
||||
|
||||
return (
|
||||
themeLoaded && (
|
||||
<>
|
||||
<HeadSeo
|
||||
title={`${rescheduleUid ? "Reschedule" : ""} ${eventType.title} | ${profile.name}`}
|
||||
description={`${rescheduleUid ? "Reschedule" : ""} ${eventType.title}`}
|
||||
name={profile.name}
|
||||
avatar={profile.image}
|
||||
/>
|
||||
<div>
|
||||
<main
|
||||
className={
|
||||
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
}>
|
||||
<>
|
||||
<HeadSeo
|
||||
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||
name={profile.name}
|
||||
avatar={profile.image}
|
||||
/>
|
||||
<CustomBranding val={profile.brandColor} />
|
||||
<div>
|
||||
<main
|
||||
className={
|
||||
"mx-auto my-0 md:my-24 transition-max-width ease-in-out duration-500 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
}>
|
||||
{isReady && (
|
||||
<div className="bg-white border-gray-200 rounded-sm sm:dark:border-gray-600 dark:bg-gray-900 md:border">
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
@@ -125,8 +126,20 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
|
||||
{eventType.title}
|
||||
<div>
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{eventType.length} minutes
|
||||
{eventType.length} {t("minutes")}
|
||||
</div>
|
||||
{eventType.price > 0 && (
|
||||
<div>
|
||||
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,14 +164,26 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="font-medium text-gray-500 dark:text-gray-300 mt-3">{profile.name}</h2>
|
||||
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
|
||||
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
|
||||
<h1 className="mb-4 text-3xl font-semibold text-gray-800 font-cal dark:text-white">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{eventType.length} minutes
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
|
||||
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<TimezoneDropdown />
|
||||
|
||||
@@ -172,14 +197,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
|
||||
periodDays={eventType?.periodDays}
|
||||
periodCountCalendarDays={eventType?.periodCountCalendarDays}
|
||||
onDatePicked={changeDate}
|
||||
workingHours={[
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
endTime: 1440,
|
||||
startTime: 0,
|
||||
},
|
||||
]}
|
||||
weekStart="Sunday"
|
||||
workingHours={workingHours}
|
||||
weekStart={profile.weekStart || "Sunday"}
|
||||
eventLength={eventType.length}
|
||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||
/>
|
||||
@@ -202,11 +221,11 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{eventType.users.length && isBrandingHidden(eventType.users[0]) && <PoweredByCalendso />}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && <PoweredByCal />}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function TimezoneDropdown() {
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ExclamationIcon,
|
||||
LocationMarkerIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { useEffect, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import "react-phone-number-input/style.css";
|
||||
import PhoneInput from "react-phone-number-input";
|
||||
import { LocationType } from "@lib/location";
|
||||
import { Button } from "@components/ui/Button";
|
||||
import { stringify } from "querystring";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
|
||||
import { createPaymentLink } from "@ee/lib/stripe/client";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import { LocationType } from "@lib/location";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import { parseZone } from "@lib/parseZone";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { BookingCreateBody } from "@lib/types/booking";
|
||||
|
||||
const BookingPage = (props: any): JSX.Element => {
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import { Button } from "@components/ui/Button";
|
||||
import PhoneInput from "@components/ui/form/PhoneInput";
|
||||
|
||||
import { BookPageProps } from "../../../pages/[user]/book";
|
||||
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||
|
||||
const BookingPage = (props: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
const themeLoaded = useTheme(props.profile.theme);
|
||||
const { isReady } = useTheme(props.profile.theme);
|
||||
|
||||
const date = asStringOrNull(router.query.date);
|
||||
const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma";
|
||||
@@ -48,13 +69,14 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
|
||||
// TODO: Move to translations
|
||||
const locationLabels = {
|
||||
[LocationType.InPerson]: "Link or In-person meeting",
|
||||
[LocationType.Phone]: "Phone call",
|
||||
[LocationType.InPerson]: t("in_person_meeting"),
|
||||
[LocationType.Phone]: t("phone_call"),
|
||||
[LocationType.GoogleMeet]: "Google Meet",
|
||||
[LocationType.Zoom]: "Zoom Video",
|
||||
[LocationType.Daily]: "Daily.co Video",
|
||||
};
|
||||
|
||||
const bookingHandler = (event) => {
|
||||
const _bookingHandler = (event) => {
|
||||
const book = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
@@ -65,7 +87,7 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
const data = event.target["custom_" + input.id];
|
||||
if (data) {
|
||||
if (input.type === EventTypeCustomInputType.BOOL) {
|
||||
return input.label + "\n" + (data.checked ? "Yes" : "No");
|
||||
return input.label + "\n" + (data.checked ? t("yes") : t("no"));
|
||||
} else {
|
||||
return input.label + "\n" + data.value;
|
||||
}
|
||||
@@ -74,12 +96,12 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
.join("\n\n");
|
||||
}
|
||||
if (!!notes && !!event.target.notes.value) {
|
||||
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
|
||||
notes += `\n\n${t("additional_notes")}:\n` + event.target.notes.value;
|
||||
} else {
|
||||
notes += event.target.notes.value;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: BookingCreateBody = {
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(props.eventType.length, "minute").format(),
|
||||
name: event.target.name.value,
|
||||
@@ -87,13 +109,11 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
notes: notes,
|
||||
guests: guestEmails,
|
||||
eventTypeId: props.eventType.id,
|
||||
rescheduleUid: rescheduleUid,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
};
|
||||
|
||||
if (router.query.user) {
|
||||
payload.user = router.query.user;
|
||||
}
|
||||
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
|
||||
if (typeof router.query.user === "string") payload.user = router.query.user;
|
||||
|
||||
if (selectedLocation) {
|
||||
switch (selectedLocation) {
|
||||
@@ -115,50 +135,80 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
||||
);
|
||||
|
||||
/*const res = await */ fetch("/api/book/event", {
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
const content = await createBooking(payload).catch((e) => {
|
||||
console.error(e.message);
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
});
|
||||
// TODO When the endpoint is fixed, change this to await the result again
|
||||
//if (res.ok) {
|
||||
let successUrl = `/success?date=${encodeURIComponent(date)}&type=${props.eventType.id}&user=${
|
||||
props.profile.slug
|
||||
}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
|
||||
if (payload["location"]) {
|
||||
if (payload["location"].includes("integration")) {
|
||||
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
|
||||
} else {
|
||||
successUrl += "&location=" + encodeURIComponent(payload["location"]);
|
||||
}
|
||||
}
|
||||
|
||||
await router.push(successUrl);
|
||||
if (content?.id) {
|
||||
const params: { [k: string]: any } = {
|
||||
date,
|
||||
type: props.eventType.id,
|
||||
user: props.profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
};
|
||||
|
||||
if (payload["location"]) {
|
||||
if (payload["location"].includes("integration")) {
|
||||
params.location = t("web_conferencing_details_to_follow");
|
||||
} else {
|
||||
params.location = payload["location"];
|
||||
}
|
||||
}
|
||||
|
||||
const query = stringify(params);
|
||||
let successUrl = `/success?${query}`;
|
||||
|
||||
if (content?.paymentUid) {
|
||||
successUrl = createPaymentLink({
|
||||
paymentUid: content?.paymentUid,
|
||||
name: payload.name,
|
||||
date,
|
||||
absolute: false,
|
||||
});
|
||||
}
|
||||
|
||||
await router.push(successUrl);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
event.preventDefault();
|
||||
book();
|
||||
};
|
||||
|
||||
return (
|
||||
themeLoaded && (
|
||||
<div>
|
||||
<Head>
|
||||
<title>
|
||||
{rescheduleUid ? "Reschedule" : "Confirm"} your {props.eventType.title} with {props.profile.name}{" "}
|
||||
| Cal.com
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
const bookingHandler = useCallback(_bookingHandler, [guestEmails]);
|
||||
|
||||
<main className="max-w-3xl mx-auto my-0 sm:my-24">
|
||||
<div className="dark:bg-neutral-900 bg-white overflow-hidden border border-gray-200 dark:border-0 sm:rounded-sm">
|
||||
<div className="sm:flex px-4 py-5 sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-black">
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>
|
||||
{rescheduleUid
|
||||
? t("booking_reschedule_confirmation", {
|
||||
eventTypeTitle: props.eventType.title,
|
||||
profileName: props.profile.name,
|
||||
})
|
||||
: t("booking_confirmation", {
|
||||
eventTypeTitle: props.eventType.title,
|
||||
profileName: props.profile.name,
|
||||
})}{" "}
|
||||
| Cal.com
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<CustomBranding val={props.profile.brandColor} />
|
||||
<main className="max-w-3xl mx-auto my-0 rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
|
||||
{isReady && (
|
||||
<div className="overflow-hidden bg-white border border-gray-200 dark:bg-neutral-900 dark:border-0 sm:rounded-sm">
|
||||
<div className="px-4 py-5 sm:flex sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
|
||||
<AvatarGroup
|
||||
size={16}
|
||||
size={14}
|
||||
items={[{ image: props.profile.image, alt: props.profile.name }].concat(
|
||||
props.eventType.users
|
||||
.filter((user) => user.name !== props.profile.name)
|
||||
@@ -168,31 +218,45 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
<h2 className="font-medium dark:text-gray-300 text-gray-500">{props.profile.name}</h2>
|
||||
<h1 className="text-3xl font-semibold dark:text-white text-gray-800 mb-4">
|
||||
<h2 className="mt-2 font-medium text-gray-500 font-cal dark:text-gray-300">
|
||||
{props.profile.name}
|
||||
</h2>
|
||||
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
|
||||
{props.eventType.title}
|
||||
</h1>
|
||||
<p className="text-gray-500 mb-2">
|
||||
<p className="mb-2 text-gray-500">
|
||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{props.eventType.length} minutes
|
||||
{props.eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{props.eventType.price > 0 && (
|
||||
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
|
||||
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={props.eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={props.eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{selectedLocation === LocationType.InPerson && (
|
||||
<p className="text-gray-500 mb-2">
|
||||
<p className="mb-2 text-gray-500">
|
||||
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{locationInfo(selectedLocation).address}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-green-500 mb-4">
|
||||
<p className="mb-4 text-green-500">
|
||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{parseZone(date).format(timeFormat + ", dddd DD MMMM YYYY")}
|
||||
</p>
|
||||
<p className="dark:text-white text-gray-600 mb-8">{props.eventType.description}</p>
|
||||
<p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p>
|
||||
</div>
|
||||
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<form onSubmit={bookingHandler}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium dark:text-white text-gray-700">
|
||||
Your name
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("your_name")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
@@ -200,7 +264,7 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="John Doe"
|
||||
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
|
||||
/>
|
||||
@@ -209,16 +273,17 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium dark:text-white text-gray-700">
|
||||
Email address
|
||||
className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("email_address")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
inputMode="email"
|
||||
required
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
|
||||
/>
|
||||
@@ -226,8 +291,8 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
</div>
|
||||
{locations.length > 1 && (
|
||||
<div className="mb-4">
|
||||
<span className="block text-sm font-medium dark:text-white text-gray-700">
|
||||
Location
|
||||
<span className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("location")}
|
||||
</span>
|
||||
{locations.map((location) => (
|
||||
<label key={location.type} className="block">
|
||||
@@ -235,12 +300,12 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
type="radio"
|
||||
required
|
||||
onChange={(e) => setSelectedLocation(e.target.value)}
|
||||
className="location focus:ring-black h-4 w-4 text-black border-gray-300 mr-2"
|
||||
className="w-4 h-4 mr-2 text-black border-gray-300 location focus:ring-black"
|
||||
name="location"
|
||||
value={location.type}
|
||||
checked={selectedLocation === location.type}
|
||||
/>
|
||||
<span className="text-sm ml-2 dark:text-gray-500">
|
||||
<span className="ml-2 text-sm dark:text-gray-500">
|
||||
{locationLabels[location.type]}
|
||||
</span>
|
||||
</label>
|
||||
@@ -251,20 +316,11 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="block text-sm font-medium dark:text-white text-gray-700">
|
||||
Phone Number
|
||||
className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("phone_number")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<PhoneInput
|
||||
name="phone"
|
||||
placeholder="Enter phone number"
|
||||
id="phone"
|
||||
required
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
onChange={() => {
|
||||
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
|
||||
}}
|
||||
/>
|
||||
<PhoneInput name="phone" placeholder={t("enter_phone_number")} id="phone" required />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -276,7 +332,7 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
{input.type !== EventTypeCustomInputType.BOOL && (
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
|
||||
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
)}
|
||||
@@ -286,7 +342,7 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
rows={3}
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
/>
|
||||
)}
|
||||
@@ -296,7 +352,7 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
/>
|
||||
)}
|
||||
@@ -306,7 +362,7 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder=""
|
||||
/>
|
||||
)}
|
||||
@@ -316,88 +372,96 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
type="checkbox"
|
||||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
className="focus:ring-black h-4 w-4 text-black border-gray-300 rounded mr-2"
|
||||
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
|
||||
placeholder=""
|
||||
required={input.required}
|
||||
/>
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-white mb-1">
|
||||
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="mb-4">
|
||||
{!guestToggle && (
|
||||
<label
|
||||
onClick={toggleGuestEmailInput}
|
||||
htmlFor="guests"
|
||||
className="block text-sm font-medium dark:text-white text-blue-500 mb-1 hover:cursor-pointer">
|
||||
+ Additional Guests
|
||||
</label>
|
||||
)}
|
||||
{guestToggle && (
|
||||
<div>
|
||||
{!props.eventType.disableGuests && (
|
||||
<div className="mb-4">
|
||||
{!guestToggle && (
|
||||
<label
|
||||
onClick={toggleGuestEmailInput}
|
||||
htmlFor="guests"
|
||||
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
|
||||
Guests
|
||||
className="block mb-1 text-sm font-medium text-blue-500 dark:text-white hover:cursor-pointer">
|
||||
{t("additional_guests")}
|
||||
</label>
|
||||
<ReactMultiEmail
|
||||
placeholder="guest@example.com"
|
||||
emails={guestEmails}
|
||||
onChange={(_emails: string[]) => {
|
||||
setGuestEmails(_emails);
|
||||
}}
|
||||
getLabel={(email: string, index: number, removeEmail: (index: number) => void) => {
|
||||
return (
|
||||
<div data-tag key={index}>
|
||||
{email}
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{guestToggle && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="guests"
|
||||
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("guests")}
|
||||
</label>
|
||||
<ReactMultiEmail
|
||||
className="relative"
|
||||
placeholder="guest@example.com"
|
||||
emails={guestEmails}
|
||||
onChange={(_emails: string[]) => {
|
||||
setGuestEmails(_emails);
|
||||
}}
|
||||
getLabel={(
|
||||
email: string,
|
||||
index: number,
|
||||
removeEmail: (index: number) => void
|
||||
) => {
|
||||
return (
|
||||
<div data-tag key={index}>
|
||||
{email}
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="notes"
|
||||
className="block text-sm font-medium dark:text-white text-gray-700 mb-1">
|
||||
Additional notes
|
||||
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("additional_notes")}
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
id="notes"
|
||||
rows={3}
|
||||
className="shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="Please share anything that will help prepare for our meeting."
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder={t("share_additional_notes")}
|
||||
defaultValue={props.booking ? props.booking.description : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start space-x-2">
|
||||
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
|
||||
<Button type="submit" loading={loading}>
|
||||
{rescheduleUid ? "Reschedule" : "Confirm"}
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
</Button>
|
||||
<Button color="secondary" type="button" onClick={() => router.back()}>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mt-2">
|
||||
<div className="p-4 mt-2 border-l-4 border-yellow-400 bg-yellow-50">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
<ExclamationIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
Could not {rescheduleUid ? "reschedule" : "book"} the meeting.
|
||||
{rescheduleUid ? t("reschedule_fail") : t("booking_fail")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,9 +470,9 @@ const BookingPage = (props: any): JSX.Element => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { DialogClose, DialogContent } from "@components/Dialog";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { ExclamationIcon } from "@heroicons/react/outline";
|
||||
import { CheckIcon } from "@heroicons/react/solid";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { DialogClose, DialogContent } from "@components/Dialog";
|
||||
import { Button } from "@components/ui/Button";
|
||||
|
||||
export type ConfirmationDialogContentProps = {
|
||||
confirmBtnText?: string;
|
||||
cancelBtnText?: string;
|
||||
onConfirm: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
title: string;
|
||||
variety?: "danger" /* no others yet */;
|
||||
variety?: "danger" | "warning" | "success";
|
||||
};
|
||||
|
||||
export default function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationDialogContentProps>) {
|
||||
const { title, variety, confirmBtnText = "Confirm", cancelBtnText = "Cancel", onConfirm, children } = props;
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
title,
|
||||
variety,
|
||||
confirmBtnText = t("confirm"),
|
||||
cancelBtnText = t("cancel"),
|
||||
onConfirm,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
@@ -25,14 +37,28 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||
<ExclamationIcon className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "warning" && (
|
||||
<div className="text-center p-2 rounded-full mx-auto bg-orange-100">
|
||||
<ExclamationIcon className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "success" && (
|
||||
<div className="text-center p-2 rounded-full mx-auto bg-green-100">
|
||||
<CheckIcon className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DialogPrimitive.Title className="text-xl font-bold text-gray-900">{title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-neutral-500">{children}</DialogPrimitive.Description>
|
||||
<DialogPrimitive.Title className="font-cal text-xl font-bold text-gray-900">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-neutral-500 text-sm">
|
||||
{children}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-x-2">
|
||||
<div className="mt-5 sm:mt-8 sm:flex sm:flex-row-reverse gap-x-2">
|
||||
<DialogClose onClick={onConfirm} asChild>
|
||||
<Button color="primary">{confirmBtnText}</Button>
|
||||
</DialogClose>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { EventType, SchedulingType } from "@prisma/client";
|
||||
import { ClockIcon, InformationCircleIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
||||
import { ClockIcon, CreditCardIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import React from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||
select: {
|
||||
id: true,
|
||||
length: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
schedulingType: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
|
||||
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
||||
|
||||
export type EventTypeDescriptionProps = {
|
||||
eventType: EventType;
|
||||
@@ -9,34 +26,48 @@ export type EventTypeDescriptionProps = {
|
||||
};
|
||||
|
||||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<ul className={classNames("mt-2 space-x-4 text-neutral-500 dark:text-white flex", className)}>
|
||||
<li className="flex whitespace-nowrap">
|
||||
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{eventType.length}m
|
||||
</li>
|
||||
{eventType.schedulingType ? (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<UsersIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && "Round Robin"}
|
||||
{eventType.schedulingType === SchedulingType.COLLECTIVE && "Collective"}
|
||||
</li>
|
||||
) : (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<UserIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
1-on-1
|
||||
</li>
|
||||
)}
|
||||
{eventType.description && (
|
||||
<li className="flex">
|
||||
<InformationCircleIcon
|
||||
className="flex-none inline mr-1.5 mt-0.5 h-4 w-4 text-neutral-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{eventType.description.substring(0, 100)}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<>
|
||||
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
||||
{eventType.description && (
|
||||
<h2 className="opacity-60 truncate max-w-[280px] sm:max-w-[500px]">
|
||||
{eventType.description.substring(0, 100)}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex mt-2 space-x-4 ">
|
||||
<li className="flex whitespace-nowrap">
|
||||
<ClockIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{eventType.length}m
|
||||
</li>
|
||||
{eventType.schedulingType ? (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<UsersIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
|
||||
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
|
||||
</li>
|
||||
) : (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<UserIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{t("1_on_1")}
|
||||
</li>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<CreditCardIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
142
components/form/fields.tsx
Normal file
142
components/form/fields.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useId } from "@radix-ui/react-id";
|
||||
import { forwardRef, ReactElement, ReactNode, Ref } from "react";
|
||||
import { FieldValues, FormProvider, SubmitHandler, useFormContext, UseFormReturn } from "react-hook-form";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { getErrorFromUnknown } from "@lib/errors";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
|
||||
type InputProps = Omit<JSX.IntrinsicElements["input"], "name"> & { name: string };
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export function Label(props: JSX.IntrinsicElements["label"]) {
|
||||
return (
|
||||
<label {...props} className={classNames("block text-sm font-medium text-gray-700", props.className)}>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
type InputFieldProps = {
|
||||
label?: ReactNode;
|
||||
addOnLeading?: ReactNode;
|
||||
} & React.ComponentProps<typeof Input> & {
|
||||
labelProps?: React.ComponentProps<typeof Label>;
|
||||
};
|
||||
|
||||
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(props, ref) {
|
||||
const id = useId();
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
const {
|
||||
label = t(props.name),
|
||||
labelProps,
|
||||
placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder"
|
||||
? t(props.name + "_placeholder")
|
||||
: "",
|
||||
className,
|
||||
addOnLeading,
|
||||
...passThroughToInput
|
||||
} = props;
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
{addOnLeading ? (
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
{addOnLeading}
|
||||
<Input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
className={classNames(className, "mt-0")}
|
||||
{...passThroughToInput}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input id={id} placeholder={placeholder} className={className} {...passThroughToInput} ref={ref} />
|
||||
)}
|
||||
{methods?.formState?.errors[props.name] && (
|
||||
<Alert className="mt-1" severity="error" message={methods.formState.errors[props.name].message} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, InputFieldProps>(function TextField(props, ref) {
|
||||
return <InputField ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(function PasswordField(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
|
||||
return <InputField type="email" inputMode="email" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
|
||||
JSX.IntrinsicElements["form"],
|
||||
"onSubmit"
|
||||
>;
|
||||
|
||||
const PlainForm = <T extends FieldValues>(props: FormProps<T>, ref: Ref<HTMLFormElement>) => {
|
||||
const { form, handleSubmit, ...passThrough } = props;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={(event) => {
|
||||
form
|
||||
.handleSubmit(handleSubmit)(event)
|
||||
.catch((err) => {
|
||||
showToast(`${getErrorFromUnknown(err).message}`, "error");
|
||||
});
|
||||
}}
|
||||
{...passThrough}>
|
||||
{props.children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Form = forwardRef(PlainForm) as <T extends FieldValues>(
|
||||
p: FormProps<T> & { ref?: Ref<HTMLFormElement> }
|
||||
) => ReactElement;
|
||||
|
||||
export function FieldsetLegend(props: JSX.IntrinsicElements["legend"]) {
|
||||
return (
|
||||
<legend {...props} className={classNames("text-sm font-medium text-gray-700", props.className)}>
|
||||
{props.children}
|
||||
</legend>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputGroupBox(props: JSX.IntrinsicElements["div"]) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("p-2 bg-white border border-gray-300 rounded-sm space-y-2", props.className)}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
components/integrations/CalendarListContainer.tsx
Normal file
218
components/integrations/CalendarListContainer.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { List } from "@components/List";
|
||||
import { ShellSubHeading } from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
import Switch from "@components/ui/Switch";
|
||||
|
||||
import ConnectIntegration from "./ConnectIntegrations";
|
||||
import DisconnectIntegration from "./DisconnectIntegration";
|
||||
import IntegrationListItem from "./IntegrationListItem";
|
||||
import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections";
|
||||
|
||||
type Props = {
|
||||
onChanged: () => unknown | Promise<unknown>;
|
||||
};
|
||||
|
||||
function CalendarSwitch(props: {
|
||||
type: string;
|
||||
externalId: string;
|
||||
title: string;
|
||||
defaultSelected: boolean;
|
||||
}) {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const mutation = useMutation<
|
||||
unknown,
|
||||
unknown,
|
||||
{
|
||||
isOn: boolean;
|
||||
}
|
||||
>(
|
||||
async ({ isOn }) => {
|
||||
const body = {
|
||||
integration: props.type,
|
||||
externalId: props.externalId,
|
||||
};
|
||||
if (isOn) {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
} else {
|
||||
const res = await fetch("/api/availability/calendar", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.integrations"]);
|
||||
},
|
||||
onError() {
|
||||
showToast(`Something went wrong when toggling "${props.title}""`, "error");
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="py-1">
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
label={props.title}
|
||||
defaultChecked={props.defaultSelected}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
mutation.mutate({ isOn });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectedCalendarsList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"], { suspense: true });
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
empty={() => null}
|
||||
success={({ data }) => (
|
||||
<List>
|
||||
{data.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
<IntegrationListItem
|
||||
{...item.integration}
|
||||
description={item.primary?.externalId || "No external Id"}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn">
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={props.onChanged}
|
||||
/>
|
||||
}>
|
||||
<ul className="p-4 space-y-2">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId as string}
|
||||
title={cal.name as string}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</IntegrationListItem>
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Something went wrong"
|
||||
message={item.error?.message}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn">
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations"]);
|
||||
|
||||
return (
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<List>
|
||||
{data.calendar.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
key={item.title}
|
||||
{...item}
|
||||
actions={
|
||||
<ConnectIntegration
|
||||
type={item.type}
|
||||
render={(btnProps) => (
|
||||
<Button color="secondary" {...btnProps}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export function CalendarListContainer(props: { heading?: false }) {
|
||||
const { t } = useLocale();
|
||||
const { heading = true } = props;
|
||||
const utils = trpc.useContext();
|
||||
const onChanged = () =>
|
||||
Promise.allSettled([
|
||||
utils.invalidateQueries(["viewer.integrations"]),
|
||||
utils.invalidateQueries(["viewer.connectedCalendars"]),
|
||||
]);
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
return (
|
||||
<>
|
||||
{heading && (
|
||||
<ShellSubHeading
|
||||
className="mt-10"
|
||||
title={<SubHeadingTitleWithConnections title={t("calendar")} numConnections={query.data?.length} />}
|
||||
subtitle={t("configure_how_your_event_types_interact")}
|
||||
/>
|
||||
)}
|
||||
<ConnectedCalendarsList onChanged={onChanged} />
|
||||
{!!query.data?.length && (
|
||||
<ShellSubHeading
|
||||
className="mt-6"
|
||||
title={<SubHeadingTitleWithConnections title={t("connect_an_additional_calendar")} />}
|
||||
/>
|
||||
)}
|
||||
<CalendarList onChanged={onChanged} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
components/integrations/ConnectIntegrations.tsx
Normal file
63
components/integrations/ConnectIntegrations.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
|
||||
import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
||||
|
||||
import { ButtonBaseProps } from "@components/ui/Button";
|
||||
|
||||
export default function ConnectIntegration(props: {
|
||||
type: string;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
const { type } = props;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const mutation = useMutation(async () => {
|
||||
const state: IntegrationOAuthCallbackState = {
|
||||
returnTo: location.pathname + location.search,
|
||||
};
|
||||
const stateStr = encodeURIComponent(JSON.stringify(state));
|
||||
const searchParams = `?state=${stateStr}`;
|
||||
const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add" + searchParams);
|
||||
if (!res.ok) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
const json = await res.json();
|
||||
window.location.href = json.url;
|
||||
setIsLoading(true);
|
||||
});
|
||||
const [isModalOpen, _setIsModalOpen] = useState(false);
|
||||
|
||||
const setIsModalOpen = (v: boolean) => {
|
||||
_setIsModalOpen(v);
|
||||
props.onOpenChange(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.render({
|
||||
onClick() {
|
||||
if (["caldav_calendar", "apple_calendar"].includes(type)) {
|
||||
// special handlers
|
||||
setIsModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate();
|
||||
},
|
||||
loading: mutation.isLoading || isLoading,
|
||||
disabled: isModalOpen,
|
||||
})}
|
||||
{type === "caldav_calendar" && (
|
||||
<AddCalDavIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||
)}
|
||||
|
||||
{type === "apple_calendar" && (
|
||||
<AddAppleIntegrationModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
components/integrations/DisconnectIntegration.tsx
Normal file
60
components/integrations/DisconnectIntegration.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { Dialog } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import { ButtonBaseProps } from "@components/ui/Button";
|
||||
|
||||
export default function DisconnectIntegration(props: {
|
||||
/** Integration credential id */
|
||||
id: number;
|
||||
render: (renderProps: ButtonBaseProps) => JSX.Element;
|
||||
onOpenChange: (isOpen: boolean) => unknown | Promise<unknown>;
|
||||
}) {
|
||||
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");
|
||||
}
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
await props.onOpenChange(modalOpen);
|
||||
},
|
||||
onSuccess() {
|
||||
setModalOpen(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title="Disconnect Integration"
|
||||
confirmBtnText="Yes, disconnect integration"
|
||||
cancelBtnText="Cancel"
|
||||
onConfirm={() => {
|
||||
mutation.mutate();
|
||||
}}>
|
||||
Are you sure you want to disconnect this integration?
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
{props.render({
|
||||
onClick() {
|
||||
setModalOpen(true);
|
||||
},
|
||||
disabled: modalOpen,
|
||||
loading: mutation.isLoading,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
0
components/integrations/IntegrationList.tsx
Normal file
0
components/integrations/IntegrationList.tsx
Normal file
30
components/integrations/IntegrationListItem.tsx
Normal file
30
components/integrations/IntegrationListItem.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Image from "next/image";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
import { ListItem, ListItemText, ListItemTitle } from "@components/List";
|
||||
|
||||
function IntegrationListItem(props: {
|
||||
imageSrc: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<ListItem expanded={!!props.children} className={classNames("flex-col")}>
|
||||
<div className={classNames("flex flex-1 space-x-2 w-full p-3 items-center")}>
|
||||
<Image width={40} height={40} src={`/${props.imageSrc}`} alt={props.title} />
|
||||
<div className="flex-grow pl-2 truncate">
|
||||
<ListItemTitle component="h3">{props.title}</ListItemTitle>
|
||||
<ListItemText component="p">{props.description}</ListItemText>
|
||||
</div>
|
||||
<div>{props.actions}</div>
|
||||
</div>
|
||||
{props.children && <div className="w-full border-t border-gray-200">{props.children}</div>}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default IntegrationListItem;
|
||||
29
components/integrations/SubHeadingTitleWithConnections.tsx
Normal file
29
components/integrations/SubHeadingTitleWithConnections.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
function pluralize(opts: { num: number; plural: string; singular: string }) {
|
||||
if (opts.num === 0) {
|
||||
return opts.singular;
|
||||
}
|
||||
return opts.singular;
|
||||
}
|
||||
|
||||
export default function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) {
|
||||
const num = props.numConnections;
|
||||
return (
|
||||
<>
|
||||
<span>{props.title}</span>
|
||||
{num ? (
|
||||
<Badge variant="success">
|
||||
{num}{" "}
|
||||
{pluralize({
|
||||
num,
|
||||
singular: "connection",
|
||||
plural: "connections",
|
||||
})}
|
||||
</Badge>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
components/pages/eventtypes/CustomInputTypeForm.tsx
Normal file
128
components/pages/eventtypes/CustomInputTypeForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client";
|
||||
import React, { FC } from "react";
|
||||
import { Controller, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
import Select, { OptionTypeBase } from "react-select";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
interface Props {
|
||||
onSubmit: SubmitHandler<IFormInput>;
|
||||
onCancel: () => void;
|
||||
selectedCustomInput?: EventTypeCustomInput;
|
||||
}
|
||||
|
||||
type IFormInput = EventTypeCustomInput;
|
||||
|
||||
const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const inputOptions: OptionTypeBase[] = [
|
||||
{ value: EventTypeCustomInputType.TEXT, label: t("text") },
|
||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||
];
|
||||
const { selectedCustomInput } = props;
|
||||
const defaultValues = selectedCustomInput || { type: inputOptions[0].value };
|
||||
const { register, control, handleSubmit } = useForm<IFormInput>({
|
||||
defaultValues,
|
||||
});
|
||||
const selectedInputType = useWatch({ name: "type", control });
|
||||
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value)!;
|
||||
|
||||
const onCancel = () => {
|
||||
props.onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(props.onSubmit)}>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
{t("input_type")}
|
||||
</label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
id="type"
|
||||
defaultValue={selectedInputOption}
|
||||
options={inputOptions}
|
||||
isSearchable={false}
|
||||
className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm"
|
||||
onChange={(option) => field.onChange(option.value)}
|
||||
value={selectedInputOption}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="label" className="block text-sm font-medium text-gray-700">
|
||||
{t("label")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(selectedInputType === EventTypeCustomInputType.TEXT ||
|
||||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
|
||||
<div className="mb-2">
|
||||
<label htmlFor="placeholder" className="block text-sm font-medium text-gray-700">
|
||||
{t("placeholder")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="required"
|
||||
type="checkbox"
|
||||
className="w-4 h-4 mr-2 border-gray-300 rounded focus:ring-primary-500 text-primary-600"
|
||||
defaultChecked={selectedCustomInput?.required ?? true}
|
||||
{...register("required")}
|
||||
/>
|
||||
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||
{t("is_required")}
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
id="eventTypeId"
|
||||
value={selectedCustomInput?.eventTypeId || -1}
|
||||
{...register("eventTypeId", { valueAsNumber: true })}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
id="id"
|
||||
value={selectedCustomInput?.id || -1}
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("save")}
|
||||
</button>
|
||||
<button onClick={onCancel} type="button" className="mr-2 btn btn-white">
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInputTypeForm;
|
||||
@@ -1,22 +1,19 @@
|
||||
import React, { SyntheticEvent, useState } from "react";
|
||||
import Modal from "@components/Modal";
|
||||
import { ErrorCode } from "@lib/auth";
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
[ErrorCode.IncorrectPassword]: "Current password is incorrect",
|
||||
[ErrorCode.NewPasswordMatchesOld]:
|
||||
"New password matches your old password. Please choose a different password.",
|
||||
};
|
||||
import { ErrorCode } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
|
||||
const ChangePasswordSection = () => {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
const closeSuccessModal = () => {
|
||||
setSuccessModalOpen(false);
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
[ErrorCode.IncorrectPassword]: t("current_incorrect_password"),
|
||||
[ErrorCode.NewPasswordMatchesOld]: t("new_password_matches_old_password"),
|
||||
};
|
||||
|
||||
async function changePasswordHandler(e: SyntheticEvent) {
|
||||
@@ -41,15 +38,15 @@ const ChangePasswordSection = () => {
|
||||
if (response.status === 200) {
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setSuccessModalOpen(true);
|
||||
showToast(t("password_has_been_changed"), "success");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
setErrorMessage(errorMessages[body.error] || "Something went wrong. Please try again");
|
||||
setErrorMessage(errorMessages[body.error] || `${t("something_went_wrong")}${t("please_try_again")}`);
|
||||
} catch (err) {
|
||||
console.error("Error changing password", err);
|
||||
setErrorMessage("Something went wrong. Please try again");
|
||||
console.error(t("error_changing_password"), err);
|
||||
setErrorMessage(`${t("something_went_wrong")}${t("please_try_again")}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -58,14 +55,14 @@ const ChangePasswordSection = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Change Password</h2>
|
||||
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="flex">
|
||||
<div className="w-1/2 mr-2">
|
||||
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
|
||||
Current Password
|
||||
{t("current_password")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
@@ -75,14 +72,14 @@ const ChangePasswordSection = () => {
|
||||
name="current_password"
|
||||
id="current_password"
|
||||
required
|
||||
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="Your old password"
|
||||
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder={t("your_old_password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 ml-2">
|
||||
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
|
||||
New Password
|
||||
{t("new_password")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
@@ -92,8 +89,8 @@ const ChangePasswordSection = () => {
|
||||
value={newPassword}
|
||||
required
|
||||
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
||||
className="shadow-sm focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder="Your super secure new password"
|
||||
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
placeholder={t("super_secure_new_password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,18 +100,12 @@ const ChangePasswordSection = () => {
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
Save
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
</div>
|
||||
</form>
|
||||
<Modal
|
||||
heading="Password updated successfully"
|
||||
description="Your password has been successfully changed."
|
||||
open={successModalOpen}
|
||||
handleClose={closeSuccessModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { SyntheticEvent, useState } from "react";
|
||||
import Button from "@components/ui/Button";
|
||||
import { Dialog, DialogContent } from "@components/Dialog";
|
||||
|
||||
import { ErrorCode } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { Dialog, DialogContent } from "@components/Dialog";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
||||
|
||||
interface DisableTwoFactorAuthModalProps {
|
||||
/**
|
||||
* Called when the user closes the modal without disabling two-factor auth
|
||||
*/
|
||||
/** Called when the user closes the modal without disabling two-factor auth */
|
||||
onCancel: () => void;
|
||||
|
||||
/**
|
||||
* Called when the user disables two-factor auth
|
||||
*/
|
||||
/** Called when the user disables two-factor auth */
|
||||
onDisable: () => void;
|
||||
}
|
||||
|
||||
@@ -21,6 +20,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
const [password, setPassword] = useState("");
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const { t } = useLocale();
|
||||
|
||||
async function handleDisable(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
@@ -40,13 +40,13 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
|
||||
const body = await response.json();
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage("Password is incorrect.");
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
} else {
|
||||
setErrorMessage("Something went wrong.");
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage("Something went wrong.");
|
||||
console.error("Error disabling two-factor authentication", e);
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_disabling_2fa"), e);
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
@@ -55,15 +55,12 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent>
|
||||
<TwoFactorModalHeader
|
||||
title="Disable two-factor authentication"
|
||||
description="If you need to disable 2FA, we recommend re-enabling it as soon as possible."
|
||||
/>
|
||||
<TwoFactorModalHeader title={t("disable_2fa")} description={t("disable_2fa_recommendation")} />
|
||||
|
||||
<form onSubmit={handleDisable}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
@@ -87,10 +84,10 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||
className="ml-2"
|
||||
onClick={handleDisable}
|
||||
disabled={password.length === 0 || isDisabling}>
|
||||
Disable
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { SyntheticEvent, useState } from "react";
|
||||
import Button from "@components/ui/Button";
|
||||
import { Dialog, DialogContent } from "@components/Dialog";
|
||||
|
||||
import { ErrorCode } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { Dialog, DialogContent } from "@components/Dialog";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
||||
|
||||
@@ -23,12 +27,6 @@ enum SetupStep {
|
||||
EnterTotpCode,
|
||||
}
|
||||
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: "Confirm your current password to get started.",
|
||||
[SetupStep.DisplayQrCode]: "Scan the image below with the authenticator app on your phone.",
|
||||
[SetupStep.EnterTotpCode]: "Enter the six-digit code from your authenticator app below.",
|
||||
};
|
||||
|
||||
const WithStep = ({
|
||||
step,
|
||||
current,
|
||||
@@ -42,10 +40,17 @@ const WithStep = ({
|
||||
};
|
||||
|
||||
const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const setupDescriptions = {
|
||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
||||
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
|
||||
};
|
||||
const [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||
const [password, setPassword] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [dataUri, setDataUri] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -65,18 +70,19 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
|
||||
if (response.status === 200) {
|
||||
setDataUri(body.dataUri);
|
||||
setSecret(body.secret);
|
||||
setStep(SetupStep.DisplayQrCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.error === ErrorCode.IncorrectPassword) {
|
||||
setErrorMessage("Password is incorrect.");
|
||||
setErrorMessage(t("incorrect_password"));
|
||||
} else {
|
||||
setErrorMessage("Something went wrong.");
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage("Something went wrong.");
|
||||
console.error("Error setting up two-factor authentication", e);
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_enabling_2fa"), e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -102,13 +108,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
}
|
||||
|
||||
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||
setErrorMessage("Code is incorrect. Please try again.");
|
||||
setErrorMessage(`${t("code_is_incorrect")} ${t("please_try_again")}`);
|
||||
} else {
|
||||
setErrorMessage("Something went wrong.");
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
}
|
||||
} catch (e) {
|
||||
setErrorMessage("Something went wrong.");
|
||||
console.error("Error enabling up two-factor authentication", e);
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
console.error(t("error_enabling_2fa"), e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -117,16 +123,13 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent>
|
||||
<TwoFactorModalHeader
|
||||
title="Enable two-factor authentication"
|
||||
description={setupDescriptions[step]}
|
||||
/>
|
||||
<TwoFactorModalHeader title={t("enable_2fa")} description={setupDescriptions[step]} />
|
||||
|
||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||
<form onSubmit={handleSetup}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
@@ -145,15 +148,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
</form>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<div className="flex justify-center">
|
||||
<img src={dataUri} />
|
||||
</div>
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<img src={dataUri} />
|
||||
</div>
|
||||
<p className="text-center text-xs font-mono">{secret}</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<form onSubmit={handleEnable}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="code" className="mt-4 block text-sm font-medium text-gray-700">
|
||||
Code
|
||||
{t("code")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
@@ -182,12 +188,12 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
className="ml-2"
|
||||
onClick={handleSetup}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
Continue
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button type="submit" className="ml-2" onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
Continue
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
@@ -196,11 +202,11 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||
className="ml-2"
|
||||
onClick={handleEnable}
|
||||
disabled={totpCode.length !== 6 || isSubmitting}>
|
||||
Enable
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Badge from "@components/ui/Badge";
|
||||
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import DisableTwoFactorModal from "./DisableTwoFactorModal";
|
||||
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
||||
|
||||
const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean }) => {
|
||||
const [enabled, setEnabled] = useState(twoFactorEnabled);
|
||||
const [enableModalOpen, setEnableModalOpen] = useState(false);
|
||||
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Two-Factor Authentication</h2>
|
||||
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">{t("2fa")}</h2>
|
||||
<Badge className="text-xs ml-2" variant={enabled ? "success" : "gray"}>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
{enabled ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Add an extra layer of security to your account in case your password is stolen.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
|
||||
<Button
|
||||
className="mt-6"
|
||||
type="submit"
|
||||
onClick={() => (enabled ? setDisableModalOpen(true) : setEnableModalOpen(true))}>
|
||||
{enabled ? "Disable" : "Enable"} Two-Factor Authentication
|
||||
{enabled ? t("disable") : t("enable")} {t("2fa")}
|
||||
</Button>
|
||||
|
||||
{enableModalOpen && (
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
|
||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-black rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ShieldCheckIcon className="w-6 h-6 text-black" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
<h3 className="font-cal text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">{description}</p>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import merge from "lodash/merge";
|
||||
import { NextSeo, NextSeoProps } from "next-seo";
|
||||
import React from "react";
|
||||
import { getBrowserInfo } from "@lib/core/browser/browser.utils";
|
||||
|
||||
import { getSeoImage, seoConfig } from "@lib/config/next-seo.config";
|
||||
import merge from "lodash.merge";
|
||||
import { getBrowserInfo } from "@lib/core/browser/browser.utils";
|
||||
|
||||
export type HeadSeoProps = {
|
||||
title: string;
|
||||
@@ -68,7 +69,7 @@ const buildSeoMeta = (pageProps: {
|
||||
const constructImage = (name: string, avatar: string, description: string): string => {
|
||||
return (
|
||||
encodeURIComponent("Meet **" + name + "** <br>" + description).replace(/'/g, "%27") +
|
||||
".png?md=1&images=https%3A%2F%2Fcal.com%2Fcalendso-logo-white.svg&images=" +
|
||||
".png?md=1&images=https%3A%2F%2Fcal.com%2Flogo-white.svg&images=" +
|
||||
encodeURIComponent(avatar)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import ErrorAlert from "@components/ui/alerts/Error";
|
||||
import { UsernameInput } from "@components/ui/UsernameInput";
|
||||
import MemberList from "./MemberList";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Modal from "@components/Modal";
|
||||
import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||
import Button from "@components/ui/Button";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
import showToast from "@lib/notification";
|
||||
import { Team } from "@lib/team";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import { UsernameInput } from "@components/ui/UsernameInput";
|
||||
import ErrorAlert from "@components/ui/alerts/Error";
|
||||
|
||||
import MemberList from "./MemberList";
|
||||
|
||||
export default function EditTeam(props: { team: Team | undefined | null; onCloseEdit: () => void }) {
|
||||
const [members, setMembers] = useState([]);
|
||||
|
||||
@@ -22,11 +26,11 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
|
||||
const loadMembers = () =>
|
||||
fetch("/api/teams/" + props.team?.id + "/membership")
|
||||
@@ -91,7 +95,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
})
|
||||
.then(handleError)
|
||||
.then(() => {
|
||||
setSuccessModalOpen(true);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
setHasErrors(false); // dismiss any open errors
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -105,10 +109,6 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
setShowMemberInvitationModal(false);
|
||||
};
|
||||
|
||||
const closeSuccessModal = () => {
|
||||
setSuccessModalOpen(false);
|
||||
};
|
||||
|
||||
const handleLogoChange = (newLogo: string) => {
|
||||
logoRef.current.value = newLogo;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement?.prototype, "value").set;
|
||||
@@ -129,19 +129,19 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
size="sm"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => props.onCloseEdit()}>
|
||||
Back
|
||||
{t("back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="">
|
||||
<div>
|
||||
<div className="pb-5 pr-4 sm:pb-6">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900">{props.team?.name}</h3>
|
||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
||||
<p>Manage your team</p>
|
||||
<p>{t("manage_your_team")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-2" />
|
||||
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">Profile</h3>
|
||||
<h3 className="font-cal font-bold leading-6 text-gray-900 mt-7 text-md">{t("profile")}</h3>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateTeamHandler}>
|
||||
{hasErrors && <ErrorAlert message={errorMessage} />}
|
||||
<div className="py-6 lg:pb-8">
|
||||
@@ -149,18 +149,22 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="block sm:flex">
|
||||
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
|
||||
<UsernameInput ref={teamUrlRef} defaultValue={props.team?.slug} label={"My team URL"} />
|
||||
<UsernameInput
|
||||
ref={teamUrlRef}
|
||||
defaultValue={props.team?.slug}
|
||||
label={t("my_team_url")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2 sm:ml-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Team name
|
||||
{t("team_name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Your team name"
|
||||
placeholder={t("your_team_name")}
|
||||
required
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
defaultValue={props.team?.name}
|
||||
@@ -169,7 +173,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
|
||||
About
|
||||
{t("about")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<textarea
|
||||
@@ -179,9 +183,7 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
rows={3}
|
||||
defaultValue={props.team?.bio}
|
||||
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
A few sentences about your team. This will appear on your team's URL page.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -198,27 +200,27 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
defaultValue={imageSrc ? imageSrc : props.team?.logo}
|
||||
defaultValue={imageSrc ?? props.team?.logo}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="logo"
|
||||
id="logo-upload"
|
||||
buttonMsg={imageSrc !== "" ? "Edit logo" : "Upload a logo"}
|
||||
buttonMsg={imageSrc !== "" ? t("edit_logo") : t("upload_a_logo")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
imageRef={imageSrc ? imageSrc : props.team?.logo}
|
||||
imageSrc={imageSrc ?? props.team?.logo}
|
||||
/>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<div className="flex justify-between mt-7">
|
||||
<h3 className="font-bold leading-6 text-gray-900 text-md">Members</h3>
|
||||
<h3 className="font-cal font-bold leading-6 text-gray-900 text-md">{t("members")}</h3>
|
||||
<div className="relative flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={PlusIcon}
|
||||
onClick={() => onInviteMember(props.team)}>
|
||||
New Member
|
||||
{t("new_member")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,14 +244,14 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
Disable Cal.com branding
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">Hide all Cal.com branding from your public pages.</p>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">Danger Zone</h3>
|
||||
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">{t("danger_zone")}</h3>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<Dialog>
|
||||
@@ -259,16 +261,14 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
}}
|
||||
className="btn-sm btn-white">
|
||||
<TrashIcon className="group-hover:text-red text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
|
||||
Disband Team
|
||||
{t("disband_team")}
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title="Disband Team"
|
||||
confirmBtnText="Yes, disband team"
|
||||
cancelBtnText="Cancel"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => deleteTeam()}>
|
||||
Are you sure you want to disband this team? Anyone who you've shared this team
|
||||
link with will no longer be able to book using it.
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
@@ -278,17 +278,11 @@ export default function EditTeam(props: { team: Team | undefined | null; onClose
|
||||
<hr className="mt-8" />
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit" color="primary">
|
||||
Save
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<Modal
|
||||
heading="Team updated successfully"
|
||||
description="Your team has been updated successfully."
|
||||
open={successModalOpen}
|
||||
handleClose={closeSuccessModal}
|
||||
/>
|
||||
{showMemberInvitationModal && (
|
||||
<MemberInvitationModal team={inviteModalTeam} onExit={onMemberInvitationModalExit} />
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
import Button from "@components/ui/Button";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Team } from "@lib/team";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
|
||||
const handleError = async (res: Response) => {
|
||||
const responseData = await res.json();
|
||||
@@ -17,13 +22,22 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
return responseData;
|
||||
};
|
||||
|
||||
const inviteMember = (e) => {
|
||||
const inviteMember = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
elements: {
|
||||
role: { value: string };
|
||||
inviteUser: { value: string };
|
||||
sendInviteEmail: { checked: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
const payload = {
|
||||
role: e.target.elements["role"].value,
|
||||
usernameOrEmail: e.target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: e.target.elements["sendInviteEmail"].checked,
|
||||
language: i18n.language,
|
||||
role: target.elements["role"].value,
|
||||
usernameOrEmail: target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||
};
|
||||
|
||||
return fetch("/api/teams/" + props?.team?.id + "/invite", {
|
||||
@@ -57,15 +71,15 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
|
||||
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-black rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="w-6 h-6 text-black" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
Invite a new member
|
||||
{t("invite_new_member")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Invite someone to your team.</p>
|
||||
<p className="text-sm text-gray-400">{t("invite_new_team_member")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +87,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
|
||||
Email or Username
|
||||
{t("email_or_username")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -81,18 +95,18 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
id="inviteUser"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-black sm:text-sm"
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-black focus:border-brand sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
Role
|
||||
{t("role")}
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm">
|
||||
<option value="MEMBER">Member</option>
|
||||
<option value="OWNER">Owner</option>
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="OWNER">{t("owner")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
@@ -102,12 +116,12 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
name="sendInviteEmail"
|
||||
defaultChecked
|
||||
id="sendInviteEmail"
|
||||
className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm"
|
||||
className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-2 text-sm">
|
||||
<label htmlFor="sendInviteEmail" className="font-medium text-gray-700">
|
||||
Send an invite email
|
||||
{t("send_invite_email")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,10 +134,10 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
)}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" color="primary" className="ml-2">
|
||||
Invite
|
||||
{t("invite")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import MemberListItem from "./MemberListItem";
|
||||
import { Member } from "@lib/member";
|
||||
|
||||
import MemberListItem from "./MemberListItem";
|
||||
|
||||
export default function MemberList(props: {
|
||||
members: Member[];
|
||||
onRemoveMember: (text: Member) => void;
|
||||
@@ -16,7 +17,7 @@ export default function MemberList(props: {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
|
||||
<ul className="px-6 mb-2 -mx-6 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
|
||||
{props.members.map((member) => (
|
||||
<MemberListItem
|
||||
onChange={props.onChange}
|
||||
|
||||
@@ -1,60 +1,71 @@
|
||||
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { Member } from "@lib/member";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
|
||||
export default function MemberListItem(props: {
|
||||
member: Member;
|
||||
onActionSelect: (text: string) => void;
|
||||
onChange: (text: string) => void;
|
||||
}) {
|
||||
const [member] = useState(props.member);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
member && (
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={
|
||||
props.member.avatar
|
||||
? props.member.avatar
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||
encodeURIComponent(props.member.name || "")
|
||||
}
|
||||
displayName={props.member.name || ""}
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{props.member.name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
|
||||
<div className="flex flex-col justify-between w-full sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={
|
||||
props.member.avatar
|
||||
? props.member.avatar
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||
encodeURIComponent(props.member.name || "")
|
||||
}
|
||||
alt={props.member.name || ""}
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{props.member.name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{props.member.role === "INVITEE" && (
|
||||
<>
|
||||
<span className="self-center h-6 px-3 py-1 mr-2 text-xs text-yellow-700 capitalize rounded-md bg-yellow-50">
|
||||
{t("pending")}
|
||||
</span>
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
|
||||
{t("member")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{props.member.role === "MEMBER" && (
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
|
||||
{t("member")}
|
||||
</span>
|
||||
)}
|
||||
{props.member.role === "OWNER" && (
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-blue-700 capitalize rounded-md bg-blue-50">
|
||||
{t("owner")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{props.member.role === "INVITEE" && (
|
||||
<>
|
||||
<span className="self-center h-6 px-3 py-1 mr-2 text-xs text-yellow-700 capitalize rounded-md bg-yellow-50">
|
||||
Pending
|
||||
</span>
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
|
||||
Member
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{props.member.role === "MEMBER" && (
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
|
||||
Member
|
||||
</span>
|
||||
)}
|
||||
{props.member.role === "OWNER" && (
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-blue-700 capitalize rounded-md bg-blue-50">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
{/* <div className="flex flex-col-reverse"> */}
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger>
|
||||
<DotsHorizontalIcon className="w-5 h-5" />
|
||||
@@ -70,21 +81,21 @@ export default function MemberListItem(props: {
|
||||
color="warn"
|
||||
StartIcon={UserRemoveIcon}
|
||||
className="w-full">
|
||||
Remove User
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title="Remove member"
|
||||
confirmBtnText="Yes, remove member"
|
||||
cancelBtnText="Cancel"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={() => props.onActionSelect("remove")}>
|
||||
Are you sure you want to remove this member from the team?
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{/* </div> */}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import TeamListItem from "./TeamListItem";
|
||||
import { Team } from "@lib/team";
|
||||
|
||||
import TeamListItem from "./TeamListItem";
|
||||
|
||||
export default function TeamList(props: {
|
||||
teams: Team[];
|
||||
onChange: () => void;
|
||||
@@ -17,10 +18,11 @@ export default function TeamList(props: {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTeam = (team: Team) => {
|
||||
return fetch("/api/teams/" + team.id, {
|
||||
const deleteTeam = async (team: Team) => {
|
||||
await fetch("/api/teams/" + team.id, {
|
||||
method: "DELETE",
|
||||
}).then(props.onChange());
|
||||
});
|
||||
return props.onChange();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,15 +5,19 @@ import {
|
||||
PencilAltIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
import { useState } from "react";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import showToast from "@lib/notification";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
|
||||
interface Team {
|
||||
id: number;
|
||||
@@ -33,6 +37,7 @@ export default function TeamListItem(props: {
|
||||
onActionSelect: (text: string) => void;
|
||||
}) {
|
||||
const [team, setTeam] = useState<Team | null>(props.team);
|
||||
const { t } = useLocale();
|
||||
|
||||
const acceptInvite = () => invitationResponse(true);
|
||||
const declineInvite = () => invitationResponse(false);
|
||||
@@ -56,57 +61,61 @@ export default function TeamListItem(props: {
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
size={9}
|
||||
imageSrc={
|
||||
props.team.logo
|
||||
? props.team.logo
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||
encodeURIComponent(props.team.name || "")
|
||||
}
|
||||
displayName="Team Logo"
|
||||
alt="Team Logo"
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{props.team.name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">
|
||||
{window.location.hostname}/{props.team.slug}
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{props.team.role === "INVITEE" && (
|
||||
<div>
|
||||
<Button type="button" color="secondary" onClick={declineInvite}>
|
||||
Reject
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
|
||||
Accept
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{props.team.role === "MEMBER" && (
|
||||
<div>
|
||||
<Button type="button" color="primary" onClick={declineInvite}>
|
||||
Leave
|
||||
{t("leave")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{props.team.role === "OWNER" && (
|
||||
<div className="flex">
|
||||
<div className="flex space-x-4">
|
||||
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
|
||||
Owner
|
||||
{t("owner")}
|
||||
</span>
|
||||
<Tooltip content="Copy link">
|
||||
<Tooltip content={t("copy_link")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.hostname + "/team/" + props.team.slug);
|
||||
showToast("Link copied!", "success");
|
||||
navigator.clipboard.writeText(
|
||||
process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
|
||||
);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
size="icon"
|
||||
color="minimal"
|
||||
className="w-full pl-5 ml-8"
|
||||
StartIcon={LinkIcon}
|
||||
type="button"></Button>
|
||||
type="button"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger className="group w-10 h-10 p-0 border border-transparent text-neutral-400 hover:border-gray-200">
|
||||
<DotsHorizontalIcon className="w-5 h-5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
@@ -118,15 +127,15 @@ export default function TeamListItem(props: {
|
||||
onClick={() => props.onActionSelect("edit")}
|
||||
StartIcon={PencilAltIcon}>
|
||||
{" "}
|
||||
Edit team
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="">
|
||||
<Link href={`/team/${props.team.slug}`} passHref={true}>
|
||||
<DropdownMenuItem>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team.slug}`} passHref={true}>
|
||||
<a target="_blank">
|
||||
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
|
||||
{" "}
|
||||
Preview team page
|
||||
{t("preview_team")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
@@ -141,17 +150,15 @@ export default function TeamListItem(props: {
|
||||
color="warn"
|
||||
StartIcon={TrashIcon}
|
||||
className="w-full">
|
||||
Disband Team
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title="Disband Team"
|
||||
confirmBtnText="Yes, disband team"
|
||||
cancelBtnText="Cancel"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => props.onActionSelect("disband")}>
|
||||
Are you sure you want to disband this team? Anyone who you've shared this team
|
||||
link with will no longer be able to book using it.
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React from "react";
|
||||
import Text from "@components/ui/Text";
|
||||
import Link from "next/link";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import classnames from "classnames";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/solid";
|
||||
import classnames from "classnames";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import Text from "@components/ui/Text";
|
||||
|
||||
const Team = ({ team }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const Member = ({ member }) => {
|
||||
const classes = classnames(
|
||||
"group",
|
||||
@@ -18,7 +23,7 @@ const Team = ({ team }) => {
|
||||
"bg-white dark:bg-neutral-900 dark:border-0 dark:bg-opacity-8",
|
||||
"border border-neutral-200",
|
||||
"hover:cursor-pointer",
|
||||
"hover:border-black dark:border-neutral-700 dark:hover:border-neutral-600",
|
||||
"hover:border-brand dark:border-neutral-700 dark:hover:border-neutral-600",
|
||||
"rounded-sm",
|
||||
"hover:shadow-md"
|
||||
);
|
||||
@@ -56,9 +61,9 @@ const Team = ({ team }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto min-w-full lg:min-w-lg max-w-5xl grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-6">
|
||||
<section className="mx-auto min-w-full lg:min-w-lg max-w-5xl flex flex-wrap gap-x-12 gap-y-6 justify-center">
|
||||
{members.map((member) => {
|
||||
return <Member key={member.id} member={member} />;
|
||||
return member.user.username !== null && <Member key={member.id} member={member} />;
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
@@ -67,10 +72,10 @@ const Team = ({ team }) => {
|
||||
return (
|
||||
<div>
|
||||
<Members members={team.members} />
|
||||
{team.eventTypes.length && (
|
||||
{team.eventTypes.length > 0 && (
|
||||
<aside className="text-center dark:text-white mt-8">
|
||||
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
|
||||
Go back
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,9 @@ import classNames from "classnames";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface AlertProps {
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
message?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
severity: "success" | "warning" | "error";
|
||||
}
|
||||
@@ -14,10 +15,10 @@ export function Alert(props: AlertProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md p-4",
|
||||
"rounded-sm p-2",
|
||||
props.className,
|
||||
severity === "error" && "bg-red-50 text-red-800",
|
||||
severity === "warning" && "bg-yellow-50 text-yellow-800",
|
||||
severity === "warning" && "bg-yellow-50 text-yellow-700",
|
||||
severity === "success" && "bg-gray-900 text-white"
|
||||
)}>
|
||||
<div className="flex">
|
||||
@@ -32,10 +33,11 @@ export function Alert(props: AlertProps) {
|
||||
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="ml-3 flex-grow">
|
||||
<h3 className="text-sm font-medium">{props.title}</h3>
|
||||
<div className="text-sm">{props.message}</div>
|
||||
</div>
|
||||
{props.actions && <div className="text-sm">{props.actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
|
||||
import { Maybe } from "@trpc/server";
|
||||
|
||||
export type AvatarProps = {
|
||||
className?: string;
|
||||
size: number;
|
||||
imageSrc?: string;
|
||||
size?: number;
|
||||
imageSrc?: Maybe<string>;
|
||||
title?: string;
|
||||
alt: string;
|
||||
gravatarFallbackMd5?: string;
|
||||
};
|
||||
|
||||
export default function Avatar({ imageSrc, gravatarFallbackMd5, size, alt, title, ...props }: AvatarProps) {
|
||||
const className = classNames("rounded-full", props.className, `h-${size} w-${size}`);
|
||||
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}`);
|
||||
const avatar = (
|
||||
<AvatarPrimitive.Root>
|
||||
<AvatarPrimitive.Image
|
||||
src={imageSrc}
|
||||
src={imageSrc ?? undefined}
|
||||
alt={alt}
|
||||
className={classNames("rounded-full", `h-auto w-${size}`, props.className)}
|
||||
/>
|
||||
@@ -32,7 +36,7 @@ export default function Avatar({ imageSrc, gravatarFallbackMd5, size, alt, title
|
||||
return title ? (
|
||||
<Tooltip.Tooltip delayDuration={300}>
|
||||
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
|
||||
<Tooltip.Content className="p-2 rounded-sm text-sm bg-black text-white shadow-sm">
|
||||
<Tooltip.Content className="p-2 rounded-sm text-sm bg-brand text-white shadow-sm">
|
||||
<Tooltip.Arrow />
|
||||
{title}
|
||||
</Tooltip.Content>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from "react";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
// import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
|
||||
export type AvatarGroupProps = {
|
||||
@@ -37,7 +40,7 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
<span className="w-16 absolute bottom-1.5 border-2 border-gray-300 flex-inline items-center text-white pt-4 text-2xl top-0 rounded-full block bg-neutral-600">+1</span>
|
||||
</Tooltip.TooltipTrigger>
|
||||
{truncatedAvatars.length !== 0 && (
|
||||
<Tooltip.Content className="p-2 rounded-sm text-sm bg-black text-white shadow-sm">
|
||||
<Tooltip.Content className="p-2 rounded-sm text-sm bg-brand text-white shadow-sm">
|
||||
<Tooltip.Arrow />
|
||||
<ul>
|
||||
{truncatedAvatars.map((title) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import classNames from "@lib/classNames";
|
||||
import React from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export type BadgeProps = {
|
||||
variant: "default" | "success" | "gray";
|
||||
} & JSX.IntrinsicElements["span"];
|
||||
@@ -12,7 +13,7 @@ export const Badge = function Badge(props: BadgeProps) {
|
||||
<span
|
||||
{...passThroughProps}
|
||||
className={classNames(
|
||||
"font-bold px-2 py-0.5 inline-block rounded-sm",
|
||||
"font-bold px-2 py-0.5 inline-block rounded-sm text-xs",
|
||||
variant === "default" && "bg-yellow-100 text-yellow-800",
|
||||
variant === "success" && "bg-green-100 text-green-800",
|
||||
variant === "gray" && "bg-gray-200 text-gray-800",
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import classNames from "@lib/classNames";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
type SVGComponent = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
import classNames from "@lib/classNames";
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
export type ButtonProps = {
|
||||
export type ButtonBaseProps = {
|
||||
color?: "primary" | "secondary" | "minimal" | "warn";
|
||||
size?: "base" | "sm" | "lg" | "fab";
|
||||
size?: "base" | "sm" | "lg" | "fab" | "icon";
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
StartIcon?: SVGComponent;
|
||||
EndIcon?: SVGComponent;
|
||||
shallow?: boolean;
|
||||
} & (
|
||||
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
|
||||
| (JSX.IntrinsicElements["button"] & { href?: never })
|
||||
);
|
||||
};
|
||||
export type ButtonProps = ButtonBaseProps &
|
||||
(
|
||||
| (Omit<JSX.IntrinsicElements["a"], "href"> & { href: LinkProps["href"] })
|
||||
| (JSX.IntrinsicElements["button"] & { href?: never })
|
||||
);
|
||||
|
||||
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
|
||||
props: ButtonProps,
|
||||
@@ -52,6 +54,8 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
||||
size === "sm" && "px-3 py-2 text-sm leading-4 font-medium rounded-sm",
|
||||
size === "base" && "px-3 py-2 text-sm font-medium rounded-sm",
|
||||
size === "lg" && "px-4 py-2 text-base font-medium rounded-sm",
|
||||
size === "icon" &&
|
||||
"group p-2 border rounded-sm border-transparent text-neutral-400 hover:border-gray-200 transition",
|
||||
// turn button into a floating action button (fab)
|
||||
size === "fab" ? "fixed" : "relative",
|
||||
size === "fab" && "justify-center bottom-20 right-8 rounded-full p-4 w-14 h-14",
|
||||
@@ -60,7 +64,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
||||
color === "primary" &&
|
||||
(disabled
|
||||
? "border border-transparent bg-gray-400 text-white"
|
||||
: "border border-transparent text-white bg-neutral-900 hover:bg-neutral-800 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
|
||||
: "border border-transparent dark:text-black text-white bg-brand dark:bg-white hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
|
||||
color === "secondary" &&
|
||||
(disabled
|
||||
? "border border-gray-200 text-gray-400 bg-white"
|
||||
@@ -72,7 +76,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
||||
color === "warn" &&
|
||||
(disabled
|
||||
? "text-gray-400 bg-transparent"
|
||||
: "text-red-700 bg-transparent hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"),
|
||||
: "text-gray-700 bg-transparent hover:text-red-700 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"),
|
||||
// set not-allowed cursor if disabled
|
||||
loading ? "cursor-wait" : disabled ? "cursor-not-allowed" : "",
|
||||
props.className
|
||||
@@ -85,14 +89,21 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
||||
: props.onClick,
|
||||
},
|
||||
<>
|
||||
{StartIcon && <StartIcon className="inline w-5 h-5 mr-2 -ml-1" />}
|
||||
{StartIcon && (
|
||||
<StartIcon
|
||||
className={classNames(
|
||||
"inline",
|
||||
size === "icon" ? "w-5 h-5 group-hover:text-black" : "w-5 h-5 mr-2 -ml-1"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{props.children}
|
||||
{loading && (
|
||||
<div className="absolute transform -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2">
|
||||
<svg
|
||||
className={classNames(
|
||||
"w-5 h-5 mx-4 animate-spin",
|
||||
color === "primary" ? "text-white" : "text-black"
|
||||
color === "primary" ? "dark:text-black text-white" : "text-black"
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
|
||||
28
components/ui/PoweredByCal.tsx
Normal file
28
components/ui/PoweredByCal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
const PoweredByCal = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="text-xs text-center sm:text-right p-1">
|
||||
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
|
||||
<a target="_blank" className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
|
||||
{t("powered_by")}{" "}
|
||||
<img
|
||||
className="dark:hidden w-auto inline h-[10px] relative -mt-px"
|
||||
src="https://cal.com/logo.svg"
|
||||
alt="Cal.com Logo"
|
||||
/>
|
||||
<img
|
||||
className="hidden dark:inline w-auto h-[10px] relativ -mt-px"
|
||||
src="https://cal.com/logo-white.svg"
|
||||
alt="Cal.com Logo"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PoweredByCal;
|
||||
@@ -1,25 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const PoweredByCalendso = () => (
|
||||
<div className="text-xs text-center sm:text-right p-1">
|
||||
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
|
||||
<a target="_blank" className="dark:text-white text-gray-500 opacity-50 hover:opacity-100">
|
||||
powered by{" "}
|
||||
<img
|
||||
style={{ top: -2 }}
|
||||
className="dark:hidden w-auto inline h-3 relative"
|
||||
src="/calendso-logo-word.svg"
|
||||
alt="Cal.com Logo"
|
||||
/>
|
||||
<img
|
||||
style={{ top: -2 }}
|
||||
className="hidden dark:inline w-auto h-3 relative"
|
||||
src="/calendso-logo-word-dark.svg"
|
||||
alt="Cal.com Logo"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PoweredByCalendso;
|
||||
@@ -1,333 +0,0 @@
|
||||
import React from "react";
|
||||
import Text from "@components/ui/Text";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import classnames from "classnames";
|
||||
|
||||
export const SCHEDULE_FORM_ID = "SCHEDULE_FORM_ID";
|
||||
export const toCalendsoAvailabilityFormat = (schedule: Schedule) => {
|
||||
return schedule;
|
||||
};
|
||||
|
||||
export const _24_HOUR_TIME_FORMAT = `HH:mm:ss`;
|
||||
|
||||
const DEFAULT_START_TIME = "09:00:00";
|
||||
const DEFAULT_END_TIME = "17:00:00";
|
||||
|
||||
/** Begin Time Increments For Select */
|
||||
const increment = 15;
|
||||
|
||||
/**
|
||||
* Creates an array of times on a 15 minute interval from
|
||||
* 00:00:00 (Start of day) to
|
||||
* 23:45:00 (End of day with enough time for 15 min booking)
|
||||
*/
|
||||
const TIMES = (() => {
|
||||
const starting_time = dayjs().startOf("day");
|
||||
const ending_time = dayjs().endOf("day");
|
||||
|
||||
const times = [];
|
||||
let t: Dayjs = starting_time;
|
||||
|
||||
while (t.isBefore(ending_time)) {
|
||||
times.push(t);
|
||||
t = t.add(increment, "minutes");
|
||||
}
|
||||
return times;
|
||||
})();
|
||||
/** End Time Increments For Select */
|
||||
|
||||
const DEFAULT_SCHEDULE: Schedule = {
|
||||
monday: [{ start: "09:00:00", end: "17:00:00" }],
|
||||
tuesday: [{ start: "09:00:00", end: "17:00:00" }],
|
||||
wednesday: [{ start: "09:00:00", end: "17:00:00" }],
|
||||
thursday: [{ start: "09:00:00", end: "17:00:00" }],
|
||||
friday: [{ start: "09:00:00", end: "17:00:00" }],
|
||||
saturday: null,
|
||||
sunday: null,
|
||||
};
|
||||
|
||||
type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
|
||||
export type TimeRange = {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export type FreeBusyTime = TimeRange[];
|
||||
|
||||
export type Schedule = {
|
||||
monday?: FreeBusyTime | null;
|
||||
tuesday?: FreeBusyTime | null;
|
||||
wednesday?: FreeBusyTime | null;
|
||||
thursday?: FreeBusyTime | null;
|
||||
friday?: FreeBusyTime | null;
|
||||
saturday?: FreeBusyTime | null;
|
||||
sunday?: FreeBusyTime | null;
|
||||
};
|
||||
|
||||
type ScheduleBlockProps = {
|
||||
day: DayOfWeek;
|
||||
ranges?: FreeBusyTime | null;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
schedule?: Schedule;
|
||||
onChange?: (data: Schedule) => void;
|
||||
onSubmit: (data: Schedule) => void;
|
||||
};
|
||||
|
||||
const SchedulerForm = ({ schedule = DEFAULT_SCHEDULE, onSubmit }: Props) => {
|
||||
const ref = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const transformElementsToSchedule = (elements: HTMLFormControlsCollection): Schedule => {
|
||||
const schedule: Schedule = {};
|
||||
const formElements = Array.from(elements)
|
||||
.map((element) => {
|
||||
return element.id;
|
||||
})
|
||||
.filter((value) => value);
|
||||
|
||||
/**
|
||||
* elementId either {day} or {day.N.start} or {day.N.end}
|
||||
* If elementId in DAYS_ARRAY add elementId to scheduleObj
|
||||
* then element is the checkbox and can be ignored
|
||||
*
|
||||
* If elementId starts with a day in DAYS_ARRAY
|
||||
* the elementId should be split by "." resulting in array length 3
|
||||
* [day, rangeIndex, "start" | "end"]
|
||||
*/
|
||||
formElements.forEach((elementId) => {
|
||||
const [day, rangeIndex, rangeId] = elementId.split(".");
|
||||
if (rangeIndex && rangeId) {
|
||||
if (!schedule[day]) {
|
||||
schedule[day] = [];
|
||||
}
|
||||
|
||||
if (!schedule[day][parseInt(rangeIndex)]) {
|
||||
schedule[day][parseInt(rangeIndex)] = {};
|
||||
}
|
||||
|
||||
schedule[day][parseInt(rangeIndex)][rangeId] = elements[elementId].value;
|
||||
}
|
||||
});
|
||||
|
||||
return schedule;
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const elements = ref.current?.elements;
|
||||
if (elements) {
|
||||
const schedule = transformElementsToSchedule(elements);
|
||||
onSubmit && typeof onSubmit === "function" && onSubmit(schedule);
|
||||
}
|
||||
};
|
||||
|
||||
const ScheduleBlock = ({ day, ranges: defaultRanges, selected: defaultSelected }: ScheduleBlockProps) => {
|
||||
const [ranges, setRanges] = React.useState(defaultRanges);
|
||||
const [selected, setSelected] = React.useState(defaultSelected);
|
||||
React.useEffect(() => {
|
||||
if (!ranges || ranges.length === 0) {
|
||||
setSelected(false);
|
||||
} else {
|
||||
setSelected(true);
|
||||
}
|
||||
}, [ranges]);
|
||||
|
||||
const handleSelectedChange = () => {
|
||||
if (!selected && (!ranges || ranges.length === 0)) {
|
||||
setRanges([
|
||||
{
|
||||
start: "09:00:00",
|
||||
end: "17:00:00",
|
||||
},
|
||||
]);
|
||||
}
|
||||
setSelected(!selected);
|
||||
};
|
||||
|
||||
const handleAddRange = () => {
|
||||
let rangeToAdd;
|
||||
if (!ranges || ranges?.length === 0) {
|
||||
rangeToAdd = {
|
||||
start: DEFAULT_START_TIME,
|
||||
end: DEFAULT_END_TIME,
|
||||
};
|
||||
setRanges([rangeToAdd]);
|
||||
} else {
|
||||
const lastRange = ranges[ranges.length - 1];
|
||||
|
||||
const [hour, minute, second] = lastRange.end.split(":");
|
||||
const date = dayjs()
|
||||
.set("hour", parseInt(hour))
|
||||
.set("minute", parseInt(minute))
|
||||
.set("second", parseInt(second));
|
||||
const nextStartTime = date.add(1, "hour");
|
||||
const nextEndTime = date.add(2, "hour");
|
||||
|
||||
/**
|
||||
* If next range goes over into "tomorrow"
|
||||
* i.e. time greater that last value in Times
|
||||
* return
|
||||
*/
|
||||
if (nextStartTime.isAfter(date.endOf("day"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
rangeToAdd = {
|
||||
start: nextStartTime.format(_24_HOUR_TIME_FORMAT),
|
||||
end: nextEndTime.format(_24_HOUR_TIME_FORMAT),
|
||||
};
|
||||
setRanges([...ranges, rangeToAdd]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRange = (range: TimeRange) => {
|
||||
if (ranges && ranges.length > 0) {
|
||||
setRanges(
|
||||
ranges.filter((r: TimeRange) => {
|
||||
return r.start != range.start;
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Should update ranges values
|
||||
*/
|
||||
const handleSelectRangeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const [day, rangeIndex, rangeId] = event.currentTarget.name.split(".");
|
||||
|
||||
if (day && ranges) {
|
||||
const newRanges = ranges.map((range, index) => {
|
||||
const newRange = {
|
||||
...range,
|
||||
[rangeId]: event.currentTarget.value,
|
||||
};
|
||||
return index === parseInt(rangeIndex) ? newRange : range;
|
||||
});
|
||||
|
||||
setRanges(newRanges);
|
||||
}
|
||||
};
|
||||
|
||||
const TimeRangeField = ({ range, day, index }: { range: TimeRange; day: DayOfWeek; index: number }) => {
|
||||
const timeOptions = (type: "start" | "end") =>
|
||||
TIMES.map((time) => (
|
||||
<option
|
||||
key={`${day}.${index}.${type}.${time.format(_24_HOUR_TIME_FORMAT)}`}
|
||||
value={time.format(_24_HOUR_TIME_FORMAT)}>
|
||||
{time.toDate().toLocaleTimeString(undefined, { minute: "numeric", hour: "numeric" })}
|
||||
</option>
|
||||
));
|
||||
return (
|
||||
<div key={`${day}-range-${index}`} className="flex items-center justify-between space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
id={`${day}.${index}.start`}
|
||||
name={`${day}.${index}.start`}
|
||||
defaultValue={range?.start || DEFAULT_START_TIME}
|
||||
onChange={handleSelectRangeChange}
|
||||
className="block px-4 pr-8 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-sm">
|
||||
{timeOptions("start")}
|
||||
</select>
|
||||
<Text>-</Text>
|
||||
<select
|
||||
id={`${day}.${index}.end`}
|
||||
name={`${day}.${index}.end`}
|
||||
defaultValue={range?.end || DEFAULT_END_TIME}
|
||||
onChange={handleSelectRangeChange}
|
||||
className=" block px-4 pr-8 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-sm">
|
||||
{timeOptions("end")}
|
||||
</select>
|
||||
</div>
|
||||
<div className="">
|
||||
<DeleteAction range={range} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Actions = () => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button type="button" onClick={() => handleAddRange()}>
|
||||
<PlusIcon className="h-5 w-5 text-neutral-400 hover:text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteAction = ({ range }: { range: TimeRange }) => {
|
||||
return (
|
||||
<button type="button" onClick={() => handleDeleteRange(range)}>
|
||||
<TrashIcon className="h-5 w-5 text-neutral-400 hover:text-neutral-500" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className=" py-6">
|
||||
<section
|
||||
className={classnames(
|
||||
"flex flex-col space-y-6 sm:space-y-0 sm:flex-row sm:justify-between",
|
||||
ranges && ranges?.length > 1 ? "sm:items-start" : "sm:items-center"
|
||||
)}>
|
||||
<div style={{ minWidth: "33%" }} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 ">
|
||||
<input
|
||||
id={day}
|
||||
name={day}
|
||||
checked={selected}
|
||||
onChange={handleSelectedChange}
|
||||
type="checkbox"
|
||||
className="focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm"
|
||||
/>
|
||||
<Text variant="overline">{day}</Text>
|
||||
</div>
|
||||
<div className="sm:hidden justify-self-end self-end">
|
||||
<Actions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 w-full">
|
||||
{selected && ranges && ranges.length != 0 ? (
|
||||
ranges.map((range, index) => (
|
||||
<TimeRangeField key={`${day}-range-${index}`} range={range} index={index} day={day} />
|
||||
))
|
||||
) : (
|
||||
<Text key={`${day}`} variant="caption">
|
||||
Unavailable
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block px-2">
|
||||
<Actions />
|
||||
</div>
|
||||
</section>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id={SCHEDULE_FORM_ID} onSubmit={handleSubmit} ref={ref} className="divide-y divide-gray-200">
|
||||
{Object.keys(schedule).map((day) => {
|
||||
const selected = schedule[day as DayOfWeek] != null;
|
||||
return (
|
||||
<ScheduleBlock
|
||||
key={`${day}`}
|
||||
day={day as DayOfWeek}
|
||||
ranges={schedule[day as DayOfWeek]}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerForm;
|
||||
@@ -1,12 +1,16 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import TimezoneSelect from "react-timezone-select";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { Availability } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { OpeningHours, DateOverride } from "@lib/types/event-type";
|
||||
|
||||
import { WeekdaySelect } from "./WeekdaySelect";
|
||||
import SetTimesModal from "./modal/SetTimesModal";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { Availability } from "@prisma/client";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
@@ -14,43 +18,30 @@ dayjs.extend(timezone);
|
||||
type Props = {
|
||||
timeZone: string;
|
||||
availability: Availability[];
|
||||
setTimeZone: unknown;
|
||||
setTimeZone: (timeZone: string) => void;
|
||||
setAvailability: (schedule: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }) => void;
|
||||
};
|
||||
|
||||
export const Scheduler = ({
|
||||
availability,
|
||||
setAvailability,
|
||||
timeZone: selectedTimeZone,
|
||||
setTimeZone,
|
||||
}: Props) => {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone }: Props) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const [editSchedule, setEditSchedule] = useState(-1);
|
||||
const [dateOverrides, setDateOverrides] = useState([]);
|
||||
const [openingHours, setOpeningHours] = useState([]);
|
||||
const [openingHours, setOpeningHours] = useState<Availability[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpeningHours(
|
||||
availability
|
||||
.filter((item: Availability) => item.days.length !== 0)
|
||||
.map((item) => {
|
||||
item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes");
|
||||
item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes");
|
||||
return item;
|
||||
})
|
||||
);
|
||||
setDateOverrides(availability.filter((item: Availability) => item.date));
|
||||
setOpeningHours(availability.filter((item: Availability) => item.days.length !== 0));
|
||||
}, []);
|
||||
|
||||
// updates availability to how it should be formatted outside this component.
|
||||
useEffect(() => {
|
||||
setAvailability({
|
||||
dateOverrides: dateOverrides,
|
||||
openingHours: openingHours,
|
||||
});
|
||||
}, [dateOverrides, openingHours]);
|
||||
setAvailability({ openingHours, dateOverrides: [] });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openingHours]);
|
||||
|
||||
const addNewSchedule = () => setEditSchedule(openingHours.length);
|
||||
|
||||
const applyEditSchedule = (changed) => {
|
||||
const applyEditSchedule = (changed: Availability) => {
|
||||
// new entry
|
||||
if (!changed.days) {
|
||||
changed.days = [1, 2, 3, 4, 5]; // Mon - Fri
|
||||
@@ -59,39 +50,33 @@ export const Scheduler = ({
|
||||
// update
|
||||
const replaceWith = { ...openingHours[editSchedule], ...changed };
|
||||
openingHours.splice(editSchedule, 1, replaceWith);
|
||||
setOpeningHours([].concat(openingHours));
|
||||
setOpeningHours([...openingHours]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeScheduleAt = (toRemove: number) => {
|
||||
openingHours.splice(toRemove, 1);
|
||||
setOpeningHours([].concat(openingHours));
|
||||
setOpeningHours([...openingHours]);
|
||||
};
|
||||
|
||||
const OpeningHours = ({ idx, item }) => (
|
||||
<li className="py-2 flex justify-between border-b">
|
||||
const OpeningHours = ({ idx, item }: { idx: number; item: Availability }) => (
|
||||
<li className="flex justify-between py-2 border-b">
|
||||
<div className="flex flex-col space-y-4 lg:inline-flex">
|
||||
<WeekdaySelect defaultValue={item.days} onSelect={(selected: number[]) => (item.days = selected)} />
|
||||
<button
|
||||
className="text-sm bg-neutral-100 rounded-sm py-2 px-3"
|
||||
className="px-3 py-2 text-sm rounded-sm bg-neutral-100"
|
||||
type="button"
|
||||
onClick={() => setEditSchedule(idx)}>
|
||||
{dayjs()
|
||||
.startOf("day")
|
||||
.add(item.startTime, "minutes")
|
||||
.format(item.startTime % 60 === 0 ? "ha" : "h:mma")}
|
||||
until
|
||||
{dayjs()
|
||||
.startOf("day")
|
||||
.add(item.endTime, "minutes")
|
||||
.format(item.endTime % 60 === 0 ? "ha" : "h:mma")}
|
||||
{item.startTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
|
||||
{t("until")}
|
||||
{item.endTime.toLocaleTimeString(i18n.language, { hour: "numeric", minute: "2-digit" })}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeScheduleAt(idx)}
|
||||
className="btn-sm bg-transparent px-2 py-1 ml-1">
|
||||
<TrashIcon className="h-5 w-5 inline text-gray-400 -mt-1" />
|
||||
className="px-2 py-1 ml-1 bg-transparent btn-sm">
|
||||
<TrashIcon className="inline w-5 h-5 -mt-1 text-gray-400" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
@@ -100,16 +85,16 @@ export const Scheduler = ({
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div className="w-full">
|
||||
<div className="">
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
Timezone
|
||||
{t("timezone")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={{ value: selectedTimeZone }}
|
||||
onChange={(tz) => setTimeZone(tz.value)}
|
||||
className="shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
value={timeZone}
|
||||
onChange={(tz: ITimezoneOption) => setTimeZone(tz.value)}
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,16 +103,36 @@ export const Scheduler = ({
|
||||
<OpeningHours key={idx} idx={idx} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={addNewSchedule} className="btn-white btn-sm mt-2">
|
||||
Add another
|
||||
<button type="button" onClick={addNewSchedule} className="mt-2 btn-white btn-sm">
|
||||
{t("add_another")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{editSchedule >= 0 && (
|
||||
<SetTimesModal
|
||||
startTime={openingHours[editSchedule] ? openingHours[editSchedule].startTime : 540}
|
||||
endTime={openingHours[editSchedule] ? openingHours[editSchedule].endTime : 1020}
|
||||
onChange={(times) => applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })}
|
||||
startTime={
|
||||
openingHours[editSchedule]
|
||||
? new Date(openingHours[editSchedule].startTime).getHours() * 60 +
|
||||
new Date(openingHours[editSchedule].startTime).getMinutes()
|
||||
: 540
|
||||
}
|
||||
endTime={
|
||||
openingHours[editSchedule]
|
||||
? new Date(openingHours[editSchedule].endTime).getHours() * 60 +
|
||||
new Date(openingHours[editSchedule].endTime).getMinutes()
|
||||
: 1020
|
||||
}
|
||||
onChange={(times: { startTime: number; endTime: number }) =>
|
||||
applyEditSchedule({
|
||||
...(openingHours[editSchedule] || {}),
|
||||
startTime: new Date(
|
||||
new Date().setHours(Math.floor(times.startTime / 60), times.startTime % 60, 0, 0)
|
||||
),
|
||||
endTime: new Date(
|
||||
new Date().setHours(Math.floor(times.endTime / 60), times.endTime % 60, 0, 0)
|
||||
),
|
||||
})
|
||||
}
|
||||
onExit={() => setEditSchedule(-1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import * as PrimitiveSwitch from "@radix-ui/react-switch";
|
||||
import { useId } from "@radix-ui/react-id";
|
||||
import * as Label from "@radix-ui/react-label";
|
||||
import * as PrimitiveSwitch from "@radix-ui/react-switch";
|
||||
import React, { useState } from "react";
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export default function Switch(props) {
|
||||
type SwitchProps = React.ComponentProps<typeof PrimitiveSwitch.Root> & {
|
||||
label: string;
|
||||
};
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const { label, onCheckedChange, ...primitiveProps } = props;
|
||||
const [checked, setChecked] = useState(props.defaultChecked || false);
|
||||
|
||||
@@ -16,7 +18,7 @@ export default function Switch(props) {
|
||||
}
|
||||
setChecked(change);
|
||||
};
|
||||
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex items-center h-[20px]">
|
||||
<PrimitiveSwitch.Root
|
||||
@@ -25,6 +27,7 @@ export default function Switch(props) {
|
||||
onCheckedChange={onPrimitiveCheckedChange}
|
||||
{...primitiveProps}>
|
||||
<PrimitiveSwitch.Thumb
|
||||
id={id}
|
||||
className={classNames(
|
||||
"bg-white w-[16px] h-[16px] block transition-transform",
|
||||
checked ? "translate-x-[16px]" : "translate-x-0"
|
||||
@@ -32,7 +35,9 @@ export default function Switch(props) {
|
||||
/>
|
||||
</PrimitiveSwitch.Root>
|
||||
{label && (
|
||||
<Label.Root className="text-neutral-700 align-text-top ml-3 font-medium cursor-pointer">
|
||||
<Label.Root
|
||||
htmlFor={id}
|
||||
className="text-neutral-700 text-sm align-text-top ml-3 font-medium cursor-pointer">
|
||||
{label}
|
||||
</Label.Root>
|
||||
)}
|
||||
|
||||
96
components/ui/TableActions.tsx
Normal file
96
components/ui/TableActions.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import React, { FC, Fragment } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
import Button from "./Button";
|
||||
|
||||
export type ActionType = {
|
||||
id: string;
|
||||
icon: SVGComponent;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
color?: "primary" | "secondary";
|
||||
} & ({ href?: never; onClick: () => any } | { href: string; onClick?: never });
|
||||
|
||||
interface Props {
|
||||
actions: ActionType[];
|
||||
}
|
||||
|
||||
const TableActions: FC<Props> = ({ actions }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<div className="space-x-2 hidden lg:block">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
data-testid={action.id}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
StartIcon={action.icon}
|
||||
disabled={action.disabled}
|
||||
color={action.color || "secondary"}>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Menu as="div" className="inline-block lg:hidden text-left ">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="text-neutral-400 mt-1 p-2 border border-transparent hover:border-gray-200">
|
||||
<span className="sr-only">{t("open_options")}</span>
|
||||
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className="origin-top-right absolute right-0 mt-2 w-56 rounded-sm shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none divide-y divide-neutral-100">
|
||||
<div className="py-1">
|
||||
{actions.map((action) => {
|
||||
const Element = typeof action.onClick === "function" ? "span" : "a";
|
||||
return (
|
||||
<Menu.Item key={action.id} disabled={action.disabled}>
|
||||
{({ active }) => (
|
||||
<Element
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
className={classNames(
|
||||
active ? "bg-neutral-100 text-neutral-900" : "text-neutral-700",
|
||||
"group flex items-center px-4 py-2 text-sm font-medium"
|
||||
)}>
|
||||
<action.icon
|
||||
className="mr-3 h-5 w-5 text-neutral-400 group-hover:text-neutral-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{action.label}
|
||||
</Element>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableActions;
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Body: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-lg leading-relaxed text-gray-900 dark:text-white", props?.className);
|
||||
const classes = classnames("text-gray-900 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import Body from "./Body";
|
||||
|
||||
export default Body;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Caption: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import Caption from "./Caption";
|
||||
|
||||
export default Caption;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Caption2: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import Caption2 from "./Caption2";
|
||||
|
||||
export default Caption2;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Footnote: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import Footnote from "./Footnote";
|
||||
|
||||
export default Footnote;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Headline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-xl font-bold text-gray-900 dark:text-white", props?.className);
|
||||
const classes = classnames("font-cal text-xl font-bold text-gray-900 dark:text-white", props?.className);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import Headline from "./Headline";
|
||||
|
||||
export default Headline;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Largetitle: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames("text-3xl font-extrabold text-gray-900 dark:text-white", props?.className);
|
||||
const classes = classnames(
|
||||
"font-cal tracking-wider text-3xl text-gray-900 dark:text-white mb-2",
|
||||
props?.className
|
||||
);
|
||||
|
||||
return <p className={classes}>{props?.text || props.children}</p>;
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import Largetitle from "./Largetitle";
|
||||
|
||||
export default Largetitle;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Overline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
const classes = classnames(
|
||||
"text-sm uppercase font-semibold leading-snug tracking-wide text-gray-900 dark:text-white",
|
||||
"text-sm capitalize font-medium text-gray-900 dark:text-white",
|
||||
props?.className
|
||||
);
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import Overline from "./Overline";
|
||||
|
||||
export default Overline;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { TextProps } from "../Text";
|
||||
|
||||
const Subheadline: React.FunctionComponent<TextProps> = (props: TextProps) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user