Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6758194b | ||
|
|
5528d60897 | ||
|
|
bb1c55e07f | ||
|
|
c899921591 | ||
|
|
ea34545d72 | ||
|
|
9d3540511b | ||
|
|
e8cf92361a | ||
|
|
e8525b963b | ||
|
|
24dabd0ddc | ||
|
|
5203168b2e | ||
|
|
c3e40f84a9 | ||
|
|
8e2ece7526 | ||
|
|
b6eca6e083 | ||
|
|
4cb264f1f7 | ||
|
|
e248fda36b | ||
|
|
1c97e549eb | ||
|
|
77600f8168 | ||
|
|
6cad606ba1 | ||
|
|
7cae7a7bd4 | ||
|
|
93f65807e7 | ||
|
|
0a63820699 | ||
|
|
05ad437b27 | ||
|
|
26a57a71ae | ||
|
|
34a10205a3 | ||
|
|
fbf8ff6db7 | ||
|
|
bef6d18fdf | ||
|
|
2e96e2ef3f | ||
|
|
2ca6769a70 | ||
|
|
1eb514dfcf | ||
|
|
d6c004ba22 | ||
|
+17 |
afb9605f0a | ||
|
|
a2549bd7b6 | ||
|
|
c2a8f799ff | ||
|
|
c68ff54e77 | ||
|
|
74fe20802d | ||
|
|
d59cda3286 | ||
|
|
1cae2b6f3d | ||
|
|
9d9fa8e575 | ||
|
|
f04c634c48 | ||
|
|
c3e7981917 | ||
|
|
d6594dab0f | ||
|
|
5fcfbb2d90 | ||
|
|
8a99c3d2a5 | ||
|
|
336c2d3643 | ||
|
|
cfe3ef4ab0 | ||
|
|
4ec8241a86 | ||
|
|
e6313aafac | ||
|
|
f87f030bb5 | ||
|
|
f87254cdc3 | ||
|
|
1f74837189 | ||
|
|
c7064906c9 | ||
|
|
86c94a3cd5 | ||
|
|
2056ebe186 | ||
|
|
53424ca9ae | ||
|
|
8197ba18d4 | ||
|
|
d341ea5111 | ||
|
|
42dcd4a2a9 | ||
|
|
c63225fdbf | ||
|
|
41774e4932 | ||
|
|
af7849f951 | ||
|
|
5353610d9a | ||
|
+14 |
156f3e5de2 | ||
|
|
05f51459a6 | ||
|
|
f0db97bbb1 | ||
|
|
05c33bd94b | ||
|
|
7d4cc59cd2 | ||
|
|
a94465ff04 | ||
|
|
4d54ac3ca2 | ||
|
|
15da690270 | ||
|
|
2462e8e597 | ||
|
|
2f628a17df | ||
|
|
1ee3783db3 | ||
|
|
b3d8d7e944 | ||
|
|
a4b7f1af65 | ||
|
|
f7445bf8a1 | ||
|
|
9cad7086d7 | ||
|
|
3345ec61ee | ||
|
|
7797421ce9 | ||
|
|
075a274207 | ||
|
|
a46a31040a | ||
|
|
7a28e1d6f4 | ||
|
|
51fd7fdb3e | ||
|
|
d01963303a | ||
|
|
6877d0f693 | ||
|
|
39040fca9a | ||
|
|
6fa4befc9b | ||
|
|
8c9871c6ce | ||
|
+8 |
a9af2fb255 | ||
|
|
8c150264f4 | ||
|
|
cfc3644d51 | ||
|
|
bdb4a7353b | ||
|
|
30c0e6d1d7 | ||
|
|
390967990a | ||
|
|
cc56602474 | ||
|
|
43a044d59e | ||
|
|
5b8ed9c3ef | ||
|
|
c3d6df2dcf | ||
|
|
9d02cb493c | ||
|
|
26ed478e50 | ||
|
|
09e338970d | ||
|
|
f60399ca8b | ||
|
|
5cf7e32852 | ||
|
|
24be8a63f2 | ||
|
|
75509c97fd | ||
|
|
9b26c7aee6 | ||
|
|
f18e80cfcf | ||
|
|
0a4b1ca014 | ||
|
|
9fa6aced33 | ||
|
|
9093909075 | ||
|
|
832fce91e0 | ||
|
|
6da41fce63 | ||
|
|
3b51cc9aef | ||
|
|
50c8959cfa | ||
|
|
c96d258f5e | ||
|
|
6e45b1c383 | ||
|
|
551ebe863b | ||
|
|
fa7b94bfe9 | ||
|
|
56c5d39cd3 | ||
|
|
7574fb6f44 | ||
|
|
901d1f0e01 | ||
|
|
828fcbe1d9 | ||
|
|
34db46869e | ||
|
|
1f1cab2be1 | ||
|
|
6cf53feaf8 | ||
|
|
376b693d91 | ||
|
|
831aa72d23 | ||
|
|
ef78db7856 | ||
|
|
7ee5967075 | ||
|
|
5b165e6ebf | ||
|
|
624f52c305 | ||
|
|
df25ae6a3d | ||
|
|
7f65d7f3c3 | ||
|
|
c42674595c | ||
|
|
17eb45b0b6 | ||
|
|
48d0edeaf4 | ||
|
|
ed4b31767f | ||
|
|
cebb10bfe4 | ||
|
|
7a6e7c2f1d | ||
|
|
0ffed24359 | ||
|
|
e121615d36 | ||
|
|
4f9aa8bd96 | ||
|
|
64ede37ad6 | ||
|
|
36b6c351ec | ||
|
|
9ab5a3149c | ||
|
|
3b8f436bdf | ||
|
|
77d45a6ec2 | ||
|
|
50c74cf286 | ||
|
|
dcbafced8b | ||
|
|
4679c80ea0 | ||
|
|
291316ee01 | ||
|
|
208fc2f0e4 | ||
|
|
99e71054ff | ||
|
|
9d118d4e34 | ||
|
+6 |
d1a877e70d | ||
|
|
238b509be6 | ||
|
|
c7471b404c | ||
|
|
4db4df8ace | ||
|
|
a15b8fb92e | ||
|
|
a4bed48eaf | ||
|
|
1be6f192d4 | ||
|
|
f1c5838aef | ||
|
|
8e994dcc33 | ||
|
|
506bbe3d31 | ||
|
|
419a84c5a2 | ||
|
|
8cf0050d1f | ||
|
|
6c23813192 | ||
|
|
875f922597 | ||
|
|
c8e3f6494a | ||
|
|
333801d2ae | ||
|
|
60f7528cbc | ||
|
|
0faf294367 | ||
|
|
87c178e53b | ||
|
|
7710b1b718 | ||
|
|
d71c6ccb31 | ||
|
|
33a4117fb5 | ||
|
|
a90419c40b | ||
|
|
16adf86c8c | ||
|
|
3b838c475a | ||
|
|
cf33106d8b | ||
|
|
a63f9ddf61 | ||
|
|
1cf83068b2 | ||
|
|
23de92f818 | ||
|
|
c6fc1f756b | ||
|
|
88b7c506f1 | ||
|
|
755aae969c | ||
|
|
2d50d09ce6 | ||
|
|
a22397dbb4 | ||
|
|
d1076982a7 | ||
|
|
d77e4f3c4a | ||
|
|
b1c0ea4c38 | ||
|
|
425e18f53c | ||
|
|
3611807838 | ||
|
|
5b1cd9e5ce | ||
|
|
00bff598ef | ||
|
|
3a8aa26c78 | ||
|
|
3c761f4451 | ||
|
|
8640eb9f52 | ||
|
|
abb5fd36f3 |
No files matched your search
+11
-4
@@ -1,20 +1,21 @@
|
|||||||
# ********** INDEX **********
|
# ********** INDEX **********
|
||||||
#
|
#
|
||||||
# - LICENSE
|
# - LICENSE (DEPRECATED)
|
||||||
# - DATABASE
|
# - DATABASE
|
||||||
# - SHARED
|
# - SHARED
|
||||||
# - NEXTAUTH
|
# - NEXTAUTH
|
||||||
# - E-MAIL SETTINGS
|
# - E-MAIL SETTINGS
|
||||||
|
|
||||||
# - LICENSE *************************************************************************************************
|
# - LICENSE (DEPRECATED) ************************************************************************************
|
||||||
# https://github.com/calendso/calendso/blob/main/LICENSE
|
# https://github.com/calcom/cal.com/blob/main/LICENSE
|
||||||
#
|
#
|
||||||
# Summary of terms:
|
# Summary of terms:
|
||||||
# - The codebase has to stay open source, whether it was modified or not
|
# - The codebase has to stay open source, whether it was modified or not
|
||||||
# - You can not repackage or sell the codebase
|
# - You can not repackage or sell the codebase
|
||||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||||
#
|
#
|
||||||
# To enable enterprise-only features, fill your license key in here.
|
# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow
|
||||||
|
# instructions. This environment variable is deprecated although still supported for backward compatibility.
|
||||||
# @see https://console.cal.com
|
# @see https://console.cal.com
|
||||||
CALCOM_LICENSE_KEY=
|
CALCOM_LICENSE_KEY=
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
@@ -157,3 +158,9 @@ NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc."
|
|||||||
# Set this to true in to disable new signups
|
# Set this to true in to disable new signups
|
||||||
# NEXT_PUBLIC_DISABLE_SIGNUP=true
|
# NEXT_PUBLIC_DISABLE_SIGNUP=true
|
||||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||||
|
|
||||||
|
# Content Security Policy
|
||||||
|
CSP_POLICY=
|
||||||
|
|
||||||
|
# Vercel Edge Config
|
||||||
|
EDGE_CONFIG=
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Cron - mark stale for inactive issues
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
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 every day (see https://crontab.guru)
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v7
|
||||||
|
with:
|
||||||
|
days-before-stale: 60
|
||||||
|
include-only-assigned: true
|
||||||
|
days-before-close: -1
|
||||||
|
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
runs-on: ubuntu-latest
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
name: "Next.js Bundle Analysis"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
# change this if your nextjs app does not live at the root of the repo
|
||||||
|
working-directory: ./apps/web/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
|
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||||
|
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET: secret
|
||||||
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
|
GOOGLE_LOGIN_ENABLED: true
|
||||||
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
|
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||||
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
|
||||||
|
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
|
||||||
|
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
|
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||||
|
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||||
|
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||||
|
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||||
|
PAYMENT_FEE_FIXED: 10
|
||||||
|
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||||
|
SAML_ADMINS: pro@example.com
|
||||||
|
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||||
|
NEXT_PUBLIC_IS_E2E: 1
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:12.1
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_DB: calendso
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16.x
|
||||||
|
cache: "yarn"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
uses: bahmutov/npm-install@v1
|
||||||
|
|
||||||
|
- name: Restore next build
|
||||||
|
uses: actions/cache@v2
|
||||||
|
id: restore-build-cache
|
||||||
|
env:
|
||||||
|
cache-name: cache-next-build
|
||||||
|
with:
|
||||||
|
path: apps/web/.next/cache
|
||||||
|
# change this if you prefer a more strict cache
|
||||||
|
key: ${{ runner.os }}-build-${{ env.cache-name }}
|
||||||
|
|
||||||
|
- name: Build next.js app
|
||||||
|
# change this if your site requires a custom build command
|
||||||
|
run: cd ../.. && yarn db-seed && yarn build
|
||||||
|
|
||||||
|
# Here's the first place where next-bundle-analysis' own script is used
|
||||||
|
# This step pulls the raw bundle stats for the current bundle
|
||||||
|
- name: Analyze bundle
|
||||||
|
run: npx -p nextjs-bundle-analysis report
|
||||||
|
|
||||||
|
- name: Upload bundle
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: bundle
|
||||||
|
path: apps/web/.next/analyze/__bundle_analysis.json
|
||||||
|
|
||||||
|
- name: Download base branch bundle stats
|
||||||
|
uses: dawidd6/action-download-artifact@v2
|
||||||
|
if: success() && github.event.number
|
||||||
|
with:
|
||||||
|
workflow: nextjs-bundle-analysis.yml
|
||||||
|
branch: ${{ github.event.pull_request.base.ref }}
|
||||||
|
path: apps/web/.next/analyze/base
|
||||||
|
|
||||||
|
# And here's the second place - this runs after we have both the current and
|
||||||
|
# base branch bundle stats, and will compare them to determine what changed.
|
||||||
|
# There are two configurable arguments that come from package.json:
|
||||||
|
#
|
||||||
|
# - budget: optional, set a budget (bytes) against which size changes are measured
|
||||||
|
# it's set to 350kb here by default, as informed by the following piece:
|
||||||
|
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
||||||
|
#
|
||||||
|
# - red-status-percentage: sets the percent size increase where you get a red
|
||||||
|
# status indicator, defaults to 20%
|
||||||
|
#
|
||||||
|
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
||||||
|
# entry in your package.json file.
|
||||||
|
- name: Compare with base branch bundle
|
||||||
|
if: success() && github.event.number
|
||||||
|
run: ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
||||||
|
|
||||||
|
- name: Get comment body
|
||||||
|
id: get-comment-body
|
||||||
|
if: success() && github.event.number
|
||||||
|
run: |
|
||||||
|
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||||
|
body="${body//'%'/'%25'}"
|
||||||
|
body="${body//$'\n'/'%0A'}"
|
||||||
|
body="${body//$'\r'/'%0D'}"
|
||||||
|
echo ::set-output name=body::$body
|
||||||
|
|
||||||
|
- name: Find Comment
|
||||||
|
uses: peter-evans/find-comment@v1
|
||||||
|
if: success() && github.event.number
|
||||||
|
id: fc
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body-includes: "<!-- __NEXTJS_BUNDLE -->"
|
||||||
|
|
||||||
|
- name: Create Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v1.4.4
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
|
||||||
|
- name: Update Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v1.4.4
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
edit-mode: replace
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: TODO to Issue
|
||||||
|
on:
|
||||||
|
pull_request_target: # So we can test on forks
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
MANUAL_COMMIT_REF:
|
||||||
|
description: "The SHA of the commit to get the diff for"
|
||||||
|
required: true
|
||||||
|
MANUAL_BASE_REF:
|
||||||
|
description: "By default, the commit entered above is compared to the one directly before it; to go back further, enter an earlier SHA here"
|
||||||
|
required: false
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
todos:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 2
|
||||||
|
- name: "Add TODOs to issues"
|
||||||
|
uses: "alstr/todo-to-issue-action@v4"
|
||||||
|
env:
|
||||||
|
MANUAL_COMMIT_REF: ${{ inputs.MANUAL_COMMIT_REF }}
|
||||||
|
MANUAL_BASE_REF: ${{ inputs.MANUAL_BASE_REF }}
|
||||||
|
with:
|
||||||
|
IDENTIFIERS: '[{"name": "TODO", "labels": ["🙋🏻♂️help wanted"]}, {"name": "FIXME", "labels": ["🐛 bug"]}]'
|
||||||
@@ -77,6 +77,9 @@ lint-results
|
|||||||
#Storybook
|
#Storybook
|
||||||
apps/storybook/build-storybook.log
|
apps/storybook/build-storybook.log
|
||||||
|
|
||||||
|
# Infisical
|
||||||
|
.infisical.json
|
||||||
|
|
||||||
# Snaplet
|
# Snaplet
|
||||||
.snaplet/snapshots
|
.snaplet/snapshots
|
||||||
.snaplet/structure.d.ts
|
.snaplet/structure.d.ts
|
||||||
|
|||||||
@@ -27,19 +27,19 @@
|
|||||||
<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://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://status.cal.com"><img src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
<a href="https://status.cal.com"><img src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||||
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
<a href="https://news.ycombinator.com/item?id=34507672"><img src="https://img.shields.io/badge/Hacker%20News-%231-%23FF6600" alt="Hacker News"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
||||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
|
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
|
||||||
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
||||||
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
||||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=flat"></a>
|
|
||||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||||
|
<a href="https://console.algora.io/org/cal/bounties?status=open"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fconsole.algora.io%2Fapi%2Fshields%2Fcal%2Fbounties%3Fstatus%3Dopen" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- ABOUT THE PROJECT -->
|
<!-- ABOUT THE PROJECT -->
|
||||||
@@ -57,18 +57,37 @@ Calendly and other scheduling tools are awesome. It made our lives massively eas
|
|||||||
|
|
||||||
That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data.
|
That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data.
|
||||||
|
|
||||||
## Product of the Month: April 2021
|
## Recognition
|
||||||
|
|
||||||
#### Support us on [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso)
|
#### Hacker News
|
||||||
|
|
||||||
|
<a href="https://news.ycombinator.com/item?id=34507672">
|
||||||
|
<img
|
||||||
|
style="width: 250px; height: 54px;" width="250" height="54"
|
||||||
|
alt="Featured on Hacker News"
|
||||||
|
src="https://hackernews-badge.vercel.app/api?id=34507672"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://news.ycombinator.com/item?id=26817795">
|
||||||
|
<img
|
||||||
|
style="width: 250px; height: 54px;" width="250" height="54"
|
||||||
|
alt="Featured on Hacker News"
|
||||||
|
src="https://hackernews-badge.vercel.app/api?id=26817795"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
#### [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso)
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
### Built With
|
### Built With
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/)
|
- [Next.js](https://nextjs.org/?ref=cal.com)
|
||||||
- [React](https://reactjs.org/)
|
- [tRPC](https://trpc.io/?ref=cal.com)
|
||||||
- [Tailwind](https://tailwindcss.com/)
|
- [React](https://reactjs.org/?ref=cal.com)
|
||||||
- [Prisma](https://prisma.io/)
|
- [Tailwind](https://tailwindcss.com/?ref=cal.com)
|
||||||
|
- [Prisma](https://prisma.io/?ref=cal.com)
|
||||||
|
|
||||||
## Stay Up-to-Date
|
## Stay Up-to-Date
|
||||||
|
|
||||||
@@ -321,6 +340,17 @@ Please see our [contributing guide](/CONTRIBUTING.md).
|
|||||||
|
|
||||||
We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||||
|
|
||||||
|
<!-- BOUNTIES -->
|
||||||
|
|
||||||
|
### Bounties
|
||||||
|
|
||||||
|
<a href="https://console.algora.io/org/cal/bounties?status=open">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=dark">
|
||||||
|
<img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
<!-- TRANSLATIONS -->
|
<!-- TRANSLATIONS -->
|
||||||
|
|
||||||
@@ -330,7 +360,9 @@ Don't code but still want to contribute? Join our [slack](https://cal.com/slack)
|
|||||||
|
|
||||||
                                             except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"keywords": ["react", "typescript", "node", "nextjs", "prisma", "postgres", "trpc"],
|
"keywords": ["react", "typescript", "node", "nextjs", "prisma", "postgres", "trpc"],
|
||||||
"addons": [
|
"addons": [
|
||||||
{
|
{
|
||||||
"plan": "heroku-postgresql:hobby-dev"
|
"plan": "heroku-postgresql:mini"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
@@ -43,15 +43,18 @@
|
|||||||
},
|
},
|
||||||
"SENDGRID_API_KEY": {
|
"SENDGRID_API_KEY": {
|
||||||
"description": "Sendgrid api key. Used for email reminders in workflows",
|
"description": "Sendgrid api key. Used for email reminders in workflows",
|
||||||
"value": ""
|
"value": "",
|
||||||
|
"required": false
|
||||||
},
|
},
|
||||||
"SENDGRID_SYNC_API_KEY": {
|
"SENDGRID_SYNC_API_KEY": {
|
||||||
"description": "Sendgrid internal sync service",
|
"description": "Sendgrid internal sync service",
|
||||||
"value": ""
|
"value": "",
|
||||||
|
"required": false
|
||||||
},
|
},
|
||||||
"SENDGRID_EMAIL": {
|
"SENDGRID_EMAIL": {
|
||||||
"description": "Sendgrid email. Used for email reminders in workflows",
|
"description": "Sendgrid email. Used for email reminders in workflows",
|
||||||
"value": ""
|
"value": "",
|
||||||
|
"required": false
|
||||||
},
|
},
|
||||||
"EMAIL_FROM": {
|
"EMAIL_FROM": {
|
||||||
"description": "Configures the global From: header whilst sending emails",
|
"description": "Configures the global From: header whilst sending emails",
|
||||||
|
|||||||
@@ -1,81 +1 @@
|
|||||||
<!-- PROJECT LOGO -->
|
|
||||||
<div align="right">
|
|
||||||
<a href="https://github.com/calcom/cal.com">
|
|
||||||
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
|
|
||||||
</a>
|
|
||||||
<a href="https://cal.com">Website</a>
|
|
||||||
·
|
|
||||||
<a href="https://github.com/calcom/cal.com/issues">Community Support</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# Cal.com Documentation
|
|
||||||
|
|
||||||
The official product, support and developer documentation, containing information and guides about using the product as well as support for self-hosted installations. This documentation site runs on [Nextra](https://nextra.vercel.app), so you may refer to their documentation should you need information on anything that isn't covered here.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Git
|
|
||||||
- Node.js & npm
|
|
||||||
- Yarn
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Firstly, clone the repository using Git:
|
|
||||||
|
|
||||||
```console
|
|
||||||
git clone https://github.com/calcom/docs.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, you can install the dependencies with yarn:
|
|
||||||
|
|
||||||
```console
|
|
||||||
yarn install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Editing
|
|
||||||
|
|
||||||
To create, edit and delete documentation pages, you can simply create markdown (.mdx) files in the `pages/` folder. You can edit Markdown with any text editor, but VS Code and WebStorm have side-by-side previews so you can see your formatted content whilst writing markdown.
|
|
||||||
|
|
||||||
You will also need to add it as an entry to the `meta.json` file found in whichever directory that the .mdx file is in.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
```console
|
|
||||||
yarn dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```console
|
|
||||||
yarn build
|
|
||||||
```
|
|
||||||
|
|
||||||
This command generates static content into the `build` directory and can be served using any static content hosting service.
|
|
||||||
|
|
||||||
## How to easily contribute
|
|
||||||
|
|
||||||
## Existing Page
|
|
||||||
|
|
||||||
1. From the documentation's GitHub repository, head to the folder called 'pages' and open it.
|
|
||||||
2. From here you can view all current pages on the documentation site. Select the page you would like to contribute to.
|
|
||||||
3. You should now be able to view the page you have selected. Located at the top right of the page will be a pencil icon. Pressing this will bring you up an editor to edit and make changes. You can add formatting using the buttons at the top, which will automatically insert the relevant markdown content needed to style the text.
|
|
||||||
4. From here make the changes you wish to make.
|
|
||||||
5. At the bottom of the screen will be a 'Propose Changes' box, fill in all the relevant details such as title and description then press the green 'Propose Changes' button.
|
|
||||||
6. Your changes have been saved, to submit them for review, located on your screen, press the green 'Create Pull Request' button.
|
|
||||||
7. Fill in all the relevant details such as title and description and after finalize the submission.
|
|
||||||
|
|
||||||
You have now successfully edited and submitted changes to our documentation site.
|
|
||||||
|
|
||||||
## Creating a New Page
|
|
||||||
|
|
||||||
1. From the documentation's GitHub repository, head to the folder called 'pages' and open it.
|
|
||||||
2. From here you can view all current pages on the documentation site. At the top of your screen press the 'New file' button.
|
|
||||||
3. You should now be able to view the page you have created. Remember when renaming the document to put .mdx at the end of the file name.
|
|
||||||
4. From here make the changes you wish to make. Such as creating a title, sub-title and body text.
|
|
||||||
5. At the bottom of the screen will be a 'Propose Changes' box, fill in all the relevant details such as title and description then press the green 'Propose Changes' button.
|
|
||||||
6. Your changes have been saved, to submit them for review, located on your screen, press the greem 'Create Pull Request' button.
|
|
||||||
7. Fill in all the relevant details such as title and description and after finalize the submission.
|
|
||||||
|
|
||||||
You have now successfully created and submitted changes to our documentation site.
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { create } from "@storybook/theming";
|
||||||
|
|
||||||
|
export default create({
|
||||||
|
base: "light",
|
||||||
|
colorPrimary: "#111827",
|
||||||
|
colorSecondary: "#111827",
|
||||||
|
appContentBg: "#F9FAFC",
|
||||||
|
brandTitle: "Cal.com UI",
|
||||||
|
brandUrl: "https://cal.com",
|
||||||
|
brandImage: "https://cal.com/logo.svg",
|
||||||
|
brandTarget: "_self",
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { addons } from "@storybook/addons";
|
||||||
|
|
||||||
|
import yourTheme from "./YourTheme";
|
||||||
|
|
||||||
|
addons.setConfig({
|
||||||
|
theme: yourTheme,
|
||||||
|
});
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"storybook-addon-next": "^1.6.9",
|
"storybook-addon-next": "^1.6.9",
|
||||||
"storybook-react-i18next": "^1.1.2",
|
"storybook-react-i18next": "^1.1.2",
|
||||||
"tailwindcss": "^3.2.1",
|
"tailwindcss": "^3.2.1",
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.9.4",
|
||||||
"vite": "^2.9.15"
|
"vite": "^2.9.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,6 @@
|
|||||||
"@types/node": "16.9.1",
|
"@types/node": "16.9.1",
|
||||||
"@types/react": "^18.0.17",
|
"@types/react": "^18.0.17",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^4.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,92 @@
|
|||||||
import { ReactNode } from "react";
|
import { useRouter } from "next/router";
|
||||||
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
import { Badge, ListItemText } from "@calcom/ui";
|
import { Badge, ListItemText } from "@calcom/ui";
|
||||||
|
import { FiAlertCircle } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
interface AppListCardProps {
|
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
|
||||||
|
|
||||||
|
type AppListCardProps = {
|
||||||
logo?: string;
|
logo?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
}
|
isTemplate?: boolean;
|
||||||
|
invalidCredential?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
} & ShouldHighlight;
|
||||||
|
|
||||||
|
const schema = z.object({ hl: z.string().optional() });
|
||||||
|
|
||||||
export default function AppListCard(props: AppListCardProps) {
|
export default function AppListCard(props: AppListCardProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { logo, title, description, actions, isDefault } = props;
|
const {
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
isDefault,
|
||||||
|
slug,
|
||||||
|
shouldHighlight,
|
||||||
|
isTemplate,
|
||||||
|
invalidCredential,
|
||||||
|
children,
|
||||||
|
} = props;
|
||||||
|
const {
|
||||||
|
data: { hl },
|
||||||
|
} = useTypedQuery(schema);
|
||||||
|
const router = useRouter();
|
||||||
|
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldHighlight && highlight) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setHighlight(false);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("hl");
|
||||||
|
router.replace(url.pathname, undefined, { shallow: true });
|
||||||
|
}, 3000);
|
||||||
|
timeoutRef.current = timer;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className={`p-4 ${highlight ? "bg-yellow-100" : ""}`}>
|
||||||
<div className="flex items-center gap-x-3">
|
<div className="flex gap-x-3">
|
||||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||||
|
|
||||||
<div className="flex grow flex-col gap-y-1 truncate">
|
<div className="flex grow flex-col gap-y-1 truncate">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<h3 className="truncate text-sm font-semibold text-gray-900">{title}</h3>
|
<h3 className="truncate text-sm font-semibold text-gray-900">{title}</h3>
|
||||||
{isDefault ? <Badge variant="green">{t("default")}</Badge> : null}
|
<div className="flex items-center gap-x-2">
|
||||||
|
{isDefault && <Badge variant="green">{t("default")}</Badge>}
|
||||||
|
{isTemplate && <Badge variant="red">Template</Badge>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ListItemText component="p">{description}</ListItemText>
|
<ListItemText component="p">{description}</ListItemText>
|
||||||
|
{invalidCredential && (
|
||||||
|
<div className="flex gap-x-2 pt-2">
|
||||||
|
<FiAlertCircle className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
|
||||||
|
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||||
|
{t("invalid_credential")}
|
||||||
|
</ListItemText>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
|
{children && <div className="w-full">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
HorizontalTabs,
|
HorizontalTabs,
|
||||||
InputLeading,
|
|
||||||
Label,
|
Label,
|
||||||
showToast,
|
showToast,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -863,7 +862,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
addOnLeading={<InputLeading>W</InputLeading>}
|
addOnLeading={<>W</>}
|
||||||
/>
|
/>
|
||||||
<span className="p-2">×</span>
|
<span className="p-2">×</span>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -883,7 +882,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
addOnLeading={<InputLeading>H</InputLeading>}
|
addOnLeading={<>H</>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
import Cropper from "react-easy-crop";
|
import Cropper from "react-easy-crop";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui";
|
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui";
|
||||||
|
|
||||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import Slider from "@components/Slider";
|
import Slider from "@components/Slider";
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ const Component = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="prose prose-sm mt-8">{body}</div>
|
<div className="prose-sm prose mt-8">{body}</div>
|
||||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||||
<span>
|
<span>
|
||||||
{price === 0 ? (
|
{price === 0 ? (
|
||||||
@@ -331,15 +331,11 @@ export default function App(props: {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell large isPublic heading={t("app_store")} backPath="/apps" withoutSeo>
|
<Shell smallHeading isPublic heading={t("app_store")} backPath="/apps" withoutSeo>
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
title={props.name}
|
title={props.name}
|
||||||
description={props.description}
|
description={props.description}
|
||||||
app={{ slug: props.logo, name: props.name, description: props.description }}
|
app={{ slug: props.logo, name: props.name, description: props.description }}
|
||||||
nextSeoProps={{
|
|
||||||
nofollow: true,
|
|
||||||
noindex: true,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{props.licenseRequired ? (
|
{props.licenseRequired ? (
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import { FiArrowLeft, FiCalendar, FiPlus } from "@calcom/ui/components/icon";
|
|||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
|
import AppListCard from "@components/AppListCard";
|
||||||
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
|
import AdditionalCalendarSelector from "@components/apps/AdditionalCalendarSelector";
|
||||||
import IntegrationListItem from "@components/apps/IntegrationListItem";
|
|
||||||
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -118,13 +118,13 @@ function CalendarList(props: Props) {
|
|||||||
success={({ data }) => (
|
success={({ data }) => (
|
||||||
<List>
|
<List>
|
||||||
{data.items.map((item) => (
|
{data.items.map((item) => (
|
||||||
<IntegrationListItem
|
<AppListCard
|
||||||
name={item.name}
|
title={item.name}
|
||||||
slug={item.slug}
|
key={item.name}
|
||||||
key={item.title}
|
|
||||||
title={item.title}
|
|
||||||
logo={item.logo}
|
logo={item.logo}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
|
shouldHighlight
|
||||||
|
slug={item.slug}
|
||||||
actions={
|
actions={
|
||||||
<InstallAppButton
|
<InstallAppButton
|
||||||
type={item.type}
|
type={item.type}
|
||||||
@@ -161,16 +161,16 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
<List>
|
||||||
{data.connectedCalendars.map((item) => (
|
{data.connectedCalendars.map((item) => (
|
||||||
<Fragment key={item.credentialId}>
|
<Fragment key={item.credentialId}>
|
||||||
{item.calendars ? (
|
{item.calendars ? (
|
||||||
<IntegrationListItem
|
<AppListCard
|
||||||
|
shouldHighlight
|
||||||
slug={item.integration.slug}
|
slug={item.integration.slug}
|
||||||
title={item.integration.title}
|
title={item.integration.name}
|
||||||
logo={item.integration.logo}
|
logo={item.integration.logo}
|
||||||
description={item.primary?.email ?? item.integration.description}
|
description={item.primary?.email ?? item.integration.description}
|
||||||
separate={true}
|
|
||||||
actions={
|
actions={
|
||||||
<div className="flex w-32 justify-end">
|
<div className="flex w-32 justify-end">
|
||||||
<DisconnectIntegration
|
<DisconnectIntegration
|
||||||
@@ -200,7 +200,7 @@ function ConnectedCalendarsList(props: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</IntegrationListItem>
|
</AppListCard>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
severity="warning"
|
severity="warning"
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { Badge, ListItem, ListItemText, ListItemTitle, showToast } from "@calcom/ui";
|
|
||||||
import { FiAlertCircle } from "@calcom/ui/components/icon";
|
|
||||||
|
|
||||||
import classNames from "@lib/classNames";
|
|
||||||
|
|
||||||
function IntegrationListItem(props: {
|
|
||||||
imageSrc?: string;
|
|
||||||
slug: string;
|
|
||||||
name?: string;
|
|
||||||
title?: string;
|
|
||||||
description: string;
|
|
||||||
actions?: ReactNode;
|
|
||||||
children?: ReactNode;
|
|
||||||
logo: string;
|
|
||||||
destination?: boolean;
|
|
||||||
separate?: boolean;
|
|
||||||
invalidCredential?: boolean;
|
|
||||||
isTemplate?: boolean;
|
|
||||||
}): JSX.Element {
|
|
||||||
const { t } = useLocale();
|
|
||||||
const router = useRouter();
|
|
||||||
const { hl } = router.query;
|
|
||||||
const [highlight, setHighlight] = useState(hl === props.slug);
|
|
||||||
const title = props.name || props.title;
|
|
||||||
|
|
||||||
// The highlight is to show a newly installed app, coming from the app's
|
|
||||||
// redirection after installation, so we proceed to show the corresponding
|
|
||||||
// message
|
|
||||||
if (highlight) {
|
|
||||||
showToast(t("app_successfully_installed"), "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setHighlight(false), 3000);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<ListItem
|
|
||||||
expanded={!!props.children}
|
|
||||||
className={classNames(
|
|
||||||
props.separate ? "rounded-md" : "first:rounded-t-md last:rounded-b-md",
|
|
||||||
"my-0 flex-col border transition-colors duration-500",
|
|
||||||
highlight ? "bg-yellow-100" : ""
|
|
||||||
)}>
|
|
||||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
|
||||||
{props.logo && <img className="h-11 w-11" src={props.logo} alt={title} />}
|
|
||||||
<div className="flex-grow truncate pl-2">
|
|
||||||
<ListItemTitle component="h3" className="flex ">
|
|
||||||
<Link href={"/apps/" + props.slug}>{props.name || title}</Link>
|
|
||||||
{props.isTemplate && (
|
|
||||||
<Badge variant="red" className="ml-4">
|
|
||||||
Template
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</ListItemTitle>
|
|
||||||
<ListItemText component="p">{props.description}</ListItemText>
|
|
||||||
{/* Alert error that key stopped working. */}
|
|
||||||
{props.invalidCredential && (
|
|
||||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
|
||||||
<FiAlertCircle className="w-8 text-red-500 sm:w-4" />
|
|
||||||
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
|
||||||
{t("invalid_credential")}
|
|
||||||
</ListItemText>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>{props.actions}</div>
|
|
||||||
</div>
|
|
||||||
{props.children && <div className="w-full">{props.children}</div>}
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default IntegrationListItem;
|
|
||||||
@@ -57,9 +57,7 @@ export default function InstalledAppsLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell {...rest}>
|
<Shell {...rest}>
|
||||||
<AppCategoryNavigation
|
<AppCategoryNavigation baseURL="/apps/installed" containerClassname="min-w-0 w-full">
|
||||||
baseURL="/apps/installed"
|
|
||||||
containerClassname="w-full xl:mx-5 xl:w-4/5 xl:max-w-2xl xl:pr-5">
|
|
||||||
{children}
|
{children}
|
||||||
</AppCategoryNavigation>
|
</AppCategoryNavigation>
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { UserPermissionRole } from ".prisma/client";
|
|||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
|
|
||||||
...rest
|
...rest
|
||||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
@@ -22,10 +23,11 @@ export default function AdminLayout({
|
|||||||
}
|
}
|
||||||
}, [session, router]);
|
}, [session, router]);
|
||||||
|
|
||||||
|
const isAppsPage = router.asPath.startsWith("/settings/admin/apps");
|
||||||
return (
|
return (
|
||||||
<SettingsLayout {...rest}>
|
<SettingsLayout {...rest}>
|
||||||
<div className="mx-auto flex max-w-4xl flex-row divide-y divide-gray-200">
|
<div className="mx-auto flex max-w-4xl flex-row divide-y divide-gray-200">
|
||||||
<div className="flex flex-1 [&>*]:flex-1">
|
<div className={isAppsPage ? "min-w-0" : "flex flex-1 [&>*]:flex-1"}>
|
||||||
<ErrorBoundary>{children}</ErrorBoundary>
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return locations.length ? (
|
return locations.length ? (
|
||||||
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-2 break-words text-sm text-gray-600">
|
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
|
||||||
{locations.map((location) => {
|
{locations.map((location, index) => {
|
||||||
const eventLocationType = getEventLocationType(location.type);
|
const eventLocationType = getEventLocationType(location.type);
|
||||||
if (!eventLocationType) {
|
if (!eventLocationType) {
|
||||||
// It's possible that the location app got uninstalled
|
// It's possible that the location app got uninstalled
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||||
<FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
<FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||||
) : (
|
) : (
|
||||||
@@ -26,7 +26,7 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
|||||||
src={eventLocationType.iconUrl}
|
src={eventLocationType.iconUrl}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
|
||||||
)}
|
)}
|
||||||
alt={`${eventLocationType.label} icon`}
|
alt={`${eventLocationType.label} icon`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,13 +17,12 @@ import {
|
|||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
MeetingTimeInTimezones,
|
MeetingTimeInTimezones,
|
||||||
showToast,
|
showToast,
|
||||||
TextArea,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ActionType,
|
ActionType,
|
||||||
TableActions,
|
TableActions,
|
||||||
|
TextAreaField,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import { FiCheck, FiClock, FiMapPin, FiRefreshCcw, FiSend, FiSlash, FiX } from "@calcom/ui/components/icon";
|
import { FiCheck, FiClock, FiMapPin, FiRefreshCcw, FiSend, FiSlash, FiX } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.status === BookingStatus.REJECTED) {
|
if (data?.status === BookingStatus.REJECTED) {
|
||||||
setRejectionDialogIsOpen(false);
|
setRejectionDialogIsOpen(false);
|
||||||
showToast(t("booking_rejection_success"), "success");
|
showToast(t("booking_rejection_success"), "success");
|
||||||
} else {
|
} else {
|
||||||
@@ -245,24 +244,23 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
)}
|
)}
|
||||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||||
<DialogContent>
|
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||||
<DialogHeader title={t("rejection_reason_title")} />
|
<div>
|
||||||
|
<TextAreaField
|
||||||
<p className="-mt-4 text-sm text-gray-500">{t("rejection_reason_description")}</p>
|
name="rejectionReason"
|
||||||
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
label={
|
||||||
{t("rejection_reason")}
|
<>
|
||||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
{t("rejection_reason")}
|
||||||
</p>
|
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||||
<TextArea
|
</>
|
||||||
name={t("rejection_reason")}
|
}
|
||||||
value={rejectionReason}
|
value={rejectionReason}
|
||||||
onChange={(e) => setRejectionReason(e.target.value)}
|
onChange={(e) => setRejectionReason(e.target.value)}
|
||||||
className="mb-5 sm:mb-6"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={mutation.isLoading}
|
disabled={mutation.isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function CancelBooking(props: Props) {
|
|||||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { booking, profile, team, allRemainingBookings } = props;
|
const { booking, allRemainingBookings } = props;
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import { ITimezoneOption, TimezoneSelect } from "@calcom/ui";
|
import { ITimezoneOption, TimezoneSelect } from "@calcom/ui";
|
||||||
|
|
||||||
|
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||||
|
|
||||||
import { timeZone } from "../../lib/clock";
|
import { timeZone } from "../../lib/clock";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -11,7 +13,8 @@ type Props = {
|
|||||||
|
|
||||||
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||||
const { t } = useLocale();
|
const query = useMeQuery();
|
||||||
|
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTimeZone(timeZone());
|
setSelectedTimeZone(timeZone());
|
||||||
@@ -23,18 +26,60 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
|||||||
}
|
}
|
||||||
}, [selectedTimeZone, onSelectTimeZone]);
|
}, [selectedTimeZone, onSelectTimeZone]);
|
||||||
|
|
||||||
return selectedTimeZone !== "" ? (
|
const customStyles = {
|
||||||
<div className="dark:border-darkgray-300 dark:bg-darkgray-200 rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
|
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
|
||||||
<div className="mb-4 flex">
|
padding: "10px 12px !important",
|
||||||
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
|
||||||
</div>
|
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
|
||||||
<TimezoneSelect
|
...(userTheme === "dark" && {
|
||||||
id="timeZone"
|
color: state.isSelected || state.isFocused ? "white" : "#80868B",
|
||||||
value={selectedTimeZone}
|
}),
|
||||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
}),
|
||||||
className="focus:border-brand mt-1 mb-2 block w-full rounded-md border-gray-300 text-sm focus:ring-black"
|
control: () => ({
|
||||||
/>
|
display: "flex",
|
||||||
</div>
|
cursor: "pointer",
|
||||||
|
backgroundColor: "transparent !important",
|
||||||
|
minWidth: "5rem",
|
||||||
|
height: "24px",
|
||||||
|
minHeight: "24px !important",
|
||||||
|
boxShadow: "none !important",
|
||||||
|
}),
|
||||||
|
singleValue: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
|
||||||
|
...provided,
|
||||||
|
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
|
||||||
|
...(userTheme === "dark" && {
|
||||||
|
color: "#a5a5a5 !important",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
menu: (provided: object) => ({
|
||||||
|
...provided,
|
||||||
|
minWidth: "20rem",
|
||||||
|
margin: "8px 0 0 -20px",
|
||||||
|
}),
|
||||||
|
valueContainer: (provided: object) => ({
|
||||||
|
...provided,
|
||||||
|
padding: "0 0 0 6px",
|
||||||
|
}),
|
||||||
|
dropdownIndicator: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
|
||||||
|
...provided,
|
||||||
|
transform: state.selectProps.menuIsOpen ? "rotate(180deg)" : "",
|
||||||
|
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
|
||||||
|
marginLeft: 4,
|
||||||
|
padding: 0,
|
||||||
|
...(userTheme === "dark" && {
|
||||||
|
color: "#80868B",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return !!selectedTimeZone ? (
|
||||||
|
<TimezoneSelect
|
||||||
|
id="timeZone"
|
||||||
|
value={selectedTimeZone}
|
||||||
|
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||||
|
className="flex h-6 text-sm font-medium"
|
||||||
|
styles={customStyles}
|
||||||
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { EventType } from "@prisma/client";
|
import { EventType } from "@prisma/client";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -20,16 +19,17 @@ import {
|
|||||||
import DatePicker from "@calcom/features/calendars/DatePicker";
|
import DatePicker from "@calcom/features/calendars/DatePicker";
|
||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import notEmpty from "@calcom/lib/notEmpty";
|
import notEmpty from "@calcom/lib/notEmpty";
|
||||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||||
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { HeadSeo } from "@calcom/ui";
|
import { HeadSeo } from "@calcom/ui";
|
||||||
import { FiChevronDown, FiChevronUp, FiCreditCard, FiGlobe, FiRefreshCcw } from "@calcom/ui/components/icon";
|
import { FiCreditCard, FiGlobe, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||||
@@ -52,6 +52,7 @@ const useSlots = ({
|
|||||||
usernameList,
|
usernameList,
|
||||||
timeZone,
|
timeZone,
|
||||||
duration,
|
duration,
|
||||||
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
eventTypeId: number;
|
eventTypeId: number;
|
||||||
eventTypeSlug: string;
|
eventTypeSlug: string;
|
||||||
@@ -60,6 +61,7 @@ const useSlots = ({
|
|||||||
usernameList: string[];
|
usernameList: string[];
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
|
enabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
||||||
{
|
{
|
||||||
@@ -72,19 +74,14 @@ const useSlots = ({
|
|||||||
duration,
|
duration,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!startTime && !!endTime,
|
enabled: !!startTime && !!endTime && enabled,
|
||||||
|
refetchInterval: 3000,
|
||||||
|
trpc: { context: { skipBatch: true } },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data?.slots) {
|
|
||||||
setCachedSlots((c) => ({ ...c, ...data?.slots }));
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
// The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state.
|
// The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state.
|
||||||
return { slots: cachedSlots, isLoading: isLoading || isPaused };
|
return { slots: data?.slots || {}, isLoading: isLoading || isPaused };
|
||||||
};
|
};
|
||||||
|
|
||||||
const SlotPicker = ({
|
const SlotPicker = ({
|
||||||
@@ -98,7 +95,10 @@ const SlotPicker = ({
|
|||||||
weekStart = 0,
|
weekStart = 0,
|
||||||
ethSignature,
|
ethSignature,
|
||||||
}: {
|
}: {
|
||||||
eventType: Pick<EventType, "id" | "schedulingType" | "slug">;
|
eventType: Pick<
|
||||||
|
EventType & { metadata: z.infer<typeof EventTypeMetaDataSchema> },
|
||||||
|
"id" | "schedulingType" | "slug" | "length" | "metadata"
|
||||||
|
>;
|
||||||
timeFormat: TimeFormat;
|
timeFormat: TimeFormat;
|
||||||
onTimeFormatChange: (is24Hour: boolean) => void;
|
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
@@ -110,11 +110,15 @@ const SlotPicker = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||||
const { duration } = useRouterQuery("duration");
|
let { duration = eventType.length.toString() } = useRouterQuery("duration");
|
||||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (!eventType.metadata?.multipleDuration) {
|
||||||
|
duration = eventType.length.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -139,16 +143,7 @@ const SlotPicker = ({
|
|||||||
}, [router.isReady, month, date, duration, timeZone]);
|
}, [router.isReady, month, date, duration, timeZone]);
|
||||||
|
|
||||||
const { i18n, isLocaleReady } = useLocale();
|
const { i18n, isLocaleReady } = useLocale();
|
||||||
const { slots: _1 } = useSlots({
|
const { slots: monthSlots, isLoading } = useSlots({
|
||||||
eventTypeId: eventType.id,
|
|
||||||
eventTypeSlug: eventType.slug,
|
|
||||||
usernameList: users,
|
|
||||||
startTime: selectedDate?.startOf("day"),
|
|
||||||
endTime: selectedDate?.endOf("day"),
|
|
||||||
timeZone,
|
|
||||||
duration,
|
|
||||||
});
|
|
||||||
const { slots: _2, isLoading } = useSlots({
|
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
usernameList: users,
|
usernameList: users,
|
||||||
@@ -160,8 +155,25 @@ const SlotPicker = ({
|
|||||||
timeZone,
|
timeZone,
|
||||||
duration,
|
duration,
|
||||||
});
|
});
|
||||||
|
const { slots: selectedDateSlots, isLoading: _isLoadingSelectedDateSlots } = useSlots({
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
eventTypeSlug: eventType.slug,
|
||||||
|
usernameList: users,
|
||||||
|
startTime: selectedDate?.startOf("day"),
|
||||||
|
endTime: selectedDate?.endOf("day"),
|
||||||
|
timeZone,
|
||||||
|
duration,
|
||||||
|
/** Prevent refetching is we already have this data from month slots */
|
||||||
|
enabled: !!selectedDate,
|
||||||
|
});
|
||||||
|
|
||||||
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
/** Hide skeleton if we have the slot loaded in the month query */
|
||||||
|
const isLoadingSelectedDateSlots = (() => {
|
||||||
|
if (!selectedDate) return _isLoadingSelectedDateSlots;
|
||||||
|
if (!!selectedDateSlots[selectedDate.format("YYYY-MM-DD")]) return false;
|
||||||
|
if (!!monthSlots[selectedDate.format("YYYY-MM-DD")]) return false;
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -171,7 +183,7 @@ const SlotPicker = ({
|
|||||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
||||||
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||||
)}
|
)}
|
||||||
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
includedDates={Object.keys(monthSlots).filter((k) => monthSlots[k].length > 0)}
|
||||||
locale={isLocaleReady ? i18n.language : "en"}
|
locale={isLocaleReady ? i18n.language : "en"}
|
||||||
selected={selectedDate}
|
selected={selectedDate}
|
||||||
onChange={(newDate) => {
|
onChange={(newDate) => {
|
||||||
@@ -186,8 +198,12 @@ const SlotPicker = ({
|
|||||||
|
|
||||||
<div ref={slotPickerRef}>
|
<div ref={slotPickerRef}>
|
||||||
<AvailableTimes
|
<AvailableTimes
|
||||||
isLoading={isLoading}
|
isLoading={isLoadingSelectedDateSlots}
|
||||||
slots={selectedDate && slots[selectedDate.format("YYYY-MM-DD")]}
|
slots={
|
||||||
|
selectedDate &&
|
||||||
|
(selectedDateSlots[selectedDate.format("YYYY-MM-DD")] ||
|
||||||
|
monthSlots[selectedDate.format("YYYY-MM-DD")])
|
||||||
|
}
|
||||||
date={selectedDate}
|
date={selectedDate}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
onTimeFormatChange={onTimeFormatChange}
|
onTimeFormatChange={onTimeFormatChange}
|
||||||
@@ -204,41 +220,22 @@ const SlotPicker = ({
|
|||||||
|
|
||||||
function TimezoneDropdown({
|
function TimezoneDropdown({
|
||||||
onChangeTimeZone,
|
onChangeTimeZone,
|
||||||
timeZone,
|
|
||||||
}: {
|
}: {
|
||||||
onChangeTimeZone: (newTimeZone: string) => void;
|
onChangeTimeZone: (newTimeZone: string) => void;
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleSelectTimeZone = (newTimeZone: string) => {
|
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||||
onChangeTimeZone(newTimeZone);
|
onChangeTimeZone(newTimeZone);
|
||||||
localStorageTimeZone(newTimeZone);
|
localStorageTimeZone(newTimeZone);
|
||||||
setIsTimeOptionsOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
<>
|
||||||
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
|
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
|
||||||
<p className="flex items-center text-sm font-medium">
|
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
|
||||||
<FiGlobe className="min-h-4 min-w-4 ml-[2px] -mt-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]" />
|
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||||
{timeZone}
|
</div>
|
||||||
{isTimeOptionsOpen ? (
|
</>
|
||||||
<FiChevronUp className="min-h-4 min-w-4 ml-1 inline-block" />
|
|
||||||
) : (
|
|
||||||
<FiChevronDown className="min-h-4 min-w-4 ml-1 inline-block" />
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content
|
|
||||||
hideWhenDetached
|
|
||||||
align="start"
|
|
||||||
className="animate-fade-in-up absolute left-0 top-2 w-80 max-w-[calc(100vw_-_1.5rem)]">
|
|
||||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +300,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
||||||
[timeZone]
|
[timeZone]
|
||||||
);
|
);
|
||||||
const stripeAppData = getStripeAppData(eventType);
|
const paymentAppData = getPaymentAppData(eventType);
|
||||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||||
@@ -354,10 +351,10 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
isBackgroundTransparent
|
isBackgroundTransparent
|
||||||
? ""
|
? ""
|
||||||
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
||||||
"border-bookinglightest overflow-hidden md:rounded-md md:border",
|
"border-bookinglightest md:rounded-md md:border",
|
||||||
isEmbed && "mx-auto"
|
isEmbed && "mx-auto"
|
||||||
)}>
|
)}>
|
||||||
<div className="overflow-hidden md:flex">
|
<div className="md:flex">
|
||||||
{showEventTypeDetails && (
|
{showEventTypeDetails && (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -391,14 +388,14 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stripeAppData.price > 0 && (
|
{paymentAppData.price > 0 && (
|
||||||
<p className="-ml-2 px-2 text-sm font-medium">
|
<p className="-ml-2 px-2 text-sm font-medium">
|
||||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<FormattedNumber
|
<FormattedNumber
|
||||||
value={stripeAppData.price / 100.0}
|
value={paymentAppData.price / 100.0}
|
||||||
style="currency"
|
style="currency"
|
||||||
currency={stripeAppData.currency.toUpperCase()}
|
currency={paymentAppData.currency?.toUpperCase()}
|
||||||
/>
|
/>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { useSession } from "next-auth/react";
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { ReactMultiEmail } from "react-multi-email";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -33,21 +32,33 @@ import {
|
|||||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { AddressInput, Button, EmailInput, Form, PhoneInput, Tooltip, Group, RadioField } from "@calcom/ui";
|
|
||||||
import {
|
import {
|
||||||
FiAlertTriangle,
|
AddressInput,
|
||||||
|
Button,
|
||||||
|
EmailField,
|
||||||
|
EmailInput,
|
||||||
|
Form,
|
||||||
|
Group,
|
||||||
|
PhoneInput,
|
||||||
|
RadioField,
|
||||||
|
Tooltip,
|
||||||
|
} from "@calcom/ui";
|
||||||
|
import {
|
||||||
|
FiUserPlus,
|
||||||
FiCalendar,
|
FiCalendar,
|
||||||
FiCreditCard,
|
FiX,
|
||||||
FiInfo,
|
FiInfo,
|
||||||
|
FiCreditCard,
|
||||||
FiRefreshCw,
|
FiRefreshCw,
|
||||||
FiUser,
|
FiUser,
|
||||||
FiUserPlus,
|
FiAlertTriangle,
|
||||||
} from "@calcom/ui/components/icon";
|
} from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
@@ -57,7 +68,6 @@ import useRouterQuery from "@lib/hooks/useRouterQuery";
|
|||||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||||
import slugify from "@lib/slugify";
|
|
||||||
|
|
||||||
import Gates, { Gate, GateState } from "@components/Gates";
|
import Gates, { Gate, GateState } from "@components/Gates";
|
||||||
import BookingDescription from "@components/booking/BookingDescription";
|
import BookingDescription from "@components/booking/BookingDescription";
|
||||||
@@ -73,7 +83,7 @@ type BookingFormValues = {
|
|||||||
email: string;
|
email: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
locationType?: EventLocationType["type"];
|
locationType?: EventLocationType["type"];
|
||||||
guests?: string[];
|
guests?: { email: string }[];
|
||||||
address?: string;
|
address?: string;
|
||||||
attendeeAddress?: string;
|
attendeeAddress?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -112,10 +122,15 @@ const BookingPage = ({
|
|||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
const stripeAppData = getStripeAppData(eventType);
|
const paymentAppData = getPaymentAppData(eventType);
|
||||||
// Define duration now that we support multiple duration eventTypes
|
// Define duration now that we support multiple duration eventTypes
|
||||||
let duration = eventType.length;
|
let duration = eventType.length;
|
||||||
if (queryDuration && !isNaN(Number(queryDuration))) {
|
if (
|
||||||
|
queryDuration &&
|
||||||
|
!isNaN(Number(queryDuration)) &&
|
||||||
|
eventType.metadata?.multipleDuration &&
|
||||||
|
eventType.metadata?.multipleDuration.includes(Number(queryDuration))
|
||||||
|
) {
|
||||||
duration = Number(queryDuration);
|
duration = Number(queryDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +148,7 @@ const BookingPage = ({
|
|||||||
const mutation = useMutation(createBooking, {
|
const mutation = useMutation(createBooking, {
|
||||||
onSuccess: async (responseData) => {
|
onSuccess: async (responseData) => {
|
||||||
const { uid, paymentUid } = responseData;
|
const { uid, paymentUid } = responseData;
|
||||||
|
|
||||||
if (paymentUid) {
|
if (paymentUid) {
|
||||||
return await router.push(
|
return await router.push(
|
||||||
createPaymentLink({
|
createPaymentLink({
|
||||||
@@ -151,7 +167,7 @@ const BookingPage = ({
|
|||||||
isSuccessBookingPage: true,
|
isSuccessBookingPage: true,
|
||||||
email: bookingForm.getValues("email"),
|
email: bookingForm.getValues("email"),
|
||||||
eventTypeSlug: eventType.slug,
|
eventTypeSlug: eventType.slug,
|
||||||
formerTime: booking?.startTime.toString(),
|
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -192,7 +208,9 @@ const BookingPage = ({
|
|||||||
|
|
||||||
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
const loggedInIsOwner = eventType?.users[0]?.id === session?.user?.id;
|
||||||
const guestListEmails = !isDynamicGroupBooking
|
const guestListEmails = !isDynamicGroupBooking
|
||||||
? booking?.attendees.slice(1).map((attendee) => attendee.email)
|
? booking?.attendees.slice(1).map((attendee) => {
|
||||||
|
return { email: attendee.email };
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// There should only exists one default userData variable for primaryAttendee.
|
// There should only exists one default userData variable for primaryAttendee.
|
||||||
@@ -211,7 +229,9 @@ const BookingPage = ({
|
|||||||
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
|
name: defaultUserValues.name || (!loggedInIsOwner && session?.user?.name) || "",
|
||||||
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
|
email: defaultUserValues.email || (!loggedInIsOwner && session?.user?.email) || "",
|
||||||
notes: (router.query.notes as string) || "",
|
notes: (router.query.notes as string) || "",
|
||||||
guests: ensureArray(router.query.guest) as string[],
|
guests: ensureArray(router.query.guest).map((guest) => {
|
||||||
|
return { email: guest as string };
|
||||||
|
}),
|
||||||
customInputs: eventType.customInputs.reduce(
|
customInputs: eventType.customInputs.reduce(
|
||||||
(customInputs, input) => ({
|
(customInputs, input) => ({
|
||||||
...customInputs,
|
...customInputs,
|
||||||
@@ -253,6 +273,7 @@ const BookingPage = ({
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
email: z.string().trim().email(),
|
email: z.string().trim().email(),
|
||||||
|
guests: z.array(z.object({ email: z.string().email() })).optional(),
|
||||||
phone: z
|
phone: z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => isValidPhoneNumber(val))
|
.refine((val) => isValidPhoneNumber(val))
|
||||||
@@ -271,6 +292,10 @@ const BookingPage = ({
|
|||||||
defaultValues: defaultValues(),
|
defaultValues: defaultValues(),
|
||||||
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
resolver: zodResolver(bookingFormSchema), // Since this isn't set to strict we only validate the fields in the schema
|
||||||
});
|
});
|
||||||
|
const guestsField = useFieldArray({
|
||||||
|
name: "guests",
|
||||||
|
control: bookingForm.control,
|
||||||
|
});
|
||||||
|
|
||||||
const selectedLocationType = useWatch({
|
const selectedLocationType = useWatch({
|
||||||
control: bookingForm.control,
|
control: bookingForm.control,
|
||||||
@@ -309,6 +334,7 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookEvent = (booking: BookingFormValues) => {
|
const bookEvent = (booking: BookingFormValues) => {
|
||||||
|
bookingForm.clearErrors();
|
||||||
const bookingCustomInputs = Object.keys(booking.customInputs || {}).map((inputId) => ({
|
const bookingCustomInputs = Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))?.label || "",
|
||||||
value: booking.customInputs && booking.customInputs[inputId] ? booking.customInputs[inputId] : "",
|
value: booking.customInputs && booking.customInputs[inputId] ? booking.customInputs[inputId] : "",
|
||||||
@@ -376,6 +402,29 @@ const BookingPage = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that guests are unique
|
||||||
|
let alreadyInvited = false;
|
||||||
|
booking.guests?.forEach((guest, index) => {
|
||||||
|
if (guest.email === booking.email) {
|
||||||
|
bookingForm.setError(`guests.${index}`, { type: "validate", message: t("already_invited") });
|
||||||
|
alreadyInvited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.guests) {
|
||||||
|
let guestCount = 0;
|
||||||
|
for (const checkGuest of booking.guests) {
|
||||||
|
if (checkGuest.email === guest.email) guestCount++;
|
||||||
|
if (guestCount > 1) {
|
||||||
|
bookingForm.setError(`guests.${index}`, { type: "validate", message: t("already_invited") });
|
||||||
|
alreadyInvited = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alreadyInvited) return;
|
||||||
|
|
||||||
if (recurringDates.length) {
|
if (recurringDates.length) {
|
||||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||||
const recurringEventId = uuidv4();
|
const recurringEventId = uuidv4();
|
||||||
@@ -406,6 +455,7 @@ const BookingPage = ({
|
|||||||
? booking.phone
|
? booking.phone
|
||||||
: booking.smsReminderNumber || undefined,
|
: booking.smsReminderNumber || undefined,
|
||||||
ethSignature: gateState.rainbowToken,
|
ethSignature: gateState.rainbowToken,
|
||||||
|
guests: booking.guests?.map((guest) => guest.email),
|
||||||
}));
|
}));
|
||||||
recurringMutation.mutate(recurringBookings);
|
recurringMutation.mutate(recurringBookings);
|
||||||
} else {
|
} else {
|
||||||
@@ -434,6 +484,7 @@ const BookingPage = ({
|
|||||||
? booking.phone
|
? booking.phone
|
||||||
: booking.smsReminderNumber || undefined,
|
: booking.smsReminderNumber || undefined,
|
||||||
ethSignature: gateState.rainbowToken,
|
ethSignature: gateState.rainbowToken,
|
||||||
|
guests: booking.guests?.map((guest) => guest.email),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -507,14 +558,14 @@ const BookingPage = ({
|
|||||||
{showEventTypeDetails && (
|
{showEventTypeDetails && (
|
||||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||||
{stripeAppData.price > 0 && (
|
{paymentAppData.price > 0 && (
|
||||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||||
<IntlProvider locale="en">
|
<IntlProvider locale="en">
|
||||||
<FormattedNumber
|
<FormattedNumber
|
||||||
value={stripeAppData.price / 100.0}
|
value={paymentAppData.price / 100.0}
|
||||||
style="currency"
|
style="currency"
|
||||||
currency={stripeAppData.currency.toUpperCase()}
|
currency={paymentAppData?.currency?.toUpperCase()}
|
||||||
/>
|
/>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
</p>
|
</p>
|
||||||
@@ -850,60 +901,7 @@ const BookingPage = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!eventType.disableGuests && guestToggle && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="guests"
|
|
||||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
|
||||||
{t("guests")}
|
|
||||||
</label>
|
|
||||||
{!disableInput && (
|
|
||||||
<Controller
|
|
||||||
control={bookingForm.control}
|
|
||||||
name="guests"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<ReactMultiEmail
|
|
||||||
className="relative"
|
|
||||||
placeholder={<span className="dark:text-darkgray-600">guest@example.com</span>}
|
|
||||||
emails={value}
|
|
||||||
onChange={onChange}
|
|
||||||
getLabel={(
|
|
||||||
email: string,
|
|
||||||
index: number,
|
|
||||||
removeEmail: (index: number) => void
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<div data-tag key={index} className="cursor-pointer">
|
|
||||||
{email}
|
|
||||||
{!disableInput && (
|
|
||||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Custom code when guest emails should not be editable */}
|
|
||||||
{disableInput && guestListEmails && guestListEmails.length > 0 && (
|
|
||||||
<div data-tag className="react-multi-email">
|
|
||||||
{/* // @TODO: user owners are appearing as guest here when should be only user input */}
|
|
||||||
{guestListEmails.map((email, index) => {
|
|
||||||
return (
|
|
||||||
<div key={index} className="cursor-pointer">
|
|
||||||
<span data-tag>{email}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isSmsReminderNumberNeeded && selectedLocationType !== LocationType.Phone && (
|
{isSmsReminderNumberNeeded && selectedLocationType !== LocationType.Phone && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label
|
<label
|
||||||
@@ -956,19 +954,84 @@ const BookingPage = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!eventType.disableGuests && guestsField.fields.length ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="guests"
|
||||||
|
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||||
|
{t("guests")}
|
||||||
|
</label>
|
||||||
|
<ul>
|
||||||
|
{guestsField.fields.map((field, index) => (
|
||||||
|
<li key={field.id}>
|
||||||
|
<EmailField
|
||||||
|
{...bookingForm.register(`guests.${index}.email` as const)}
|
||||||
|
className={classNames(
|
||||||
|
inputClassName,
|
||||||
|
bookingForm.formState.errors.guests?.[index] &&
|
||||||
|
"!focus:ring-red-700 !border-red-700",
|
||||||
|
"border-r-0"
|
||||||
|
)}
|
||||||
|
addOnClassname={classNames(
|
||||||
|
"border-gray-300 border block border-l-0 disabled:bg-gray-200 disabled:hover:cursor-not-allowed bg-transparent disabled:text-gray-500 dark:border-darkgray-300 ",
|
||||||
|
bookingForm.formState.errors.guests?.[index] &&
|
||||||
|
"!focus:ring-red-700 !border-red-700"
|
||||||
|
)}
|
||||||
|
placeholder="guest@example.com"
|
||||||
|
label={<></>}
|
||||||
|
required
|
||||||
|
addOnSuffix={
|
||||||
|
<Tooltip content="Remove guest">
|
||||||
|
<button
|
||||||
|
className="m-1 disabled:hover:cursor-not-allowed"
|
||||||
|
type="button"
|
||||||
|
onClick={() => guestsField.remove(index)}>
|
||||||
|
<FiX className="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{bookingForm.formState.errors.guests?.[index] && (
|
||||||
|
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
|
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
|
<p className="text-red-700">
|
||||||
|
{bookingForm.formState.errors.guests?.[index]?.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="minimal"
|
||||||
|
StartIcon={FiUserPlus}
|
||||||
|
className="my-2.5"
|
||||||
|
// className="mb-1 block text-sm font-medium text-gray-700 dark:text-white"
|
||||||
|
onClick={() => guestsField.append({ email: "" })}>
|
||||||
|
{t("add_another")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!eventType.disableGuests && !guestsField.fields.length && (
|
||||||
|
<Button
|
||||||
|
color="minimal"
|
||||||
|
variant="button"
|
||||||
|
StartIcon={FiUserPlus}
|
||||||
|
onClick={() => {
|
||||||
|
guestsField.append({ email: "" });
|
||||||
|
}}
|
||||||
|
className="mr-auto">
|
||||||
|
{t("additional_guests")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 rtl:space-x-reverse">
|
<div className="flex justify-end space-x-2 rtl:space-x-reverse">
|
||||||
{!eventType.disableGuests && !guestToggle && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
color="minimal"
|
|
||||||
variant="icon"
|
|
||||||
tooltip={t("additional_guests")}
|
|
||||||
StartIcon={FiUserPlus}
|
|
||||||
onClick={() => setGuestToggle(!guestToggle)}
|
|
||||||
className="mr-auto"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button color="minimal" type="button" onClick={() => router.back()}>
|
<Button color="minimal" type="button" onClick={() => router.back()}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ErrorMessage } from "@hookform/error-message";
|
import { ErrorMessage } from "@hookform/error-message";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -216,7 +218,15 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
{t("edit_location")}
|
{t("edit_location")}
|
||||||
</h3>
|
</h3>
|
||||||
{!booking && (
|
{!booking && (
|
||||||
<p className="text-sm text-gray-400">{t("this_input_will_shown_booking_this_event")}</p>
|
<p className="text-sm text-gray-400">
|
||||||
|
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||||
|
Can't find the right video app? Visit our
|
||||||
|
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
||||||
|
App Store
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
||||||
@@ -327,7 +337,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||||||
setShowLocationModal(false);
|
setShowLocationModal(false);
|
||||||
setSelectedLocation?.(undefined);
|
setSelectedLocation?.(undefined);
|
||||||
setEditingLocationType?.("");
|
setEditingLocationType?.("");
|
||||||
locationFormMethods.unregister("locationType");
|
locationFormMethods.unregister(["locationType", "locationLink"]);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
color="secondary">
|
color="secondary">
|
||||||
|
|||||||
@@ -99,100 +99,105 @@ const format = (date: Date, hour12: boolean) =>
|
|||||||
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
|
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
const EventTypeScheduleDetails = () => {
|
||||||
|
const { data: loggedInUser } = useMeQuery();
|
||||||
|
const timeFormat = loggedInUser?.timeFormat;
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const { watch } = useFormContext<FormValues>();
|
const { watch } = useFormContext<FormValues>();
|
||||||
|
|
||||||
const EventTypeSchedule = () => {
|
|
||||||
const me = useMeQuery();
|
|
||||||
const timeFormat = me?.data?.timeFormat;
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="min-w-4 mb-2">
|
|
||||||
<label htmlFor="availability" className="mt-0 flex text-sm font-medium text-gray-700">
|
|
||||||
{t("availability")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Controller
|
|
||||||
name="schedule"
|
|
||||||
render={({ field }) => (
|
|
||||||
<AvailabilitySelect
|
|
||||||
value={field.value}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
onChange={(selected) => {
|
|
||||||
field.onChange(selected?.value || null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4 rounded border p-4 py-6 pt-2 md:p-8">
|
|
||||||
<ol className="table border-collapse text-sm">
|
|
||||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
|
||||||
const isAvailable = !!filterDays(index).length;
|
|
||||||
return (
|
|
||||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
"w-20 font-medium sm:w-32",
|
|
||||||
!isAvailable && "text-gray-500 opacity-50"
|
|
||||||
)}>
|
|
||||||
{day}
|
|
||||||
</span>
|
|
||||||
{isLoading ? (
|
|
||||||
<SkeletonText className="block h-5 w-60" />
|
|
||||||
) : isAvailable ? (
|
|
||||||
<div className="space-y-3 text-right">
|
|
||||||
{filterDays(index).map((dayRange, i) => (
|
|
||||||
<div key={i} className="flex items-center leading-4">
|
|
||||||
<span className="w-16 sm:w-28 sm:text-left">
|
|
||||||
{format(dayRange.startTime, timeFormat === 12)}
|
|
||||||
</span>
|
|
||||||
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
|
||||||
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
<hr />
|
|
||||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
|
||||||
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
|
||||||
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
|
||||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
href={`/availability/${schedule?.schedule.id}`}
|
|
||||||
color="minimal"
|
|
||||||
EndIcon={FiExternalLink}
|
|
||||||
target="_blank"
|
|
||||||
className="justify-center border sm:border-0"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
{t("edit_availability")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleId = watch("schedule");
|
const scheduleId = watch("schedule");
|
||||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId });
|
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
|
||||||
|
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
|
||||||
|
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
|
||||||
|
);
|
||||||
|
|
||||||
const filterDays = (dayNum: number) =>
|
const filterDays = (dayNum: number) =>
|
||||||
schedule?.schedule.availability.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
schedule?.schedule.availability.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||||
|
|
||||||
if (!isTeamEvent) {
|
return (
|
||||||
return <EventTypeSchedule />;
|
<div className="space-y-4 rounded border px-6 pb-4">
|
||||||
}
|
<ol className="table border-collapse text-sm">
|
||||||
|
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||||
|
const isAvailable = !!filterDays(index).length;
|
||||||
|
return (
|
||||||
|
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
"w-20 font-medium sm:w-32",
|
||||||
|
!isAvailable && "text-gray-500 opacity-50"
|
||||||
|
)}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<SkeletonText className="block h-5 w-60" />
|
||||||
|
) : isAvailable ? (
|
||||||
|
<div className="space-y-3 text-right">
|
||||||
|
{filterDays(index).map((dayRange, i) => (
|
||||||
|
<div key={i} className="flex items-center leading-4">
|
||||||
|
<span className="w-16 sm:w-28 sm:text-left">
|
||||||
|
{format(dayRange.startTime, timeFormat === 12)}
|
||||||
|
</span>
|
||||||
|
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
||||||
|
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<hr />
|
||||||
|
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||||
|
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||||
|
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||||
|
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
href={`/availability/${schedule?.schedule.id}`}
|
||||||
|
color="minimal"
|
||||||
|
EndIcon={FiExternalLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
{t("edit_availability")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventTypeSchedule = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="availability" className="mb-2 block text-sm font-medium leading-none text-gray-700">
|
||||||
|
{t("availability")}
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="schedule"
|
||||||
|
render={({ field }) => (
|
||||||
|
<AvailabilitySelect
|
||||||
|
value={field.value}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
onChange={(selected) => {
|
||||||
|
field.onChange(selected?.value || null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<EventTypeScheduleDetails />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UseCommonScheduleSettingsToggle = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { resetField, setValue } = useFormContext<FormValues>();
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||||
@@ -201,6 +206,11 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
|||||||
checked={!value}
|
checked={!value}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
onChange(!checked);
|
onChange(!checked);
|
||||||
|
if (checked) {
|
||||||
|
resetField("schedule");
|
||||||
|
} else {
|
||||||
|
setValue("schedule", null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
title={t("choose_common_schedule_team_event")}
|
title={t("choose_common_schedule_team_event")}
|
||||||
description={t("choose_common_schedule_team_event_description")}>
|
description={t("choose_common_schedule_team_event_description")}>
|
||||||
@@ -210,3 +220,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||||
|
return isTeamEvent ? <UseCommonScheduleSettingsToggle /> : <EventTypeSchedule />;
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ import { Controller, useFormContext } from "react-hook-form";
|
|||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
|
|
||||||
|
import type { EventNameObjectType } from "@calcom/core/event";
|
||||||
|
import { getEventName } from "@calcom/core/event";
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||||
import CustomInputItem from "@calcom/features/eventtypes/components/CustomInputItem";
|
import CustomInputItem from "@calcom/features/eventtypes/components/CustomInputItem";
|
||||||
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||||
@@ -57,6 +59,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||||
|
const eventNameObject: EventNameObjectType = {
|
||||||
|
attendeeName: t("scheduler"),
|
||||||
|
eventType: eventType.title,
|
||||||
|
eventName: eventType.eventName,
|
||||||
|
host: eventType.users[0]?.name || "Nameless",
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
const [previewText, setPreviewText] = useState(getEventName(eventNameObject));
|
||||||
const [customInputs, setCustomInputs] = useState<CustomInputParsed[]>(
|
const [customInputs, setCustomInputs] = useState<CustomInputParsed[]>(
|
||||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||||
);
|
);
|
||||||
@@ -64,7 +74,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||||
|
|
||||||
const removeCustom = (index: number) => {
|
const removeCustom = (index: number) => {
|
||||||
formMethods.getValues("customInputs").splice(index, 1);
|
formMethods.getValues("customInputs").splice(index, 1);
|
||||||
@@ -72,6 +82,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
setCustomInputs([...customInputs]);
|
setCustomInputs([...customInputs]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changePreviewText = (eventNameObject: EventNameObjectType, previewEventName: string) => {
|
||||||
|
setPreviewText(
|
||||||
|
previewEventName
|
||||||
|
.replace("{Event type title}", eventNameObject.eventType)
|
||||||
|
.replace("{Scheduler}", eventNameObject.attendeeName)
|
||||||
|
.replace("{Organiser}", eventNameObject.host)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||||
}, [eventType.users, hashedUrl, team?.id]);
|
}, [eventType.users, hashedUrl, team?.id]);
|
||||||
@@ -120,11 +139,18 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
)}
|
)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<TextField
|
<TextField
|
||||||
label={t("event_name")}
|
label={t("event_name_in_calendar")}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
placeholder={t("meeting_with_user")}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName", {
|
||||||
|
onChange: (e) => {
|
||||||
|
if (!e.target.value || !formMethods.getValues("eventName")) {
|
||||||
|
return setPreviewText(getEventName(eventNameObject));
|
||||||
|
}
|
||||||
|
changePreviewText(eventNameObject, e.target.value);
|
||||||
|
},
|
||||||
|
})}
|
||||||
addOnSuffix={
|
addOnSuffix={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -384,23 +410,63 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||||||
description={t("custom_event_name_description")}
|
description={t("custom_event_name_description")}
|
||||||
type="creation">
|
type="creation">
|
||||||
<TextField
|
<TextField
|
||||||
label={t("event_name")}
|
label={t("event_name_in_calendar")}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("meeting_with_user", { attendeeName: eventType.users[0]?.name })}
|
placeholder={t("meeting_with_user")}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName", {
|
||||||
|
onChange: (e) => {
|
||||||
|
if (!e.target.value || !formMethods.getValues("eventName")) {
|
||||||
|
return setPreviewText(getEventName(eventNameObject));
|
||||||
|
}
|
||||||
|
changePreviewText(eventNameObject, e.target.value);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
className="mb-0"
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 text-gray-500">
|
<div className="text-sm">
|
||||||
<p>{`{HOST} = ${t("your_name")}`}</p>
|
<div className="mb-6 rounded-md bg-gray-100 p-2">
|
||||||
<p>{`{ATTENDEE} = ${t("attendee_name")}`}</p>
|
<h1 className="mb-2 ml-1 font-medium text-gray-900">{t("available_variables")}</h1>
|
||||||
<p>{`{HOST/ATTENDEE} = ${t("dynamically_display_attendee_or_organizer")}`}</p>
|
<div className="mb-2.5 flex font-normal">
|
||||||
<p>{`{LOCATION} = ${t("event_location")}`}</p>
|
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Event type title}`}</p>
|
||||||
|
<p className="text-gray-900">{t("event_name_info")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2.5 flex font-normal">
|
||||||
|
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Organiser}`}</p>
|
||||||
|
<p className="text-gray-900">{t("your_full_name")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2.5 flex font-normal">
|
||||||
|
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Scheduler}`}</p>
|
||||||
|
<p className="text-gray-900">{t("scheduler_full_name")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 flex font-normal">
|
||||||
|
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Location}`}</p>
|
||||||
|
<p className="text-gray-900">{t("location_info")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||||
|
<div
|
||||||
|
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url(/calendar-preview.svg)",
|
||||||
|
}}>
|
||||||
|
<div className="m-auto flex items-center justify-center self-stretch">
|
||||||
|
<div className="mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black bg-gray-100 text-[12px] leading-3">
|
||||||
|
<p className="overflow-hidden text-ellipsis p-1.5 font-medium text-gray-900">
|
||||||
|
{previewText}
|
||||||
|
</p>
|
||||||
|
<p className="ml-1.5 text-[10px] font-normal text-gray-600">8 - 10 AM</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")}>
|
||||||
|
{t("cancel")}
|
||||||
|
</DialogClose>
|
||||||
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
||||||
{t("create")}
|
{t("create")}
|
||||||
</Button>
|
</Button>
|
||||||
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")} />
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -420,12 +420,14 @@ const BookingLimits = () => {
|
|||||||
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
|
defaultValue={BOOKING_LIMIT_OPTIONS.find((option) => option.value === key)}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
const current = currentBookingLimits;
|
const current = currentBookingLimits;
|
||||||
|
const currentValue = watchBookingLimits[bookingLimitKey];
|
||||||
|
|
||||||
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
// Removes limit from previous selected value (eg when changed from per_week to per_month, we unset per_week here)
|
||||||
delete current[bookingLimitKey];
|
delete current[bookingLimitKey];
|
||||||
const newData = {
|
const newData = {
|
||||||
...current,
|
...current,
|
||||||
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
// Set limit to new selected value (in the example above this means we set the limit to per_week here).
|
||||||
[val?.value as BookingLimitsKey]: watchBookingLimits[bookingLimitKey],
|
[val?.value as BookingLimitsKey]: currentValue,
|
||||||
};
|
};
|
||||||
onChange(newData);
|
onChange(newData);
|
||||||
}}
|
}}
|
||||||
@@ -459,7 +461,7 @@ const BookingLimits = () => {
|
|||||||
|
|
||||||
setValue("bookingLimits", {
|
setValue("bookingLimits", {
|
||||||
...watchBookingLimits,
|
...watchBookingLimits,
|
||||||
[rest[0].value]: undefined,
|
[rest[0].value]: 1,
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
{t("add_limit")}
|
{t("add_limit")}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
|
|
||||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||||
|
|
||||||
import RecurringEventController from "./RecurringEventController";
|
import RecurringEventController from "./RecurringEventController";
|
||||||
|
|
||||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||||
const stripeAppData = getStripeAppData(eventType);
|
const paymentAppData = getPaymentAppData(eventType);
|
||||||
|
|
||||||
const requirePayment = stripeAppData.price > 0;
|
const requirePayment = paymentAppData.price > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ import { Controller, useForm, useFormContext } from "react-hook-form";
|
|||||||
import { MultiValue } from "react-select";
|
import { MultiValue } from "react-select";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { EventLocationType, getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
import {
|
||||||
|
EventLocationType,
|
||||||
|
getEventLocationType,
|
||||||
|
MeetLocationType,
|
||||||
|
LocationType,
|
||||||
|
} from "@calcom/app-store/locations";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { slugify } from "@calcom/lib/slugify";
|
||||||
import { Button, Label, Select, SettingsToggle, Skeleton, TextField } from "@calcom/ui";
|
import { Button, Label, Select, SettingsToggle, Skeleton, TextField } from "@calcom/ui";
|
||||||
import { FiEdit2, FiCheck, FiX, FiPlus } from "@calcom/ui/components/icon";
|
import { FiEdit2, FiCheck, FiX, FiPlus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { slugify } from "@lib/slugify";
|
|
||||||
|
|
||||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||||
import LocationSelect, {
|
import LocationSelect, {
|
||||||
SingleValueLocationOption,
|
SingleValueLocationOption,
|
||||||
@@ -35,6 +39,16 @@ const getLocationFromType = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDefaultLocationValue = (options: EventTypeSetupProps["locationOptions"], type: string) => {
|
||||||
|
for (const locationType of options) {
|
||||||
|
for (const location of locationType.options) {
|
||||||
|
if (location.value === type && location.disabled === false) {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const EventSetupTab = (
|
export const EventSetupTab = (
|
||||||
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
|
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
|
||||||
) => {
|
) => {
|
||||||
@@ -70,7 +84,12 @@ export const EventSetupTab = (
|
|||||||
const removeLocation = (selectedLocation: typeof eventType.locations[number]) => {
|
const removeLocation = (selectedLocation: typeof eventType.locations[number]) => {
|
||||||
formMethods.setValue(
|
formMethods.setValue(
|
||||||
"locations",
|
"locations",
|
||||||
formMethods.getValues("locations").filter((location) => location.type !== selectedLocation.type),
|
formMethods.getValues("locations").filter((location) => {
|
||||||
|
if (location.type === LocationType.InPerson) {
|
||||||
|
return location.address !== selectedLocation.address;
|
||||||
|
}
|
||||||
|
return location.type !== selectedLocation.type;
|
||||||
|
}),
|
||||||
{ shouldValidate: true }
|
{ shouldValidate: true }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -85,14 +104,14 @@ export const EventSetupTab = (
|
|||||||
...details,
|
...details,
|
||||||
type: newLocationType,
|
type: newLocationType,
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
copy[existingIdx] = {
|
|
||||||
...formMethods.getValues("locations")[existingIdx],
|
|
||||||
...details,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
formMethods.setValue("locations", copy);
|
formMethods.setValue("locations", [
|
||||||
|
...copy,
|
||||||
|
...(newLocationType === LocationType.InPerson && editingLocationType === ""
|
||||||
|
? [{ ...details, type: newLocationType }]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
formMethods.setValue(
|
formMethods.setValue(
|
||||||
"locations",
|
"locations",
|
||||||
@@ -139,7 +158,8 @@ export const EventSetupTab = (
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultValue = locationOptions.find((item) => item.label === "video")?.options;
|
const defaultValue = getDefaultLocationValue(locationOptions, "integrations:daily");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{validLocations.length === 0 && (
|
{validLocations.length === 0 && (
|
||||||
@@ -150,6 +170,7 @@ export const EventSetupTab = (
|
|||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
|
menuPlacement="auto"
|
||||||
onChange={(e: SingleValueLocationOption) => {
|
onChange={(e: SingleValueLocationOption) => {
|
||||||
if (e?.value) {
|
if (e?.value) {
|
||||||
const newLocationType = e.value;
|
const newLocationType = e.value;
|
||||||
@@ -175,8 +196,16 @@ export const EventSetupTab = (
|
|||||||
if (!eventLocationType) {
|
if (!eventLocationType) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// We dont want to translate the string link - it doesnt exist in common.json and it gets prefixed/suffixed with __ or //
|
||||||
|
const eventLabel =
|
||||||
|
eventLocationType.defaultValueVariable === "link"
|
||||||
|
? eventLocationType.label
|
||||||
|
: t(location[eventLocationType.defaultValueVariable] || eventLocationType.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={location.type} className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
|
<li
|
||||||
|
key={`${location.type}${index}`}
|
||||||
|
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
|
||||||
<div className="flex max-w-full justify-between">
|
<div className="flex max-w-full justify-between">
|
||||||
<div key={index} className="flex flex-grow items-center">
|
<div key={index} className="flex flex-grow items-center">
|
||||||
<img
|
<img
|
||||||
@@ -184,9 +213,7 @@ export const EventSetupTab = (
|
|||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
alt={`${eventLocationType.label} logo`}
|
alt={`${eventLocationType.label} logo`}
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">
|
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">{eventLabel}</span>
|
||||||
{t(location[eventLocationType.defaultValueVariable] || eventLocationType.label)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
|
|||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof mapUserToValue>) => {
|
||||||
|
if (a.label < b.label) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.label > b.label) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
const FixedHosts = ({
|
const FixedHosts = ({
|
||||||
control,
|
control,
|
||||||
labelText,
|
labelText,
|
||||||
@@ -103,7 +113,6 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
|||||||
];
|
];
|
||||||
|
|
||||||
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{team && (
|
{team && (
|
||||||
@@ -128,17 +137,16 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
|||||||
|
|
||||||
{schedulingType === SchedulingType.COLLECTIVE && (
|
{schedulingType === SchedulingType.COLLECTIVE && (
|
||||||
<FixedHosts
|
<FixedHosts
|
||||||
options={teamMembersOptions}
|
options={teamMembersOptions.sort(sortByLabel)}
|
||||||
placeholder={t("add_attendees")}
|
placeholder={t("add_attendees")}
|
||||||
labelText={t("team")}
|
labelText={t("team")}
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schedulingType === SchedulingType.ROUND_ROBIN && (
|
{schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||||
<>
|
<>
|
||||||
<FixedHosts
|
<FixedHosts
|
||||||
options={teamMembersOptions}
|
options={teamMembersOptions.sort(sortByLabel)}
|
||||||
isOptionDisabled={(option) =>
|
isOptionDisabled={(option) =>
|
||||||
!!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value)
|
!!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value)
|
||||||
}
|
}
|
||||||
@@ -173,7 +181,7 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
|||||||
)
|
)
|
||||||
.filter(Boolean)}
|
.filter(Boolean)}
|
||||||
controlShouldRenderValue={false}
|
controlShouldRenderValue={false}
|
||||||
options={teamMembers.map(mapUserToValue)}
|
options={teamMembersOptions.sort(sortByLabel)}
|
||||||
isOptionDisabled={(option) =>
|
isOptionDisabled={(option) =>
|
||||||
!!formMethods
|
!!formMethods
|
||||||
.getValues("hostsFixed")
|
.getValues("hostsFixed")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TbWebhook } from "react-icons/tb";
|
import { TbWebhook } from "react-icons/tb";
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export const EventTeamWebhooksTab = ({
|
|||||||
<>
|
<>
|
||||||
{webhooks.length ? (
|
{webhooks.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 mb-8 rounded-md border">
|
<div className="mb-8 rounded-md border">
|
||||||
{webhooks.map((webhook, index) => {
|
{webhooks.map((webhook, index) => {
|
||||||
return (
|
return (
|
||||||
<WebhookListItem
|
<WebhookListItem
|
||||||
|
|||||||
@@ -195,7 +195,6 @@ function EventTypeSingleLayout({
|
|||||||
backPath="/event-types"
|
backPath="/event-types"
|
||||||
title={eventType.title + " | " + t("event_type")}
|
title={eventType.title + " | " + t("event_type")}
|
||||||
heading={eventType.title}
|
heading={eventType.title}
|
||||||
subtitle={eventType.description || ""}
|
|
||||||
CTA={
|
CTA={
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<div className="hidden items-center rounded-md px-2 sm:flex sm:hover:bg-gray-100">
|
<div className="hidden items-center rounded-md px-2 sm:flex sm:hover:bg-gray-100">
|
||||||
@@ -287,6 +286,7 @@ function EventTypeSingleLayout({
|
|||||||
<DropdownMenuItem className="focus:ring-gray-100">
|
<DropdownMenuItem className="focus:ring-gray-100">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
type="button"
|
type="button"
|
||||||
|
color="destructive"
|
||||||
StartIcon={FiTrash}
|
StartIcon={FiTrash}
|
||||||
disabled={!hasPermsToDelete}
|
disabled={!hasPermsToDelete}
|
||||||
onClick={() => setDeleteDialogOpen(true)}>
|
onClick={() => setDeleteDialogOpen(true)}>
|
||||||
@@ -323,7 +323,7 @@ function EventTypeSingleLayout({
|
|||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<Suspense fallback={<FiLoader />}>
|
<Suspense fallback={<FiLoader />}>
|
||||||
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
<div className="flex flex-col xl:flex-row xl:space-x-6">
|
||||||
<div className="hidden xl:block">
|
<div className="hidden xl:block">
|
||||||
<VerticalTabs
|
<VerticalTabs
|
||||||
className="primary-navigation"
|
className="primary-navigation"
|
||||||
@@ -339,7 +339,7 @@ function EventTypeSingleLayout({
|
|||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mt-4 rounded-md border-gray-200 bg-white sm:mx-0 xl:mt-0",
|
"mt-4 rounded-md border-gray-200 bg-white sm:mx-0 xl:mt-0",
|
||||||
disableBorder ? "border-0 xl:-mt-4 " : "p-2 md:border md:p-6"
|
disableBorder ? "border-0 " : "p-2 md:border md:p-6"
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import { CalendarSwitch } from "./CalendarSwitch";
|
import { CalendarSwitch } from "./CalendarSwitch";
|
||||||
|
|
||||||
interface IConnectedCalendarItem {
|
interface IConnectedCalendarItem {
|
||||||
@@ -21,7 +19,7 @@ interface IConnectedCalendarItem {
|
|||||||
|
|
||||||
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
|
||||||
const { name, logo, externalId, calendars, integrationType } = prop;
|
const { name, logo, externalId, calendars, integrationType } = prop;
|
||||||
const { t } = useLocale();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-row items-center p-4">
|
<div className="flex flex-row items-center p-4">
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { FormEvent, useRef, useState } from "react";
|
import { FormEvent, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
|
import turndown from "@calcom/lib/turndownService";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, ImageUploader, showToast, TextArea } from "@calcom/ui";
|
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||||
import { Avatar } from "@calcom/ui";
|
import { Avatar } from "@calcom/ui";
|
||||||
|
|
||||||
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||||
|
|
||||||
|
const md = new MarkdownIt("default", { html: true, breaks: true });
|
||||||
|
|
||||||
type FormData = {
|
type FormData = {
|
||||||
bio: string;
|
bio: string;
|
||||||
};
|
};
|
||||||
@@ -21,18 +26,16 @@ const UserProfile = (props: IUserProfileProps) => {
|
|||||||
const { user } = props;
|
const { user } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const avatarRef = useRef<HTMLInputElement>(null!);
|
const avatarRef = useRef<HTMLInputElement>(null!);
|
||||||
const bioRef = useRef<HTMLTextAreaElement>(null);
|
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||||
const {
|
defaultValues: { bio: user?.bio || "" },
|
||||||
register,
|
});
|
||||||
setValue,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<FormData>({ defaultValues: { bio: user?.bio || "" } });
|
|
||||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||||
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
|
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||||
onSuccess: async (_data, context) => {
|
onSuccess: async (_data, context) => {
|
||||||
@@ -63,6 +66,8 @@ const UserProfile = (props: IUserProfileProps) => {
|
|||||||
const onSubmit = handleSubmit((data: { bio: string }) => {
|
const onSubmit = handleSubmit((data: { bio: string }) => {
|
||||||
const { bio } = data;
|
const { bio } = data;
|
||||||
|
|
||||||
|
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||||
|
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
bio,
|
bio,
|
||||||
completedOnboarding: true,
|
completedOnboarding: true,
|
||||||
@@ -138,25 +143,12 @@ const UserProfile = (props: IUserProfileProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<fieldset className="mt-8">
|
<fieldset className="mt-8">
|
||||||
<label htmlFor="bio" className="mb-2 block text-sm font-medium text-gray-700">
|
<Label className="mb-2 block text-sm font-medium text-gray-700">{t("about")}</Label>
|
||||||
{t("about")}
|
<Editor
|
||||||
</label>
|
getText={() => md.render(getValues("bio") || user?.bio || "")}
|
||||||
<TextArea
|
setText={(value: string) => setValue("bio", turndown(value))}
|
||||||
{...register("bio", { required: true })}
|
excludedToolbarItems={["blockType"]}
|
||||||
ref={bioRef}
|
|
||||||
name="bio"
|
|
||||||
id="bio"
|
|
||||||
className="mt-1 block h-[60px] w-full rounded-sm border border-gray-300 px-3 py-2 focus:border-gray-500 focus:outline-none focus:ring-gray-500 sm:text-sm"
|
|
||||||
defaultValue={user?.bio || undefined}
|
|
||||||
onChange={(event) => {
|
|
||||||
setValue("bio", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{errors.bio && (
|
|
||||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
|
||||||
{t("required")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 font-sans text-sm font-normal text-gray-600 dark:text-white">
|
<p className="mt-2 font-sans text-sm font-normal text-gray-600 dark:text-white">
|
||||||
{t("few_sentences_about_yourself")}
|
{t("few_sentences_about_yourself")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, TimezoneSelect } from "@calcom/ui";
|
import { Button, TimezoneSelect } from "@calcom/ui";
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
const { user, nextStep } = props;
|
const { user, nextStep } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
||||||
|
const telemetry = useTelemetry();
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -31,6 +32,11 @@ const UserSettings = (props: IUserSettingsProps) => {
|
|||||||
},
|
},
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
telemetry.event(telemetryEventTypes.onboardingStarted);
|
||||||
|
}, [telemetry]);
|
||||||
|
|
||||||
const defaultOptions = { required: true, maxLength: 255 };
|
const defaultOptions = { required: true, maxLength: 255 };
|
||||||
|
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Badge, Button } from "@calcom/ui";
|
import { Badge, Button } from "@calcom/ui";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import DisableTwoFactorModal from "./DisableTwoFactorModal";
|
import DisableTwoFactorModal from "./DisableTwoFactorModal";
|
||||||
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -1,17 +1,39 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import React from "react";
|
||||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { isPasswordValid } from "@calcom/lib/auth";
|
import { isPasswordValid } from "@calcom/lib/auth";
|
||||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { EmailField, Label, PasswordField, TextField } from "@calcom/ui";
|
import { EmailField, EmptyScreen, Label, PasswordField, TextField } from "@calcom/ui";
|
||||||
|
import { FiUserCheck } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
export const AdminUserContainer = (props: React.ComponentProps<typeof AdminUser> & { userCount: number }) => {
|
||||||
const router = useRouter();
|
const { t } = useLocale();
|
||||||
|
if (props.userCount > 0)
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
id="wizard-step-1"
|
||||||
|
name="wizard-step-1"
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
props.onSuccess();
|
||||||
|
}}>
|
||||||
|
<EmptyScreen
|
||||||
|
Icon={FiUserCheck}
|
||||||
|
headline={t("admin_user_created")}
|
||||||
|
description={t("admin_user_created_description")}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
return <AdminUser {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminUser = (props: { onSubmit: () => void; onError: () => void; onSuccess: () => void }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -45,11 +67,11 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
props.setIsLoading(false);
|
props.onError();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
||||||
props.setIsLoading(true);
|
props.onSubmit();
|
||||||
const response = await fetch("/api/auth/setup", {
|
const response = await fetch("/api/auth/setup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -69,9 +91,9 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
email: data.email_address.toLowerCase(),
|
email: data.email_address.toLowerCase(),
|
||||||
password: data.password,
|
password: data.password,
|
||||||
});
|
});
|
||||||
router.replace(`/auth/setup?step=2&category=calendar`);
|
props.onSuccess();
|
||||||
} else {
|
} else {
|
||||||
router.replace("/auth/setup");
|
props.onError();
|
||||||
}
|
}
|
||||||
}, onError);
|
}, onError);
|
||||||
|
|
||||||
@@ -186,5 +208,3 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SetupFormStep1;
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
|
const ENTERPRISE_BOOKING_FEE = "$99"; // TODO: get this from a new API endpoint
|
||||||
|
|
||||||
|
const ChooseLicense = (
|
||||||
|
props: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
} & Omit<JSX.IntrinsicElements["form"], "onSubmit" | "onChange">
|
||||||
|
) => {
|
||||||
|
const { value: initialValue = "FREE", onChange, onSubmit, ...rest } = props;
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
{...rest}
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(value);
|
||||||
|
}}>
|
||||||
|
<RadioGroup.Root
|
||||||
|
defaultValue={initialValue}
|
||||||
|
value={value}
|
||||||
|
aria-label={t("choose_a_license")}
|
||||||
|
className="grid grid-rows-2 gap-4 md:grid-cols-2 md:grid-rows-1"
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onChange(value);
|
||||||
|
setValue(value);
|
||||||
|
}}>
|
||||||
|
<RadioGroup.Item value="FREE">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"cursor-pointer space-y-2 rounded-md border bg-white p-4 hover:border-black",
|
||||||
|
value === "FREE" && "ring-2 ring-black"
|
||||||
|
)}>
|
||||||
|
<h2 className="font-cal text-xl text-black">{t("agplv3_license")}</h2>
|
||||||
|
<p className="font-medium text-green-800">{t("free_license_fee")}</p>
|
||||||
|
<p className="text-gray-500">{t("forever_open_and_free")}</p>
|
||||||
|
<ul className="ml-4 list-disc text-left text-xs text-gray-500">
|
||||||
|
<li>{t("required_to_keep_your_code_open_source")}</li>
|
||||||
|
<li>{t("cannot_repackage_and_resell")}</li>
|
||||||
|
<li>{t("no_enterprise_features")}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Item>
|
||||||
|
<RadioGroup.Item value="EE">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"cursor-pointer space-y-2 rounded-md border bg-white p-4 hover:border-black",
|
||||||
|
value === "EE" && "ring-2 ring-black"
|
||||||
|
)}>
|
||||||
|
<h2 className="font-cal text-xl text-black">{t("ee_enterprise_license")}</h2>
|
||||||
|
<p className="font-medium text-green-800">
|
||||||
|
{t("enterprise_booking_fee", { enterprise_booking_fee: ENTERPRISE_BOOKING_FEE })}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500">{t("enterprise_license_includes")}</p>
|
||||||
|
<ul className="ml-4 list-disc text-left text-xs text-gray-500">
|
||||||
|
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||||
|
<li>{t("repackage_rebrand_resell")}</li>
|
||||||
|
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Item>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChooseLicense;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { noop } from "lodash";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Controller, FormProvider, useForm, useFormState } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
|
import { Button, TextField } from "@calcom/ui";
|
||||||
|
import { FiCheck, FiExternalLink, FiLoader } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
type EnterpriseLicenseFormValues = {
|
||||||
|
licenseKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSchemaLicenseKey = (args: { callback: (valid: boolean) => void; onSuccessValidate: () => void }) =>
|
||||||
|
z.object({
|
||||||
|
licenseKey: z
|
||||||
|
.string()
|
||||||
|
.uuid({
|
||||||
|
message: "License key must follow UUID format: 8-4-4-4-12",
|
||||||
|
})
|
||||||
|
.superRefine(async (data, ctx) => {
|
||||||
|
const parse = z.string().uuid().safeParse(data);
|
||||||
|
if (parse.success) {
|
||||||
|
args.callback(true);
|
||||||
|
const response = await fetch(`${CONSOLE_URL}/api/license?key=${data}`);
|
||||||
|
args.callback(false);
|
||||||
|
const json = await response.json();
|
||||||
|
if (!json.valid) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `License key ${json.message.toLowerCase()}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
args.onSuccessValidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EnterpriseLicense = (
|
||||||
|
props: {
|
||||||
|
licenseKey?: string;
|
||||||
|
initialValue?: Partial<EnterpriseLicenseFormValues>;
|
||||||
|
onSuccessValidate: () => void;
|
||||||
|
onSubmit: (value: EnterpriseLicenseFormValues) => void;
|
||||||
|
onSuccess?: (
|
||||||
|
data: RouterOutputs["viewer"]["deploymentSetup"]["update"],
|
||||||
|
variables: RouterInputs["viewer"]["deploymentSetup"]["update"]
|
||||||
|
) => void;
|
||||||
|
} & Omit<JSX.IntrinsicElements["form"], "onSubmit">
|
||||||
|
) => {
|
||||||
|
const { onSubmit, onSuccess = noop, onSuccessValidate = noop, ...rest } = props;
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [checkLicenseLoading, setCheckLicenseLoading] = useState(false);
|
||||||
|
const mutation = trpc.viewer.deploymentSetup.update.useMutation({
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaLicenseKey = useCallback(
|
||||||
|
() =>
|
||||||
|
makeSchemaLicenseKey({
|
||||||
|
callback: setCheckLicenseLoading,
|
||||||
|
onSuccessValidate,
|
||||||
|
}),
|
||||||
|
[setCheckLicenseLoading, onSuccessValidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formMethods = useForm<EnterpriseLicenseFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
licenseKey: props.licenseKey || "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(schemaLicenseKey()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = formMethods.handleSubmit((values) => {
|
||||||
|
onSubmit(values);
|
||||||
|
setCheckLicenseLoading(false);
|
||||||
|
mutation.mutate(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isDirty, errors } = useFormState(formMethods);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...formMethods}>
|
||||||
|
<form {...rest} className="space-y-4 rounded-md bg-white px-8 py-10" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className="w-full justify-center text-lg"
|
||||||
|
EndIcon={FiExternalLink}
|
||||||
|
href="https://console.cal.com"
|
||||||
|
target="_blank">
|
||||||
|
{t("purchase_license")}
|
||||||
|
</Button>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<hr className="my-8 w-full border-[1.5px] border-gray-200" />
|
||||||
|
<span className="absolute mt-[22px] bg-white px-3.5 text-sm">OR</span>
|
||||||
|
</div>
|
||||||
|
{t("already_have_key")}
|
||||||
|
<Controller
|
||||||
|
name="licenseKey"
|
||||||
|
control={formMethods.control}
|
||||||
|
render={({ field: { onBlur, onChange, value } }) => (
|
||||||
|
<TextField
|
||||||
|
{...formMethods.register("licenseKey")}
|
||||||
|
className={classNames(
|
||||||
|
"mb-0 group-hover:border-gray-400",
|
||||||
|
(checkLicenseLoading || (errors.licenseKey === undefined && isDirty)) && "border-r-0"
|
||||||
|
)}
|
||||||
|
placeholder="xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx"
|
||||||
|
labelSrOnly={true}
|
||||||
|
value={value}
|
||||||
|
addOnFilled={false}
|
||||||
|
addOnClassname={classNames(
|
||||||
|
"hover:border-gray-300",
|
||||||
|
errors.licenseKey === undefined && isDirty && "group-hover:border-gray-400"
|
||||||
|
)}
|
||||||
|
addOnSuffix={
|
||||||
|
checkLicenseLoading ? (
|
||||||
|
<FiLoader className="h-5 w-5 animate-spin" />
|
||||||
|
) : errors.licenseKey === undefined && isDirty ? (
|
||||||
|
<FiCheck className="h-5 w-5 text-green-700" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
color={errors.licenseKey ? "warn" : ""}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
formMethods.setValue("licenseKey", e.target.value);
|
||||||
|
await formMethods.trigger("licenseKey");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnterpriseLicense;
|
||||||
+11
-5
@@ -1,20 +1,26 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { FiCheck } from "@calcom/ui/components/icon";
|
import { FiCheck } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
const StepDone = () => {
|
const StepDone = (props: {
|
||||||
|
currentStep: number;
|
||||||
|
nextStepPath: string;
|
||||||
|
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
id="wizard-step-1"
|
id={`wizard-step-${props.currentStep}`}
|
||||||
name="wizard-step-1"
|
name={`wizard-step-${props.currentStep}`}
|
||||||
className="space-y-4"
|
className="flex justify-center space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
props.setIsLoading(true);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
router.replace(`/auth/setup?step=2&category=calendar`);
|
router.replace(props.nextStepPath);
|
||||||
}}>
|
}}>
|
||||||
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
|
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
|
||||||
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
|
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import MarkdownIt from "markdown-it";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TeamPageProps } from "pages/team/[slug]";
|
import { TeamPageProps } from "pages/team/[slug]";
|
||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Avatar } from "@calcom/ui";
|
import { Avatar } from "@calcom/ui";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||||
|
|
||||||
type TeamType = TeamPageProps["team"];
|
type TeamType = TeamPageProps["team"];
|
||||||
type MembersType = TeamType["members"];
|
type MembersType = TeamType["members"];
|
||||||
@@ -13,9 +15,11 @@ type MemberType = MembersType[number];
|
|||||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const isBioEmpty = !member.bio || !member.bio.replace("<p><br></p>", "").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={member.id} href={`/${member.username}`}>
|
<Link key={member.id} href={`/${member.username}`}>
|
||||||
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full w-[90%] flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
|
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
|
||||||
<Avatar
|
<Avatar
|
||||||
size="md"
|
size="md"
|
||||||
alt={member.name || ""}
|
alt={member.name || ""}
|
||||||
@@ -23,9 +27,18 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
|
|||||||
/>
|
/>
|
||||||
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
||||||
<p className="font-medium text-gray-900 dark:text-white">{member.name}</p>
|
<p className="font-medium text-gray-900 dark:text-white">{member.name}</p>
|
||||||
<p className="line-clamp-3 overflow-ellipsis text-sm font-normal text-gray-500 dark:text-white">
|
<div className="line-clamp-3 overflow-ellipsis text-sm font-normal text-gray-500 dark:text-white">
|
||||||
{member.bio || t("user_from_team", { user: member.name, team: teamName })}
|
{!isBioEmpty ? (
|
||||||
</p>
|
<>
|
||||||
|
<div
|
||||||
|
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||||
|
dangerouslySetInnerHTML={{ __html: md.render(member.bio || "") }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("user_from_team", { user: member.name, team: teamName })
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -46,7 +59,7 @@ const Members = ({ members, teamName }: { members: MembersType; teamName: string
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Team = ({ team }: TeamPageProps) => {
|
const Team = ({ team }: Omit<TeamPageProps, "trpcState">) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Members members={team.members} teamName={team.name} />
|
<Members members={team.members} teamName={team.name} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { APP_NAME, LOGO } from "@calcom/lib/constants";
|
import { APP_NAME, LOGO } from "@calcom/lib/constants";
|
||||||
import { Credits, HeadSeo } from "@calcom/ui";
|
import { HeadSeo } from "@calcom/ui";
|
||||||
|
|
||||||
import Loader from "@components/Loader";
|
import Loader from "@components/Loader";
|
||||||
|
|
||||||
@@ -35,10 +35,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) {
|
|||||||
<div className="mx-2 rounded-md border border-gray-200 bg-white px-4 py-10 sm:px-10">
|
<div className="mx-2 rounded-md border border-gray-200 bg-white px-4 py-10 sm:px-10">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 text-center text-sm text-gray-600">
|
<div className="mt-8 text-center text-sm text-gray-600">{props.footerText}</div>
|
||||||
{props.footerText}
|
|
||||||
<Credits />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const EditableHeading = function EditableHeading({
|
|||||||
<label className="min-w-8 relative inline-block">
|
<label className="min-w-8 relative inline-block">
|
||||||
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value} </span>
|
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value} </span>
|
||||||
{!isEditing && isReady && (
|
{!isEditing && isReady && (
|
||||||
<FiEdit2 className="ml-1 inline h-4 w-4 align-top text-gray-700 group-hover:text-gray-500" />
|
<FiEdit2 className=" ml-1 -mt-px inline h-3 w-3 text-gray-500 group-hover:text-gray-500" />
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
{...passThroughProps}
|
{...passThroughProps}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||||
@@ -6,6 +5,8 @@ import { User } from "@calcom/prisma/client";
|
|||||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||||
|
|
||||||
|
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||||
|
|
||||||
import { PremiumTextfield } from "./PremiumTextfield";
|
import { PremiumTextfield } from "./PremiumTextfield";
|
||||||
import { UsernameTextfield } from "./UsernameTextfield";
|
import { UsernameTextfield } from "./UsernameTextfield";
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export const UsernameAvailabilityField = ({
|
|||||||
onErrorMutation,
|
onErrorMutation,
|
||||||
user,
|
user,
|
||||||
}: UsernameAvailabilityFieldProps) => {
|
}: UsernameAvailabilityFieldProps) => {
|
||||||
const [currentUsername, setCurrentUsername] = useState(user.username ?? "");
|
const { username: currentUsername, setQuery: setCurrentUsername } = useRouterQuery("username");
|
||||||
const formMethods = useForm({
|
const formMethods = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: currentUsername,
|
username: currentUsername,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const withQuery = <
|
|||||||
TInput = inferProcedureInput<TQuery>,
|
TInput = inferProcedureInput<TQuery>,
|
||||||
TOutput = inferProcedureOutput<TQuery>
|
TOutput = inferProcedureOutput<TQuery>
|
||||||
>(
|
>(
|
||||||
queryProcedure: DecorateProcedure<TQuery, any>,
|
queryProcedure: DecorateProcedure<TQuery, any, any>,
|
||||||
input?: TInput,
|
input?: TInput,
|
||||||
params?: UseTRPCQueryOptions<any, TInput, TOutput, TOutput, TError>
|
params?: UseTRPCQueryOptions<any, TInput, TOutput, TOutput, TError>
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -13,18 +13,20 @@ import { trpc } from "@calcom/trpc/react";
|
|||||||
import { MetaProvider } from "@calcom/ui";
|
import { MetaProvider } from "@calcom/ui";
|
||||||
|
|
||||||
import usePublicPage from "@lib/hooks/usePublicPage";
|
import usePublicPage from "@lib/hooks/usePublicPage";
|
||||||
|
import { WithNonceProps } from "@lib/withNonce";
|
||||||
|
|
||||||
const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children: React.ReactNode }>(
|
const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children: React.ReactNode }>(
|
||||||
({ children }) => <>{children}</>
|
({ children }) => <>{children}</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||||
export type AppProps = Omit<NextAppProps, "Component"> & {
|
export type AppProps = Omit<NextAppProps<WithNonceProps & Record<string, unknown>>, "Component"> & {
|
||||||
Component: NextAppProps["Component"] & {
|
Component: NextAppProps["Component"] & {
|
||||||
requiresLicense?: boolean;
|
requiresLicense?: boolean;
|
||||||
isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean);
|
isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean);
|
||||||
getLayout?: (page: React.ReactElement, router: NextRouter) => ReactNode;
|
getLayout?: (page: React.ReactElement, router: NextRouter) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Will be defined only is there was an error */
|
/** Will be defined only is there was an error */
|
||||||
err?: Error;
|
err?: Error;
|
||||||
};
|
};
|
||||||
@@ -77,6 +79,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
|
nonce={props.pageProps.nonce}
|
||||||
enableColorScheme={false}
|
enableColorScheme={false}
|
||||||
storageKey={storageKey}
|
storageKey={storageKey}
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@calcom/lib/availability";
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// TODO: Remove this file once everything is imported from `@calcom/lib`
|
|
||||||
export * from "@calcom/lib/constants";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@calcom/lib/weekday";
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { IncomingMessage, OutgoingMessage } from "http";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
|
||||||
|
function getCspPolicy(nonce: string) {
|
||||||
|
//TODO: Do we need to explicitly define it in turbo.json
|
||||||
|
const CSP_POLICY = process.env.CSP_POLICY;
|
||||||
|
|
||||||
|
// Note: "non-strict" policy only allows inline styles otherwise it's the same as "strict"
|
||||||
|
// We can remove 'unsafe-inline' from style-src when we add nonces to all style tags
|
||||||
|
// Maybe see how @next-safe/middleware does it if it's supported.
|
||||||
|
const useNonStrictPolicy = CSP_POLICY === "non-strict";
|
||||||
|
|
||||||
|
return `
|
||||||
|
default-src 'self' ${IS_PRODUCTION ? "" : "data:"};
|
||||||
|
script-src ${
|
||||||
|
IS_PRODUCTION
|
||||||
|
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
|
||||||
|
"'nonce-" + nonce + "' 'strict-dynamic' 'self' 'unsafe-inline' https:"
|
||||||
|
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
|
||||||
|
"'unsafe-inline' 'unsafe-eval' https: http:"
|
||||||
|
};
|
||||||
|
object-src 'none';
|
||||||
|
base-uri 'none';
|
||||||
|
child-src app.cal.com;
|
||||||
|
style-src 'self' ${
|
||||||
|
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
|
||||||
|
} app.cal.com;
|
||||||
|
font-src 'self';
|
||||||
|
img-src 'self' https://www.gravatar.com https://img.youtube.com https://eu.ui-avatars.com/api/ data:
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken from @next-safe/middleware
|
||||||
|
const isPagePathRequest = (url: URL) => {
|
||||||
|
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
|
||||||
|
const isFile = /\..*$/;
|
||||||
|
const { pathname } = url;
|
||||||
|
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
|
||||||
|
if (!req) {
|
||||||
|
return { nonce: undefined };
|
||||||
|
}
|
||||||
|
const existingNonce = req.headers["x-nonce"];
|
||||||
|
if (existingNonce) {
|
||||||
|
const existingNoneParsed = z.string().safeParse(existingNonce);
|
||||||
|
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
|
||||||
|
}
|
||||||
|
if (!req.url) {
|
||||||
|
return { nonce: undefined };
|
||||||
|
}
|
||||||
|
const CSP_POLICY = process.env.CSP_POLICY;
|
||||||
|
const cspEnabledForInstance = CSP_POLICY;
|
||||||
|
const nonce = crypto.randomBytes(16).toString("base64");
|
||||||
|
|
||||||
|
const parsedUrl = new URL(req.url, "http://base_url");
|
||||||
|
const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl);
|
||||||
|
if (!cspEnabledForPage) {
|
||||||
|
return {
|
||||||
|
nonce: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
|
||||||
|
// It is generated for all page requests but only used by pages that need CSP
|
||||||
|
req.headers["x-nonce"] = nonce;
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
res.setHeader(
|
||||||
|
req.headers["x-csp-enforce"] === "true"
|
||||||
|
? "Content-Security-Policy"
|
||||||
|
: "Content-Security-Policy-Report-Only",
|
||||||
|
getCspPolicy(nonce)
|
||||||
|
.replace(/\s{2,}/g, " ")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nonce };
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useTranslation } from "next-i18next";
|
|
||||||
|
|
||||||
/** @deprecated use the one from `@calcom/lib/hooks/useLocale` */
|
|
||||||
export const useLocale = () => {
|
|
||||||
const { i18n, t } = useTranslation("common");
|
|
||||||
|
|
||||||
return {
|
|
||||||
i18n,
|
|
||||||
t,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
/* Prefer import from `@calcom/lib/isOutOfBounds` */
|
|
||||||
export * from "@calcom/lib/isOutOfBounds";
|
|
||||||
export { default } from "@calcom/lib/isOutOfBounds";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@calcom/core/location";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "@calcom/prisma";
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// TODO: Remove this file once everything is imported from `@calcom/lib`
|
|
||||||
export * from "@calcom/lib/random";
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
/** Prefer import from `@calcom/lib/slots` */
|
|
||||||
export * from "@calcom/lib/slots";
|
|
||||||
export { default } from "@calcom/lib/slots";
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// TODO: Remove this file once every `classNames` is imported from `@calcom/lib`
|
|
||||||
export * from "@calcom/lib/slugify";
|
|
||||||
export { default } from "@calcom/lib/slugify";
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { GetServerSideProps, GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
|
import { csp } from "@lib/csp";
|
||||||
|
|
||||||
|
export type WithNonceProps = {
|
||||||
|
nonce?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make any getServerSideProps fn return the nonce so that it can be used by Components in the page to add any script tag.
|
||||||
|
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
|
||||||
|
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
|
||||||
|
*/
|
||||||
|
export default function withNonce(getServerSideProps: GetServerSideProps) {
|
||||||
|
return async (context: GetServerSidePropsContext) => {
|
||||||
|
const ssrResponse = await getServerSideProps(context);
|
||||||
|
const { nonce } = csp(context.req, context.res);
|
||||||
|
|
||||||
|
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
|
||||||
|
const nonceProps = nonce
|
||||||
|
? {
|
||||||
|
nonce,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!("props" in ssrResponse)) {
|
||||||
|
return ssrResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
|
||||||
|
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ssrResponse,
|
||||||
|
props: {
|
||||||
|
...ssrResponse.props,
|
||||||
|
...nonceProps,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
+52
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { get } from "@vercel/edge-config";
|
||||||
import { collectEvents } from "next-collect/server";
|
import { collectEvents } from "next-collect/server";
|
||||||
import { NextMiddleware, NextResponse, userAgent } from "next/server";
|
import { NextMiddleware, NextResponse, userAgent } from "next/server";
|
||||||
|
|
||||||
@@ -8,6 +9,28 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry
|
|||||||
const middleware: NextMiddleware = async (req) => {
|
const middleware: NextMiddleware = async (req) => {
|
||||||
const url = req.nextUrl;
|
const url = req.nextUrl;
|
||||||
|
|
||||||
|
if (!url.pathname.startsWith("/api")) {
|
||||||
|
//
|
||||||
|
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
||||||
|
// by the application the user is automatically redirected to /auth/login.
|
||||||
|
//
|
||||||
|
// - For this reason our matchers are sufficient for an app-wide maintenance page.
|
||||||
|
//
|
||||||
|
try {
|
||||||
|
// Check whether the maintenance page should be shown
|
||||||
|
const isInMaintenanceMode = await get<boolean>("isInMaintenanceMode");
|
||||||
|
// If is in maintenance mode, point the url pathname to the maintenance page
|
||||||
|
if (isInMaintenanceMode) {
|
||||||
|
req.nextUrl.pathname = `/maintenance`;
|
||||||
|
return NextResponse.rewrite(req.nextUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// show the default page if EDGE_CONFIG env var is missing,
|
||||||
|
// but log the error to the console
|
||||||
|
// console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (["/api/collect-events", "/api/auth"].some((p) => url.pathname.startsWith(p))) {
|
if (["/api/collect-events", "/api/auth"].some((p) => url.pathname.startsWith(p))) {
|
||||||
const callbackUrl = url.searchParams.get("callbackUrl");
|
const callbackUrl = url.searchParams.get("callbackUrl");
|
||||||
const { isBot } = userAgent(req);
|
const { isBot } = userAgent(req);
|
||||||
@@ -36,11 +59,39 @@ const middleware: NextMiddleware = async (req) => {
|
|||||||
return NextResponse.rewrite(url);
|
return NextResponse.rewrite(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/api/trpc/")) {
|
||||||
|
const requestHeaders = new Headers(req.headers);
|
||||||
|
requestHeaders.set("x-cal-timezone", req.headers.get("x-vercel-ip-timezone") ?? "");
|
||||||
|
return NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/auth/login")) {
|
||||||
|
const moreHeaders = new Headers(req.headers);
|
||||||
|
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
|
||||||
|
moreHeaders.set("x-csp-enforce", "true");
|
||||||
|
return NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: moreHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/api/collect-events/:path*", "/api/auth/:path*", "/apps/routing_forms/:path*", "/:path*/embed"],
|
matcher: [
|
||||||
|
"/api/collect-events/:path*",
|
||||||
|
"/api/auth/:path*",
|
||||||
|
"/apps/routing_forms/:path*",
|
||||||
|
"/:path*/embed",
|
||||||
|
"/api/trpc/:path*",
|
||||||
|
"/auth/login",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default collectEvents({
|
export default collectEvents({
|
||||||
|
|||||||
+44
-2
@@ -35,6 +35,12 @@ if (!process.env.NEXT_PUBLIC_WEBSITE_URL) {
|
|||||||
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.CSP_POLICY === "strict" && process.env.NODE_ENV === "production") {
|
||||||
|
throw new Error(
|
||||||
|
"Strict CSP policy(for style-src) is not yet supported in production. You can experiment with it in Dev Mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!process.env.EMAIL_FROM) {
|
if (!process.env.EMAIL_FROM) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"\x1b[33mwarn",
|
"\x1b[33mwarn",
|
||||||
@@ -76,7 +82,6 @@ if (process.env.ANALYZE === "true") {
|
|||||||
|
|
||||||
plugins.push(withTM);
|
plugins.push(withTM);
|
||||||
plugins.push(withAxiom);
|
plugins.push(withAxiom);
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
i18n,
|
i18n,
|
||||||
@@ -98,7 +103,7 @@ const nextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
webpack: (config) => {
|
webpack: (config, { webpack, buildId }) => {
|
||||||
config.plugins.push(
|
config.plugins.push(
|
||||||
new CopyWebpackPlugin({
|
new CopyWebpackPlugin({
|
||||||
patterns: [
|
patterns: [
|
||||||
@@ -120,6 +125,8 @@ const nextConfig = {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
config.plugins.push(new webpack.DefinePlugin({ "process.env.BUILD_ID": JSON.stringify(buildId) }));
|
||||||
|
|
||||||
config.resolve.fallback = {
|
config.resolve.fallback = {
|
||||||
...config.resolve.fallback, // if you miss it, all the other options in fallback, specified
|
...config.resolve.fallback, // if you miss it, all the other options in fallback, specified
|
||||||
// by next.js will be dropped. Doesn't make much sense, but how it is
|
// by next.js will be dropped. Doesn't make much sense, but how it is
|
||||||
@@ -176,6 +183,41 @@ const nextConfig = {
|
|||||||
}, */
|
}, */
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/auth/:path*",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Frame-Options",
|
||||||
|
value: "DENY",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/signup",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Frame-Options",
|
||||||
|
value: "DENY",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/:path*",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Content-Type-Options",
|
||||||
|
value: "nosniff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Referrer-Policy",
|
||||||
|
value: "strict-origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async redirects() {
|
async redirects() {
|
||||||
const redirects = [
|
const redirects = [
|
||||||
{
|
{
|
||||||
|
|||||||
+13
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "2.5.5",
|
"version": "2.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"yarn": ">=1.19.0 < 2.0.0"
|
"yarn": ">=1.19.0 < 2.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boxyhq/saml-jackson": "1.3.6",
|
"@boxyhq/saml-jackson": "1.7.1",
|
||||||
"@calcom/app-store": "*",
|
"@calcom/app-store": "*",
|
||||||
"@calcom/app-store-cli": "*",
|
"@calcom/app-store-cli": "*",
|
||||||
"@calcom/core": "*",
|
"@calcom/core": "*",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "*",
|
||||||
"@calcom/tsconfig": "*",
|
"@calcom/tsconfig": "*",
|
||||||
"@calcom/ui": "*",
|
"@calcom/ui": "*",
|
||||||
"@daily-co/daily-js": "^0.26.0",
|
"@daily-co/daily-js": "^0.37.0",
|
||||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||||
"@glidejs/glide": "^3.5.2",
|
"@glidejs/glide": "^3.5.2",
|
||||||
"@heroicons/react": "^1.0.6",
|
"@heroicons/react": "^1.0.6",
|
||||||
@@ -60,6 +60,8 @@
|
|||||||
"@stripe/react-stripe-js": "^1.10.0",
|
"@stripe/react-stripe-js": "^1.10.0",
|
||||||
"@stripe/stripe-js": "^1.35.0",
|
"@stripe/stripe-js": "^1.35.0",
|
||||||
"@tanstack/react-query": "^4.3.9",
|
"@tanstack/react-query": "^4.3.9",
|
||||||
|
"@types/turndown": "^5.0.1",
|
||||||
|
"@vercel/edge-config": "^0.1.1",
|
||||||
"@vercel/edge-functions-ui": "^0.2.1",
|
"@vercel/edge-functions-ui": "^0.2.1",
|
||||||
"@vercel/og": "^0.0.21",
|
"@vercel/og": "^0.0.21",
|
||||||
"accept-language-parser": "^1.5.0",
|
"accept-language-parser": "^1.5.0",
|
||||||
@@ -118,6 +120,7 @@
|
|||||||
"stripe": "^9.16.0",
|
"stripe": "^9.16.0",
|
||||||
"superjson": "1.9.1",
|
"superjson": "1.9.1",
|
||||||
"tailwindcss-radix": "^2.6.0",
|
"tailwindcss-radix": "^2.6.0",
|
||||||
|
"turndown": "^7.1.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"web3": "^1.7.5",
|
"web3": "^1.7.5",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
@@ -162,6 +165,12 @@
|
|||||||
"tailwindcss": "^3.2.1",
|
"tailwindcss": "^3.2.1",
|
||||||
"ts-jest": "^28.0.8",
|
"ts-jest": "^28.0.8",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^4.9.4"
|
||||||
|
},
|
||||||
|
"nextBundleAnalysis": {
|
||||||
|
"budget": 358400,
|
||||||
|
"budgetPercentIncreaseRed": 20,
|
||||||
|
"minimumChangeThreshold": 500,
|
||||||
|
"showDetails": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,10 @@ import { useRouter } from "next/router";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
|
import { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HeadSeo } from "@calcom/ui";
|
import { HeadSeo } from "@calcom/ui";
|
||||||
import { FiFileText, FiCheck, FiBookOpen, FiChevronRight } from "@calcom/ui/components/icon";
|
import { FiFileText, FiCheck, FiBookOpen, FiChevronRight } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import { ssgInit } from "@server/lib/ssg";
|
import { ssgInit } from "@server/lib/ssg";
|
||||||
|
|
||||||
export default function Custom404() {
|
export default function Custom404() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -34,6 +35,8 @@ import { EmbedProps } from "@lib/withEmbedSsr";
|
|||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
|
||||||
|
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||||
|
|
||||||
export default function User(props: inferSSRProps<typeof getServerSideProps> & EmbedProps) {
|
export default function User(props: inferSSRProps<typeof getServerSideProps> & EmbedProps) {
|
||||||
const { users, profile, eventTypes, isDynamicGroup, dynamicNames, dynamicUsernames, isSingleUser } = props;
|
const { users, profile, eventTypes, isDynamicGroup, dynamicNames, dynamicUsernames, isSingleUser } = props;
|
||||||
const [user] = users; //To be used when we only have a single user, not dynamic group
|
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||||
@@ -41,6 +44,8 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
|
||||||
|
|
||||||
const groupEventTypes = props.users.some((user) => !user.allowDynamicBooking) ? (
|
const groupEventTypes = props.users.some((user) => !user.allowDynamicBooking) ? (
|
||||||
<div className="space-y-6" data-testid="event-types">
|
<div className="space-y-6" data-testid="event-types">
|
||||||
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
|
||||||
@@ -55,7 +60,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
{eventTypes.map((type, index) => (
|
{eventTypes.map((type, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
|
className="dark:bg-darkgray-100 dark:border-darkgray-300 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50">
|
||||||
<FiArrowRight className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
<FiArrowRight className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||||
<Link
|
<Link
|
||||||
href={getUsernameSlugLink({ users: props.users, slug: type.slug })}
|
href={getUsernameSlugLink({ users: props.users, slug: type.slug })}
|
||||||
@@ -138,13 +143,20 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
|
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
|
||||||
)}
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="dark:text-darkgray-600 text-s text-gray-500">{user.bio}</p>
|
{!isBioEmpty && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||||
|
dangerouslySetInnerHTML={{ __html: md.render(user.bio || "") }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"rounded-md ",
|
"rounded-md ",
|
||||||
!isEventListEmpty && "border border-gray-200 dark:border-gray-700 dark:hover:border-gray-600"
|
!isEventListEmpty && "dark:border-darkgray-300 border border-gray-200"
|
||||||
)}
|
)}
|
||||||
data-testid="event-types">
|
data-testid="event-types">
|
||||||
{user.away ? (
|
{user.away ? (
|
||||||
@@ -163,7 +175,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
|||||||
<div
|
<div
|
||||||
key={type.id}
|
key={type.id}
|
||||||
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
|
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
|
||||||
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
|
className="dark:bg-darkgray-100 dark:hover:bg-darkgray-200 dark:border-darkgray-300 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50">
|
||||||
<FiArrowRight className="absolute right-4 top-4 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
<FiArrowRight className="absolute right-4 top-4 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||||
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
|
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
|
||||||
<Link
|
<Link
|
||||||
@@ -279,6 +291,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
}
|
}
|
||||||
const isDynamicGroup = users.length > 1;
|
const isDynamicGroup = users.length > 1;
|
||||||
|
|
||||||
|
if (isDynamicGroup) {
|
||||||
|
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||||
|
users.sort((a, b) => {
|
||||||
|
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||||
|
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const dynamicNames = isDynamicGroup
|
const dynamicNames = isDynamicGroup
|
||||||
? users.map((user) => {
|
? users.map((user) => {
|
||||||
return user.name || "";
|
return user.name || "";
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
import { GetStaticPaths, GetStaticPropsContext } from "next";
|
import { GetStaticPaths, GetStaticPropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { privacyFilteredLocations, LocationObject } from "@calcom/app-store/locations";
|
import { privacyFilteredLocations, LocationObject } from "@calcom/app-store/locations";
|
||||||
|
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { User } from "@calcom/prisma/client";
|
import { User } from "@calcom/prisma/client";
|
||||||
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import {
|
||||||
|
EventTypeMetaDataSchema,
|
||||||
|
teamMetadataSchema,
|
||||||
|
userMetadata as userMetadataSchema,
|
||||||
|
} from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
@@ -216,6 +220,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
|||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
defaultScheduleId: true,
|
defaultScheduleId: true,
|
||||||
allowDynamicBooking: true,
|
allowDynamicBooking: true,
|
||||||
|
metadata: true,
|
||||||
away: true,
|
away: true,
|
||||||
schedules: {
|
schedules: {
|
||||||
select: {
|
select: {
|
||||||
@@ -234,7 +239,32 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||||
|
let sortedUsers: typeof users = [];
|
||||||
|
if (users.length > 1) {
|
||||||
|
sortedUsers = users.sort((a, b) => {
|
||||||
|
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||||
|
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||||
|
|
||||||
|
// Get the prefered location type from the first user
|
||||||
|
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
|
||||||
|
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
|
||||||
|
|
||||||
|
if (preferedLocationType?.appSlug) {
|
||||||
|
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
|
||||||
|
const appType = foundApp?.appData?.location?.type;
|
||||||
|
if (appType) {
|
||||||
|
// Replace the location with the prefered location type
|
||||||
|
// This will still be default to daily if the app is not found
|
||||||
|
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const eventTypeObject = Object.assign({}, eventType, {
|
const eventTypeObject = Object.assign({}, eventType, {
|
||||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
|
||||||
|
|
||||||
import { LocationObject, privacyFilteredLocations } from "@calcom/app-store/locations";
|
import { LocationObject, privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||||
|
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||||
import { parseRecurringEvent } from "@calcom/lib";
|
import { parseRecurringEvent } from "@calcom/lib";
|
||||||
import {
|
import {
|
||||||
getDefaultEvent,
|
getDefaultEvent,
|
||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { bookEventTypeSelect } from "@calcom/prisma";
|
import { bookEventTypeSelect } from "@calcom/prisma";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import {
|
||||||
|
customInputSchema,
|
||||||
|
EventTypeMetaDataSchema,
|
||||||
|
userMetadata as userMetadataSchema,
|
||||||
|
} from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||||
@@ -86,6 +90,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
allowDynamicBooking: true,
|
allowDynamicBooking: true,
|
||||||
away: true,
|
away: true,
|
||||||
|
metadata: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,8 +123,38 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventTypeObject = [eventType].map((e) => {
|
const getLocations = () => {
|
||||||
let locations = eventTypeRaw.locations || [];
|
let locations = eventTypeRaw.locations || [];
|
||||||
|
if (!isDynamicGroupBooking) return locations;
|
||||||
|
|
||||||
|
let sortedUsers: typeof users = [];
|
||||||
|
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||||
|
if (users.length > 1) {
|
||||||
|
sortedUsers = users.sort((a, b) => {
|
||||||
|
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||||
|
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the prefered location type from the first user
|
||||||
|
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
|
||||||
|
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
|
||||||
|
|
||||||
|
if (preferedLocationType?.appSlug) {
|
||||||
|
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
|
||||||
|
const appType = foundApp?.appData?.location?.type;
|
||||||
|
if (appType) {
|
||||||
|
// Replace the location with the prefered location type
|
||||||
|
// This will still be default to daily if the app is not found
|
||||||
|
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return locations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventTypeObject = [eventType].map((e) => {
|
||||||
|
let locations = getLocations();
|
||||||
locations = privacyFilteredLocations(locations as LocationObject[]);
|
locations = privacyFilteredLocations(locations as LocationObject[]);
|
||||||
return {
|
return {
|
||||||
...e,
|
...e,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* This page is empty for the user, it is used only to take advantage of the
|
||||||
|
* caching system that NextJS uses SSG pages.
|
||||||
|
* TODO: Redirect to user profile on browser
|
||||||
|
*/
|
||||||
|
import { GetStaticPaths, GetStaticProps } from "next";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getCachedResults } from "@calcom/core";
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
const CalendarCache = () => <div />;
|
||||||
|
|
||||||
|
const paramsSchema = z.object({ user: z.string(), month: z.string() });
|
||||||
|
export const getStaticProps: GetStaticProps<
|
||||||
|
{ results: Awaited<ReturnType<typeof getCachedResults>> },
|
||||||
|
{ user: string }
|
||||||
|
> = async (context) => {
|
||||||
|
const { user: username, month } = paramsSchema.parse(context.params);
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
credentials: true,
|
||||||
|
selectedCalendars: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const startDate = (
|
||||||
|
dayjs(month, "YYYY-MM").isSame(dayjs(), "month") ? dayjs() : dayjs(month, "YYYY-MM")
|
||||||
|
).startOf("day");
|
||||||
|
const endDate = startDate.endOf("month");
|
||||||
|
try {
|
||||||
|
const results = user?.credentials
|
||||||
|
? await getCachedResults(
|
||||||
|
user?.credentials,
|
||||||
|
startDate.format(),
|
||||||
|
endDate.format(),
|
||||||
|
user?.selectedCalendars
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
props: { results, date: new Date().toISOString() },
|
||||||
|
revalidate: 1,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
let message = "Unknown error while fetching calendarƒ";
|
||||||
|
if (error instanceof Error) message = error.message;
|
||||||
|
console.error(error, message);
|
||||||
|
return {
|
||||||
|
props: { results: [], date: new Date().toISOString(), message },
|
||||||
|
revalidate: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStaticPaths: GetStaticPaths = () => {
|
||||||
|
return {
|
||||||
|
paths: [],
|
||||||
|
fallback: "blocking",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CalendarCache;
|
||||||
+17
-86
@@ -1,18 +1,10 @@
|
|||||||
import { DefaultSeo } from "next-seo";
|
import { DefaultSeo } from "next-seo";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import superjson from "superjson";
|
|
||||||
|
|
||||||
import "@calcom/embed-core/src/embed-iframe";
|
import "@calcom/embed-core/src/embed-iframe";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||||
import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { httpLink } from "@calcom/trpc/client/links/httpLink";
|
|
||||||
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
|
|
||||||
import { splitLink } from "@calcom/trpc/client/links/splitLink";
|
|
||||||
import { withTRPC } from "@calcom/trpc/next";
|
|
||||||
import type { TRPCClientErrorLike } from "@calcom/trpc/react";
|
|
||||||
import { Maybe } from "@calcom/trpc/server";
|
|
||||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
|
||||||
|
|
||||||
import AppProviders, { AppProps } from "@lib/app-providers";
|
import AppProviders, { AppProps } from "@lib/app-providers";
|
||||||
import { seoConfig } from "@lib/config/next-seo.config";
|
import { seoConfig } from "@lib/config/next-seo.config";
|
||||||
@@ -29,13 +21,27 @@ function MyApp(props: AppProps) {
|
|||||||
} else if (router.pathname === "/500") {
|
} else if (router.pathname === "/500") {
|
||||||
pageStatus = "500";
|
pageStatus = "500";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On client side don't let nonce creep into DOM
|
||||||
|
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||||
|
// See https://github.com/kentcdodds/nonce-hydration-issues
|
||||||
|
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
|
||||||
|
const nonce = typeof window !== "undefined" ? (pageProps.nonce ? "" : undefined) : pageProps.nonce;
|
||||||
|
const providerProps = {
|
||||||
|
...props,
|
||||||
|
pageProps: {
|
||||||
|
...props.pageProps,
|
||||||
|
nonce,
|
||||||
|
},
|
||||||
|
};
|
||||||
// Use the layout defined at the page level, if available
|
// Use the layout defined at the page level, if available
|
||||||
const getLayout = Component.getLayout ?? ((page) => page);
|
const getLayout = Component.getLayout ?? ((page) => page);
|
||||||
return (
|
return (
|
||||||
<AppProviders {...props}>
|
<AppProviders {...providerProps}>
|
||||||
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
||||||
<I18nLanguageHandler />
|
<I18nLanguageHandler />
|
||||||
<Script
|
<Script
|
||||||
|
nonce={nonce}
|
||||||
id="page-status"
|
id="page-status"
|
||||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||||
/>
|
/>
|
||||||
@@ -56,79 +62,4 @@ function MyApp(props: AppProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTRPC<AppRouter>({
|
export default trpc.withTRPC(MyApp);
|
||||||
config() {
|
|
||||||
const url =
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? "/api/trpc"
|
|
||||||
: process.env.VERCEL_URL
|
|
||||||
? `https://${process.env.VERCEL_URL}/api/trpc`
|
|
||||||
: `http://${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If you want to use SSR, you need to use the server's full URL
|
|
||||||
* @link https://trpc.io/docs/ssr
|
|
||||||
*/
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* @link https://trpc.io/docs/links
|
|
||||||
*/
|
|
||||||
links: [
|
|
||||||
// adds pretty logs to your console in development and logs errors in production
|
|
||||||
loggerLink({
|
|
||||||
enabled: (opts) =>
|
|
||||||
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
|
|
||||||
}),
|
|
||||||
splitLink({
|
|
||||||
// check for context property `skipBatch`
|
|
||||||
condition: (op) => {
|
|
||||||
return op.context.skipBatch === true;
|
|
||||||
},
|
|
||||||
// when condition is true, use normal request
|
|
||||||
true: httpLink({ url }),
|
|
||||||
// when condition is false, use batching
|
|
||||||
false: httpBatchLink({
|
|
||||||
url,
|
|
||||||
/** @link https://github.com/trpc/trpc/issues/2008 */
|
|
||||||
// maxBatchSize: 7
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
/**
|
|
||||||
* @link https://react-query.tanstack.com/reference/QueryClient
|
|
||||||
*/
|
|
||||||
queryClientConfig: {
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
/**
|
|
||||||
* 1s should be enough to just keep identical query waterfalls low
|
|
||||||
* @example if one page components uses a query that is also used further down the tree
|
|
||||||
*/
|
|
||||||
staleTime: 1000,
|
|
||||||
/**
|
|
||||||
* Retry `useQuery()` calls depending on this function
|
|
||||||
*/
|
|
||||||
retry(failureCount, _err) {
|
|
||||||
const err = _err as never as Maybe<TRPCClientErrorLike<AppRouter>>;
|
|
||||||
const code = err?.data?.code;
|
|
||||||
if (code === "BAD_REQUEST" || code === "FORBIDDEN" || code === "UNAUTHORIZED") {
|
|
||||||
// if input data is wrong or you're not authorized there's no point retrying a query
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const MAX_QUERY_RETRIES = 3;
|
|
||||||
return failureCount < MAX_QUERY_RETRIES;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* @link https://trpc.io/docs/data-transformers
|
|
||||||
*/
|
|
||||||
transformer: superjson,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* @link https://trpc.io/docs/ssr
|
|
||||||
*/
|
|
||||||
ssr: false,
|
|
||||||
})(MyApp);
|
|
||||||
@@ -1,58 +1,36 @@
|
|||||||
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
|
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getDirFromLang } from "@calcom/lib/i18n";
|
import { getDirFromLang } from "@calcom/lib/i18n";
|
||||||
|
|
||||||
|
import { csp } from "@lib/csp";
|
||||||
|
|
||||||
type Props = Record<string, unknown> & DocumentProps;
|
type Props = Record<string, unknown> & DocumentProps;
|
||||||
|
|
||||||
function toRunBeforeReactOnClient() {
|
|
||||||
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
|
|
||||||
/* Iframe Name */
|
|
||||||
window.name.includes("cal-embed");
|
|
||||||
|
|
||||||
window.isEmbed = () => {
|
|
||||||
// Once an embed mode always an embed mode
|
|
||||||
return calEmbedMode;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.resetEmbedStatus = () => {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
|
||||||
window.sessionStorage.removeItem("calEmbedMode");
|
|
||||||
} catch (e) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.getEmbedTheme = () => {
|
|
||||||
const url = new URL(document.URL);
|
|
||||||
return url.searchParams.get("theme") as "dark" | "light";
|
|
||||||
};
|
|
||||||
|
|
||||||
window.getEmbedNamespace = () => {
|
|
||||||
const url = new URL(document.URL);
|
|
||||||
const namespace = url.searchParams.get("embed");
|
|
||||||
return namespace;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.isPageOptimizedForEmbed = () => {
|
|
||||||
// Those pages are considered optimized, which know at backend that they are rendering for embed.
|
|
||||||
// Such pages can be shown straightaway without a loader for a better embed experience
|
|
||||||
return location.pathname.includes("forms/");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class MyDocument extends Document<Props> {
|
class MyDocument extends Document<Props> {
|
||||||
static async getInitialProps(ctx: DocumentContext) {
|
static async getInitialProps(ctx: DocumentContext) {
|
||||||
|
const { nonce } = csp(ctx.req || null, ctx.res || null);
|
||||||
|
if (!process.env.CSP_POLICY) {
|
||||||
|
ctx.res?.setHeader("x-csp", "not-opted-in");
|
||||||
|
} else if (!ctx.res?.getHeader("x-csp")) {
|
||||||
|
// If x-csp not set by gSSP, then it's initialPropsOnly
|
||||||
|
ctx.res?.setHeader("x-csp", "initialPropsOnly");
|
||||||
|
}
|
||||||
const isEmbed = ctx.asPath?.includes("/embed") || ctx.asPath?.includes("embedType=");
|
const isEmbed = ctx.asPath?.includes("/embed") || ctx.asPath?.includes("embedType=");
|
||||||
const initialProps = await Document.getInitialProps(ctx);
|
const initialProps = await Document.getInitialProps(ctx);
|
||||||
return { isEmbed, ...initialProps };
|
return { isEmbed, nonce, ...initialProps };
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { locale } = this.props.__NEXT_DATA__;
|
const { locale } = this.props.__NEXT_DATA__;
|
||||||
|
const { isEmbed } = this.props;
|
||||||
|
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||||
|
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||||
const dir = getDirFromLang(locale);
|
const dir = getDirFromLang(locale);
|
||||||
return (
|
return (
|
||||||
<Html lang={locale} dir={dir}>
|
<Html lang={locale} dir={dir}>
|
||||||
<Head>
|
<Head nonce={nonce}>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
@@ -68,22 +46,13 @@ class MyDocument extends Document<Props> {
|
|||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
/>
|
/>
|
||||||
<link rel="preload" href="/fonts/cal.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
|
<link rel="preload" href="/fonts/cal.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
|
||||||
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
|
<Script src="/embed-init-iframe.js" strategy="beforeInteractive" />
|
||||||
Persist the embed mode in sessionStorage because query param might get lost during browsing.
|
|
||||||
*/}
|
|
||||||
<Script
|
|
||||||
id="run-before-client"
|
|
||||||
strategy="beforeInteractive"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `(${toRunBeforeReactOnClient.toString()})()`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<body
|
<body
|
||||||
className="dark:bg-darkgray-50 desktop-transparent bg-gray-100 antialiased"
|
className="dark:bg-darkgray-50 desktop-transparent bg-gray-100 antialiased"
|
||||||
style={
|
style={
|
||||||
this.props.isEmbed
|
isEmbed
|
||||||
? {
|
? {
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
// Keep the embed hidden till parent initializes and
|
// Keep the embed hidden till parent initializes and
|
||||||
@@ -95,7 +64,7 @@ class MyDocument extends Document<Props> {
|
|||||||
: {}
|
: {}
|
||||||
}>
|
}>
|
||||||
<Main />
|
<Main />
|
||||||
<NextScript />
|
<NextScript nonce={nonce} />
|
||||||
</body>
|
</body>
|
||||||
</Html>
|
</Html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { IdentityProvider, UserPermissionRole } from "@prisma/client";
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import Handlebars from "handlebars";
|
import Handlebars from "handlebars";
|
||||||
import NextAuth, { Session } from "next-auth";
|
import NextAuth, { Session } from "next-auth";
|
||||||
|
import { encode } from "next-auth/jwt";
|
||||||
import { Provider } from "next-auth/providers";
|
import { Provider } from "next-auth/providers";
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
import EmailProvider from "next-auth/providers/email";
|
import EmailProvider from "next-auth/providers/email";
|
||||||
@@ -17,14 +18,14 @@ import { ErrorCode, isPasswordValid, verifyPassword } from "@calcom/lib/auth";
|
|||||||
import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||||
|
import { randomString } from "@calcom/lib/random";
|
||||||
import rateLimit from "@calcom/lib/rateLimit";
|
import rateLimit from "@calcom/lib/rateLimit";
|
||||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
|
import CalComAdapter from "@lib/auth/next-auth-custom-adapter";
|
||||||
import { randomString } from "@lib/random";
|
|
||||||
import slugify from "@lib/slugify";
|
|
||||||
|
|
||||||
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ const providers: Provider[] = [
|
|||||||
username: true,
|
username: true,
|
||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
metadata: true,
|
||||||
identityProvider: true,
|
identityProvider: true,
|
||||||
password: true,
|
password: true,
|
||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
@@ -72,8 +74,9 @@ const providers: Provider[] = [
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Don't leak information about it being username or password that is invalid
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(ErrorCode.UserNotFound);
|
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.identityProvider !== IdentityProvider.CAL) {
|
if (user.identityProvider !== IdentityProvider.CAL) {
|
||||||
@@ -81,12 +84,12 @@ const providers: Provider[] = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
throw new Error(ErrorCode.UserMissingPassword);
|
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
|
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
|
||||||
if (!isCorrectPassword) {
|
if (!isCorrectPassword) {
|
||||||
throw new Error(ErrorCode.IncorrectPassword);
|
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.twoFactorEnabled) {
|
if (user.twoFactorEnabled) {
|
||||||
@@ -161,6 +164,7 @@ if (IS_GOOGLE_LOGIN_ENABLED) {
|
|||||||
GoogleProvider({
|
GoogleProvider({
|
||||||
clientId: GOOGLE_CLIENT_ID,
|
clientId: GOOGLE_CLIENT_ID,
|
||||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||||
|
allowDangerousEmailAccountLinking: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,6 +203,7 @@ if (isSAMLLoginEnabled) {
|
|||||||
clientId: "dummy",
|
clientId: "dummy",
|
||||||
clientSecret: "dummy",
|
clientSecret: "dummy",
|
||||||
},
|
},
|
||||||
|
allowDangerousEmailAccountLinking: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +238,13 @@ if (true) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNumber(n: string) {
|
||||||
|
return !isNaN(parseFloat(n)) && !isNaN(+n);
|
||||||
|
}
|
||||||
|
|
||||||
const calcomAdapter = CalComAdapter(prisma);
|
const calcomAdapter = CalComAdapter(prisma);
|
||||||
|
|
||||||
export default NextAuth({
|
export default NextAuth({
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -241,6 +252,26 @@ export default NextAuth({
|
|||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
},
|
},
|
||||||
|
jwt: {
|
||||||
|
// decorate the native JWT encode function
|
||||||
|
// Impl. detail: We don't pass through as this function is called with encode/decode functions.
|
||||||
|
encode: async ({ token, maxAge, secret }) => {
|
||||||
|
if (token?.sub && isNumber(token.sub)) {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { id: Number(token.sub) },
|
||||||
|
select: { metadata: true },
|
||||||
|
});
|
||||||
|
// if no user is found, we still don't want to crash here.
|
||||||
|
if (user) {
|
||||||
|
const metadata = userMetadata.parse(user.metadata);
|
||||||
|
if (metadata?.sessionTimeout) {
|
||||||
|
maxAge = metadata.sessionTimeout / 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return encode({ secret, token, maxAge });
|
||||||
|
},
|
||||||
|
},
|
||||||
cookies: defaultCookies(WEBAPP_URL?.startsWith("https://")),
|
cookies: defaultCookies(WEBAPP_URL?.startsWith("https://")),
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/auth/login",
|
signIn: "/auth/login",
|
||||||
@@ -331,7 +362,7 @@ export default NextAuth({
|
|||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
const hasValidLicense = await checkLicense(process.env.CALCOM_LICENSE_KEY || "");
|
const hasValidLicense = await checkLicense(prisma);
|
||||||
const calendsoSession: Session = {
|
const calendsoSession: Session = {
|
||||||
...session,
|
...session,
|
||||||
hasValidLicense,
|
hasValidLicense,
|
||||||
@@ -468,7 +499,13 @@ export default NextAuth({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
|
// User signs up with email/password and then tries to login with Google/SAML using the same email
|
||||||
|
if (
|
||||||
|
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
|
||||||
|
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
|
||||||
return "/auth/error?error=use-password-login";
|
return "/auth/error?error=use-password-login";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { sendPasswordResetEmail } from "@calcom/emails";
|
import { sendPasswordResetEmail } from "@calcom/emails";
|
||||||
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
|
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
|
||||||
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { getTranslation } from "@server/lib/i18n";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const t = await getTranslation(req.body.language ?? "en", "common");
|
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||||
|
|
||||||
|
import { HttpError } from "@lib/core/http/error";
|
||||||
|
|
||||||
|
// This is the callback endpoint for the OIDC provider
|
||||||
|
// A team must set this endpoint in the OIDC provider's configuration
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return res.status(400).send("Method not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, state } = req.query as {
|
||||||
|
code: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { oauthController } = await jackson();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { redirect_url } = await oauthController.oidcAuthzResponse({ code, state });
|
||||||
|
|
||||||
|
if (!redirect_url) {
|
||||||
|
throw new HttpError({
|
||||||
|
message: "No redirect URL found",
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect(302, redirect_url);
|
||||||
|
} catch (err) {
|
||||||
|
const { message, statusCode = 500 } = err as HttpError;
|
||||||
|
|
||||||
|
return res.status(statusCode).send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IdentityProvider } from "@prisma/client";
|
import { IdentityProvider } from "@prisma/client";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest } from "next";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { isPasswordValid } from "@calcom/lib/auth";
|
import { isPasswordValid } from "@calcom/lib/auth";
|
||||||
@@ -50,7 +50,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { message: "First admin user created successfuly." };
|
return { message: "First admin user created successfully." };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultHandler({
|
export default defaultHandler({
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import { IdentityProvider } from "@prisma/client";
|
|||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { hashPassword } from "@calcom/lib/auth";
|
import { hashPassword } from "@calcom/lib/auth";
|
||||||
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import slugify from "@lib/slugify";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return;
|
return;
|
||||||
@@ -44,7 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
OR: [
|
OR: [
|
||||||
{ username },
|
{ username },
|
||||||
{
|
{
|
||||||
AND: [{ email: userEmail }, { password: { not: null } }, { username: { not: null } }],
|
AND: [{ email: userEmail }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
import notEmpty from "@calcom/lib/notEmpty";
|
import notEmpty from "@calcom/lib/notEmpty";
|
||||||
|
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
@@ -65,6 +66,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
res.status(200).json({ message: "Calendar Selection Saved" });
|
res.status(200).json({ message: "Calendar Selection Saved" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (["DELETE", "POST"].includes(req.method)) {
|
||||||
|
await revalidateCalendarCache(res.revalidate, `${session?.user?.username}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
|
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
|
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
|
||||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||||
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { getTranslation } from "@server/lib/i18n";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const apiKey = req.headers.authorization || req.query.apiKey;
|
const apiKey = req.headers.authorization || req.query.apiKey;
|
||||||
if (process.env.CRON_API_KEY !== apiKey) {
|
if (process.env.CRON_API_KEY !== apiKey) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Session } from "next-auth";
|
|||||||
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
|
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
|
||||||
import { getSession } from "@calcom/lib/auth";
|
import { getSession } from "@calcom/lib/auth";
|
||||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||||
|
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler";
|
import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler";
|
||||||
|
|
||||||
@@ -53,7 +54,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
try {
|
try {
|
||||||
/* Absolute path didn't work */
|
/* Absolute path didn't work */
|
||||||
const handlerMap = (await import("@calcom/app-store/apps.server.generated")).apiHandlers;
|
const handlerMap = (await import("@calcom/app-store/apps.server.generated")).apiHandlers;
|
||||||
|
|
||||||
const handlerKey = deriveAppDictKeyFromType(appName, handlerMap);
|
const handlerKey = deriveAppDictKeyFromType(appName, handlerMap);
|
||||||
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
|
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
|
||||||
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
|
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
|
||||||
@@ -63,8 +63,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
|
|
||||||
if (typeof handler === "function") {
|
if (typeof handler === "function") {
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
if (appName.includes("calendar") && req.session?.user?.username) {
|
||||||
|
await revalidateCalendarCache(res.revalidate, req.session?.user?.username);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await defaultIntegrationAddHandler({ user: req.session?.user, ...handler });
|
await defaultIntegrationAddHandler({ user: req.session?.user, ...handler });
|
||||||
|
if (handler.appType.includes("calendar") && req.session?.user?.username) {
|
||||||
|
await revalidateCalendarCache(res.revalidate, req.session?.user?.username);
|
||||||
|
}
|
||||||
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
||||||
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { getSession } from "next-auth/react";
|
||||||
|
|
||||||
|
import { performance } from "@calcom/lib/server/perfObserver";
|
||||||
|
|
||||||
|
let isCold = true;
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||||
|
const prePrismaDate = performance.now();
|
||||||
|
const prisma = (await import("@calcom/prisma")).default;
|
||||||
|
const preSessionDate = performance.now();
|
||||||
|
const session = await getSession({ req });
|
||||||
|
if (!session) return res.status(409).json({ message: "Unauthorized" });
|
||||||
|
const preUserDate = performance.now();
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||||
|
if (!user) return res.status(404).json({ message: "No user found" });
|
||||||
|
const lastUpdate = performance.now();
|
||||||
|
|
||||||
|
res.setHeader("x-is-cold", isCold.toString());
|
||||||
|
isCold = false;
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: `Hello ${user.name}`,
|
||||||
|
prePrismaDate,
|
||||||
|
prismaDuration: `Prisma took ${preSessionDate - prePrismaDate}ms`,
|
||||||
|
preSessionDate,
|
||||||
|
sessionDuration: `Session took ${preUserDate - preSessionDate}ms`,
|
||||||
|
preUserDate,
|
||||||
|
userDuration: `User took ${lastUpdate - preUserDate}ms`,
|
||||||
|
lastUpdate,
|
||||||
|
wasCold: isCold,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
username: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This endpoint revalidates users calendar cache several months ahead
|
||||||
|
* Can be used as webhook
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { username } = querySchema.parse(req.query);
|
||||||
|
try {
|
||||||
|
await revalidateCalendarCache(res.revalidate, username);
|
||||||
|
|
||||||
|
return res.status(200).json({ revalidated: true });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).send({ message: "Error revalidating" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* This file contains tRPC's HTTP response handler
|
* This file contains tRPC's HTTP response handler
|
||||||
*/
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import * as trpcNext from "@calcom/trpc/server/adapters/next";
|
import * as trpcNext from "@calcom/trpc/server/adapters/next";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { appRouter } from "@calcom/trpc/server/routers/_app";
|
import { appRouter } from "@calcom/trpc/server/routers/_app";
|
||||||
@@ -30,34 +32,46 @@ export default trpcNext.createNextApiHandler({
|
|||||||
* @link https://trpc.io/docs/caching#api-response-caching
|
* @link https://trpc.io/docs/caching#api-response-caching
|
||||||
*/
|
*/
|
||||||
responseMeta({ ctx, paths, type, errors }) {
|
responseMeta({ ctx, paths, type, errors }) {
|
||||||
// assuming we have all our public routes in `viewer.public`
|
// Some helpers relevant to this function only
|
||||||
|
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
||||||
|
// assuming you have all your public routes with the keyword `public` in them
|
||||||
const allPublic = paths && paths.every((path) => path.startsWith("viewer.public."));
|
const allPublic = paths && paths.every((path) => path.startsWith("viewer.public."));
|
||||||
// checking that no procedures errored
|
// checking that no procedures errored
|
||||||
const allOk = errors.length === 0;
|
const allOk = errors.length === 0;
|
||||||
// checking we're doing a query request
|
// checking we're doing a query request
|
||||||
const isQuery = type === "query";
|
const isQuery = type === "query";
|
||||||
|
const noHeaders = {};
|
||||||
|
|
||||||
// i18n response depends on request header
|
// We cannot set headers on SSG queries
|
||||||
const nonCacheableQueries = ["viewer.public.i18n"];
|
if (!ctx?.res) return noHeaders;
|
||||||
const isThereANonCacheableQuery = paths?.some((path) => nonCacheableQueries.includes(path));
|
|
||||||
const isThereACacheableQuery = paths?.some((path) => !nonCacheableQueries.includes(path));
|
|
||||||
if (isThereANonCacheableQuery && isThereACacheableQuery) {
|
|
||||||
console.warn(
|
|
||||||
"Cacheable and Non-cacheable queries are mixed in the same request. Not going to cache the request"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
const defaultHeaders: Record<"headers", Record<string, string>> = {
|
||||||
// @ts-ignore ctx.req is available for SSR but not SSG
|
headers: {},
|
||||||
if (!!ctx?.req && allPublic && allOk && isQuery && !isThereANonCacheableQuery) {
|
};
|
||||||
// cache request for 1 day + revalidate once every 5 seconds
|
|
||||||
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
const timezone = z.string().safeParse(ctx.req?.headers["x-vercel-ip-timezone"]);
|
||||||
return {
|
if (timezone.success) defaultHeaders.headers["x-cal-timezone"] = timezone.data;
|
||||||
headers: {
|
|
||||||
"cache-control": `s-maxage=5, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
|
// We need all these conditions to be true to set cache headers
|
||||||
},
|
if (!(allPublic && allOk && isQuery)) return defaultHeaders;
|
||||||
};
|
|
||||||
}
|
// No cache by default
|
||||||
return {};
|
defaultHeaders.headers["cache-control"] = `no-cache`;
|
||||||
|
|
||||||
|
// Our cache can change depending on our current paths value. Since paths is an array,
|
||||||
|
// we want to create a map that can match potential paths with their desired cache value
|
||||||
|
const cacheRules = {
|
||||||
|
"viewer.public.session": `no-cache`,
|
||||||
|
"viewer.public.i18n": `no-cache`,
|
||||||
|
// Revalidation time here should be 1 second, per https://github.com/calcom/cal.com/pull/6823#issuecomment-1423215321
|
||||||
|
"viewer.public.slots.getSchedule": `no-cache`, // FIXME
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Find which element above is an exact match for this group of paths
|
||||||
|
const matchedPath = paths.find((v) => v in cacheRules) as keyof typeof cacheRules;
|
||||||
|
|
||||||
|
if (matchedPath) defaultHeaders.headers["cache-control"] = cacheRules[matchedPath];
|
||||||
|
|
||||||
|
return defaultHeaders;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -19,6 +19,7 @@ export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticP
|
|||||||
<Shell
|
<Shell
|
||||||
isPublic
|
isPublic
|
||||||
backPath="/apps"
|
backPath="/apps"
|
||||||
|
smallHeading
|
||||||
heading={
|
heading={
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
@@ -33,8 +34,7 @@ export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticP
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}>
|
||||||
large>
|
|
||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
{apps.map((app) => {
|
{apps.map((app) => {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useReducer, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||||
import { InstallAppButton } from "@calcom/app-store/components";
|
import { InstallAppButton } from "@calcom/app-store/components";
|
||||||
|
import { EventLocationType, getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
||||||
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
import { AppSetDefaultLinkDailog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||||
|
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||||
import { App } from "@calcom/types/App";
|
import { App } from "@calcom/types/App";
|
||||||
@@ -16,31 +19,40 @@ import {
|
|||||||
List,
|
List,
|
||||||
AppSkeletonLoader as SkeletonLoader,
|
AppSkeletonLoader as SkeletonLoader,
|
||||||
ShellSubHeading,
|
ShellSubHeading,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
Dropdown,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownItem,
|
||||||
|
showToast,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import {
|
import {
|
||||||
FiBarChart,
|
FiBarChart,
|
||||||
FiCalendar,
|
FiCalendar,
|
||||||
FiCreditCard,
|
FiCreditCard,
|
||||||
FiGrid,
|
FiGrid,
|
||||||
|
FiMoreHorizontal,
|
||||||
FiPlus,
|
FiPlus,
|
||||||
FiShare2,
|
FiShare2,
|
||||||
|
FiTrash,
|
||||||
FiVideo,
|
FiVideo,
|
||||||
} from "@calcom/ui/components/icon";
|
} from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { QueryCell } from "@lib/QueryCell";
|
import { QueryCell } from "@lib/QueryCell";
|
||||||
|
|
||||||
|
import AppListCard from "@components/AppListCard";
|
||||||
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||||
import IntegrationListItem from "@components/apps/IntegrationListItem";
|
|
||||||
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
||||||
|
|
||||||
function ConnectOrDisconnectIntegrationButton(props: {
|
function ConnectOrDisconnectIntegrationMenuItem(props: {
|
||||||
credentialIds: number[];
|
credentialIds: number[];
|
||||||
type: App["type"];
|
type: App["type"];
|
||||||
isGlobal?: boolean;
|
isGlobal?: boolean;
|
||||||
installed?: boolean;
|
installed?: boolean;
|
||||||
invalidCredentialIds?: number[];
|
invalidCredentialIds?: number[];
|
||||||
|
handleDisconnect: (credentialId: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { type, credentialIds, isGlobal, installed } = props;
|
const { type, credentialIds, isGlobal, installed, handleDisconnect } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [credentialId] = credentialIds;
|
const [credentialId] = credentialIds;
|
||||||
|
|
||||||
@@ -49,25 +61,17 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
|||||||
utils.viewer.integrations.invalidate();
|
utils.viewer.integrations.invalidate();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (credentialId) {
|
if (credentialId || type === "stripe_payment" || isGlobal) {
|
||||||
if (type === "stripe_payment") {
|
|
||||||
return (
|
|
||||||
<DisconnectIntegration
|
|
||||||
credentialId={credentialId}
|
|
||||||
trashIcon
|
|
||||||
onSuccess={handleOpenChange}
|
|
||||||
buttonProps={{ className: "border border-gray-300" }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisconnectIntegration
|
<DropdownMenuItem>
|
||||||
credentialId={credentialId}
|
<DropdownItem
|
||||||
trashIcon
|
color="destructive"
|
||||||
onSuccess={handleOpenChange}
|
onClick={() => handleDisconnect(credentialId)}
|
||||||
buttonProps={{ className: "border border-gray-300" }}
|
disabled={isGlobal}
|
||||||
/>
|
StartIcon={FiTrash}>
|
||||||
|
{t("remove_app")}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,14 +82,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/** We don't need to "Connect", just show that it's installed */
|
|
||||||
if (isGlobal) {
|
|
||||||
return (
|
|
||||||
<div className="truncate px-3 py-2">
|
|
||||||
<h3 className="text-sm font-medium text-gray-700">{t("default")}</h3>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<InstallAppButton
|
<InstallAppButton
|
||||||
type={type}
|
type={type}
|
||||||
@@ -102,48 +99,115 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
|||||||
interface IntegrationsContainerProps {
|
interface IntegrationsContainerProps {
|
||||||
variant?: typeof InstalledAppVariants[number];
|
variant?: typeof InstalledAppVariants[number];
|
||||||
exclude?: typeof InstalledAppVariants[number][];
|
exclude?: typeof InstalledAppVariants[number][];
|
||||||
|
handleDisconnect: (credentialId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IntegrationsListProps {
|
interface IntegrationsListProps {
|
||||||
variant?: IntegrationsContainerProps["variant"];
|
variant?: IntegrationsContainerProps["variant"];
|
||||||
data: RouterOutputs["viewer"]["integrations"];
|
data: RouterOutputs["viewer"]["integrations"];
|
||||||
|
handleDisconnect: (credentialId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IntegrationsList = ({ data }: IntegrationsListProps) => {
|
const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListProps) => {
|
||||||
|
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
|
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast("Default app updated successfully", "success");
|
||||||
|
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
showToast(`Error: ${error.message}`, "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
<>
|
||||||
{data.items
|
<List>
|
||||||
.filter((item) => item.invalidCredentialIds)
|
{data.items
|
||||||
.map((item) => (
|
.filter((item) => item.invalidCredentialIds)
|
||||||
<IntegrationListItem
|
.map((item) => {
|
||||||
name={item.name}
|
const appSlug = item?.slug;
|
||||||
slug={item.slug}
|
const appIsDefault =
|
||||||
key={item.title}
|
appSlug === defaultConferencingApp?.appSlug ||
|
||||||
title={item.title}
|
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
|
||||||
logo={item.logo}
|
return (
|
||||||
description={item.description}
|
<AppListCard
|
||||||
separate={true}
|
key={item.name}
|
||||||
isTemplate={item.isTemplate}
|
description={item.description}
|
||||||
invalidCredential={item.invalidCredentialIds.length > 0}
|
title={item.name}
|
||||||
actions={
|
logo={item.logo}
|
||||||
<div className="flex w-16 justify-end">
|
isDefault={appIsDefault}
|
||||||
<ConnectOrDisconnectIntegrationButton
|
shouldHighlight
|
||||||
credentialIds={item.credentialIds}
|
slug={item.slug}
|
||||||
type={item.type}
|
invalidCredential={item.invalidCredentialIds.length > 0}
|
||||||
isGlobal={item.isGlobal}
|
actions={
|
||||||
installed
|
<div className="flex justify-end">
|
||||||
invalidCredentialIds={item.invalidCredentialIds}
|
<Dropdown modal={false}>
|
||||||
/>
|
<DropdownMenuTrigger asChild>
|
||||||
</div>
|
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
|
||||||
}>
|
</DropdownMenuTrigger>
|
||||||
<AppSettings slug={item.slug} />
|
<DropdownMenuContent>
|
||||||
</IntegrationListItem>
|
{!appIsDefault && variant === "conferencing" && (
|
||||||
))}
|
<DropdownMenuItem>
|
||||||
</List>
|
<DropdownItem
|
||||||
|
type="button"
|
||||||
|
color="secondary"
|
||||||
|
StartIcon={FiVideo}
|
||||||
|
onClick={() => {
|
||||||
|
const locationType = getEventLocationTypeFromApp(
|
||||||
|
item?.locationOption?.value ?? ""
|
||||||
|
);
|
||||||
|
if (locationType?.linkType === "static") {
|
||||||
|
setLocationType({ ...locationType, slug: appSlug });
|
||||||
|
} else {
|
||||||
|
updateDefaultAppMutation.mutate({
|
||||||
|
appSlug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{t("change_default_conferencing_app")}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<ConnectOrDisconnectIntegrationMenuItem
|
||||||
|
credentialIds={item.credentialIds}
|
||||||
|
type={item.type}
|
||||||
|
isGlobal={item.isGlobal}
|
||||||
|
installed
|
||||||
|
invalidCredentialIds={item.invalidCredentialIds}
|
||||||
|
handleDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<AppSettings slug={item.slug} />
|
||||||
|
</AppListCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
{locationType && (
|
||||||
|
<AppSetDefaultLinkDailog
|
||||||
|
locationType={locationType}
|
||||||
|
setLocationType={() => setLocationType(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps): JSX.Element => {
|
const IntegrationsContainer = ({
|
||||||
|
variant,
|
||||||
|
exclude,
|
||||||
|
handleDisconnect,
|
||||||
|
}: IntegrationsContainerProps): JSX.Element => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const query = trpc.viewer.integrations.useQuery({ variant, exclude, onlyInstalled: true });
|
const query = trpc.viewer.integrations.useQuery({ variant, exclude, onlyInstalled: true });
|
||||||
const emptyIcon = {
|
const emptyIcon = {
|
||||||
@@ -161,46 +225,43 @@ const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps)
|
|||||||
query={query}
|
query={query}
|
||||||
customLoader={<SkeletonLoader />}
|
customLoader={<SkeletonLoader />}
|
||||||
success={({ data }) => {
|
success={({ data }) => {
|
||||||
|
if (!data.items.length) {
|
||||||
|
return (
|
||||||
|
<EmptyScreen
|
||||||
|
Icon={emptyIcon[variant || "other"]}
|
||||||
|
headline={t("no_category_apps", {
|
||||||
|
category: (variant && t(variant).toLowerCase()) || t("other").toLowerCase(),
|
||||||
|
})}
|
||||||
|
description={t(`no_category_apps_description_${variant || "other"}`)}
|
||||||
|
buttonRaw={
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
data-testid={`connect-${variant || "other"}-apps`}
|
||||||
|
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
|
||||||
|
{t(`connect_${variant || "other"}_apps`)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data.items.length > 0 ? (
|
<ShellSubHeading
|
||||||
<div className="rounded-md border border-gray-200 p-7">
|
title={t(variant || "other")}
|
||||||
<ShellSubHeading
|
subtitle={t(`installed_app_${variant || "other"}_description`)}
|
||||||
title={t(variant || "other")}
|
className="mb-6"
|
||||||
subtitle={t(`installed_app_${variant || "other"}_description`)}
|
actions={
|
||||||
className="mb-6"
|
<Button
|
||||||
actions={
|
href={
|
||||||
<Button
|
variant ? `/apps/categories/${variant === "conferencing" ? "video" : variant}` : "/apps"
|
||||||
href={
|
|
||||||
variant
|
|
||||||
? `/apps/categories/${variant === "conferencing" ? "video" : variant}`
|
|
||||||
: "/apps"
|
|
||||||
}
|
|
||||||
color="secondary"
|
|
||||||
StartIcon={FiPlus}>
|
|
||||||
{t("add")}
|
|
||||||
</Button>
|
|
||||||
}
|
}
|
||||||
/>
|
color="secondary"
|
||||||
<IntegrationsList data={data} variant={variant} />
|
StartIcon={FiPlus}>
|
||||||
</div>
|
{t("add")}
|
||||||
) : (
|
</Button>
|
||||||
<EmptyScreen
|
}
|
||||||
Icon={emptyIcon[variant || "other"]}
|
/>
|
||||||
headline={t("no_category_apps", {
|
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||||
category: (variant && t(variant).toLowerCase()) || t("other").toLowerCase(),
|
|
||||||
})}
|
|
||||||
description={t(`no_category_apps_description_${variant || "other"}`)}
|
|
||||||
buttonRaw={
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
data-testid={`connect-${variant || "other"}-apps`}
|
|
||||||
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
|
|
||||||
{t(`connect_${variant || "other"}_apps`)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -214,6 +275,11 @@ const querySchema = z.object({
|
|||||||
|
|
||||||
type querySchemaType = z.infer<typeof querySchema>;
|
type querySchemaType = z.infer<typeof querySchema>;
|
||||||
|
|
||||||
|
type ModalState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
credentialId: null | number;
|
||||||
|
};
|
||||||
|
|
||||||
export default function InstalledApps() {
|
export default function InstalledApps() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -226,14 +292,45 @@ export default function InstalledApps() {
|
|||||||
"web3",
|
"web3",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const [data, updateData] = useReducer(
|
||||||
|
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
|
||||||
|
{
|
||||||
|
isOpen: false,
|
||||||
|
credentialId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModelClose = () => {
|
||||||
|
updateData({ isOpen: false, credentialId: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = (credentialId: number) => {
|
||||||
|
updateData({ isOpen: true, credentialId });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
|
<>
|
||||||
{categoryList.includes(category) && <IntegrationsContainer variant={category} />}
|
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
|
||||||
{category === "calendar" && <CalendarListContainer />}
|
<div className="rounded-md border border-gray-200 p-7">
|
||||||
{category === "other" && (
|
{categoryList.includes(category) && (
|
||||||
<IntegrationsContainer variant={category} exclude={[...categoryList, "calendar"]} />
|
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
|
||||||
)}
|
)}
|
||||||
</InstalledAppsLayout>
|
{category === "calendar" && <CalendarListContainer />}
|
||||||
|
{category === "other" && (
|
||||||
|
<IntegrationsContainer
|
||||||
|
handleDisconnect={handleDisconnect}
|
||||||
|
variant={category}
|
||||||
|
exclude={[...categoryList, "calendar"]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</InstalledAppsLayout>
|
||||||
|
<DisconnectIntegrationModal
|
||||||
|
handleModelClose={handleModelClose}
|
||||||
|
isOpen={data.isOpen}
|
||||||
|
credentialId={data.credentialId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import Link from "next/link";
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Button, TextField } from "@calcom/ui";
|
import { Button, TextField } from "@calcom/ui";
|
||||||
|
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import AuthContainer from "@components/ui/AuthContainer";
|
import AuthContainer from "@components/ui/AuthContainer";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { SyntheticEvent } from "react";
|
import React, { SyntheticEvent } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button, EmailField } from "@calcom/ui";
|
import { Button, EmailField } from "@calcom/ui";
|
||||||
|
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import AuthContainer from "@components/ui/AuthContainer";
|
import AuthContainer from "@components/ui/AuthContainer";
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { FaGoogle } from "react-icons/fa";
|
|||||||
|
|
||||||
import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
|
import { SAMLLogin } from "@calcom/features/auth/SAMLLogin";
|
||||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||||
|
import { ErrorCode, getSession } from "@calcom/lib/auth";
|
||||||
|
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
@@ -16,9 +18,8 @@ import prisma from "@calcom/prisma";
|
|||||||
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||||
import { FiArrowLeft } from "@calcom/ui/components/icon";
|
import { FiArrowLeft } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { ErrorCode, getSession } from "@lib/auth";
|
|
||||||
import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants";
|
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
import withNonce, { WithNonceProps } from "@lib/withNonce";
|
||||||
|
|
||||||
import AddToHomescreen from "@components/AddToHomescreen";
|
import AddToHomescreen from "@components/AddToHomescreen";
|
||||||
import TwoFactor from "@components/auth/TwoFactor";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
@@ -40,7 +41,7 @@ export default function Login({
|
|||||||
isSAMLLoginEnabled,
|
isSAMLLoginEnabled,
|
||||||
samlTenantID,
|
samlTenantID,
|
||||||
samlProductID,
|
samlProductID,
|
||||||
}: inferSSRProps<typeof getServerSideProps>) {
|
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const methods = useForm<LoginValues>();
|
const methods = useForm<LoginValues>();
|
||||||
@@ -52,8 +53,8 @@ export default function Login({
|
|||||||
|
|
||||||
const errorMessages: { [key: string]: string } = {
|
const errorMessages: { [key: string]: string } = {
|
||||||
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
|
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
|
||||||
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
|
// Don't leak information about whether an email is registered or not
|
||||||
[ErrorCode.UserNotFound]: t("no_account_exists"),
|
[ErrorCode.IncorrectUsernamePassword]: t("incorrect_username_password"),
|
||||||
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
|
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
|
||||||
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
|
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
|
||||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||||
@@ -149,7 +150,7 @@ export default function Login({
|
|||||||
</div>
|
</div>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
id="password"
|
id="password"
|
||||||
autoComplete="current-password"
|
autoComplete="off"
|
||||||
required
|
required
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
@@ -181,8 +182,6 @@ export default function Login({
|
|||||||
StartIcon={FaGoogle}
|
StartIcon={FaGoogle}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// track Google logins. Without personal data/payload
|
|
||||||
telemetry.event(telemetryEventTypes.googleLogin, collectPageParameters());
|
|
||||||
await signIn("google");
|
await signIn("google");
|
||||||
}}>
|
}}>
|
||||||
{t("signin_with_google")}
|
{t("signin_with_google")}
|
||||||
@@ -205,7 +204,8 @@ export default function Login({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
|
||||||
|
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const { req } = context;
|
const { req } = context;
|
||||||
const session = await getSession({ req });
|
const session = await getSession({ req });
|
||||||
const ssr = await ssrInit(context);
|
const ssr = await ssrInit(context);
|
||||||
@@ -229,7 +229,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
csrfToken: await getCsrfToken(context),
|
csrfToken: await getCsrfToken(context),
|
||||||
@@ -240,4 +239,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
samlProductID,
|
samlProductID,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export const getServerSideProps = withNonce(_getServerSideProps);
|
||||||
@@ -1,40 +1,127 @@
|
|||||||
import { UserPermissionRole } from "@prisma/client";
|
import { UserPermissionRole } from "@prisma/client";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
||||||
|
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
|
||||||
import { getSession } from "@calcom/lib/auth";
|
import { getSession } from "@calcom/lib/auth";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||||
import { WizardForm } from "@calcom/ui";
|
import { Meta, WizardForm } from "@calcom/ui";
|
||||||
|
|
||||||
import SetupFormStep1 from "./steps/SetupFormStep1";
|
import { AdminUserContainer as AdminUser } from "@components/setup/AdminUser";
|
||||||
import StepDone from "./steps/StepDone";
|
import ChooseLicense from "@components/setup/ChooseLicense";
|
||||||
|
import EnterpriseLicense from "@components/setup/EnterpriseLicense";
|
||||||
|
|
||||||
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
|
||||||
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [isLoadingStep1, setIsLoadingStep1] = useState(false);
|
const router = useRouter();
|
||||||
const shouldDisable = props.userCount !== 0;
|
const [value, setValue] = useState(props.isFreeLicense ? "FREE" : "EE");
|
||||||
|
const isFreeLicense = value === "FREE";
|
||||||
|
const [isEnabledEE, setIsEnabledEE] = useState(!props.isFreeLicense);
|
||||||
|
const setStep = (newStep: number) => {
|
||||||
|
router.replace(`/auth/setup?step=${newStep || 1}`, undefined, { shallow: true });
|
||||||
|
};
|
||||||
|
|
||||||
const steps = [
|
const steps: React.ComponentProps<typeof WizardForm>["steps"] = [
|
||||||
{
|
{
|
||||||
title: t("administrator_user"),
|
title: t("administrator_user"),
|
||||||
description: t("lets_create_first_administrator_user"),
|
description: t("lets_create_first_administrator_user"),
|
||||||
content: shouldDisable ? <StepDone /> : <SetupFormStep1 setIsLoading={setIsLoadingStep1} />,
|
content: (setIsLoading) => (
|
||||||
isLoading: isLoadingStep1,
|
<AdminUser
|
||||||
|
onSubmit={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setStep(2);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
}}
|
||||||
|
userCount={props.userCount}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("enable_apps"),
|
title: t("choose_a_license"),
|
||||||
description: t("enable_apps_description"),
|
description: t("choose_license_description"),
|
||||||
content: <AdminAppsList baseURL="/auth/setup?step=2" useQueryParam={true} />,
|
content: (setIsLoading) => {
|
||||||
isLoading: false,
|
return (
|
||||||
|
<ChooseLicense
|
||||||
|
id="wizard-step-2"
|
||||||
|
name="wizard-step-2"
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
onSubmit={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setStep(3);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (!isFreeLicense) {
|
||||||
|
steps.push({
|
||||||
|
title: t("step_enterprise_license"),
|
||||||
|
description: t("step_enterprise_license_description"),
|
||||||
|
content: (setIsLoading) => {
|
||||||
|
const currentStep = 3;
|
||||||
|
return (
|
||||||
|
<EnterpriseLicense
|
||||||
|
id={`wizard-step-${currentStep}`}
|
||||||
|
name={`wizard-step-${currentStep}`}
|
||||||
|
onSubmit={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setStep(currentStep + 1);
|
||||||
|
}}
|
||||||
|
onSuccessValidate={() => {
|
||||||
|
setIsEnabledEE(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isEnabled: isEnabledEE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
title: t("enable_apps"),
|
||||||
|
description: t("enable_apps_description"),
|
||||||
|
contentClassname: "!pb-0 mb-[-1px]",
|
||||||
|
content: (setIsLoading) => {
|
||||||
|
const currentStep = isFreeLicense ? 3 : 4;
|
||||||
|
return (
|
||||||
|
<AdminAppsList
|
||||||
|
id={`wizard-step-${currentStep}`}
|
||||||
|
name={`wizard-step-${currentStep}`}
|
||||||
|
classNames={{
|
||||||
|
form: "mb-4 rounded-md bg-white px-0 pt-0 md:max-w-full",
|
||||||
|
appCategoryNavigationContainer: "max-h-[400px] overflow-y-auto md:p-4",
|
||||||
|
verticalTabsItem: "!w-48 md:p-4",
|
||||||
|
}}
|
||||||
|
baseURL={`/auth/setup?step=${currentStep}`}
|
||||||
|
useQueryParam={true}
|
||||||
|
onSubmit={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
router.replace("/");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="flex items-center bg-gray-100 print:h-full">
|
<Meta title={t("setup")} description={t("setup_description")} />
|
||||||
|
<main className="flex items-center bg-gray-100 print:h-full md:h-screen">
|
||||||
<WizardForm
|
<WizardForm
|
||||||
href="/auth/setup"
|
href="/auth/setup"
|
||||||
steps={steps}
|
steps={steps}
|
||||||
@@ -49,6 +136,7 @@ export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const ssr = await ssrInit(context);
|
||||||
const userCount = await prisma.user.count();
|
const userCount = await prisma.user.count();
|
||||||
const { req } = context;
|
const { req } = context;
|
||||||
const session = await getSession({ req });
|
const session = await getSession({ req });
|
||||||
@@ -62,8 +150,30 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deploymentKey = await getDeploymentKey(prisma);
|
||||||
|
|
||||||
|
// Check existant CALCOM_LICENSE_KEY env var and acccount for it
|
||||||
|
if (!!process.env.CALCOM_LICENSE_KEY && !deploymentKey) {
|
||||||
|
await prisma.deployment.upsert({
|
||||||
|
where: { id: 1 },
|
||||||
|
update: {
|
||||||
|
licenseKey: process.env.CALCOM_LICENSE_KEY,
|
||||||
|
agreedLicenseAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
licenseKey: process.env.CALCOM_LICENSE_KEY,
|
||||||
|
agreedLicenseAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
deploymentKey = await getDeploymentKey(prisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFreeLicense = deploymentKey === "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
trpcState: ssr.dehydrate(),
|
||||||
|
isFreeLicense,
|
||||||
userCount,
|
userCount,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
|||||||
});
|
});
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="p-6">
|
||||||
<h3 className="font-medium leading-6 text-gray-900">
|
<h3 className="font-medium leading-6 text-gray-900">
|
||||||
{t("date_overrides")}{" "}
|
{t("date_overrides")}{" "}
|
||||||
<Tooltip content={t("date_overrides_info")}>
|
<Tooltip content={t("date_overrides_info")}>
|
||||||
@@ -60,8 +60,8 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-4 text-sm text-gray-500 ltr:mr-4 rtl:ml-4">{t("date_overrides_subtitle")}</p>
|
<p className="mb-4 text-sm text-gray-500">{t("date_overrides_subtitle")}</p>
|
||||||
<div className="mt-1 space-y-2">
|
<div className="space-y-2">
|
||||||
<DateOverrideList
|
<DateOverrideList
|
||||||
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
|
excludedDates={fields.map((field) => yyyymmdd(field.ranges[0].start))}
|
||||||
remove={remove}
|
remove={remove}
|
||||||
@@ -185,25 +185,29 @@ export default function Availability({ schedule }: { schedule: number }) {
|
|||||||
...values,
|
...values,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
|
className="flex flex-col sm:mx-0 xl:flex-row xl:space-x-6">
|
||||||
<div className="flex-1 divide-y divide-gray-200 rounded-md border">
|
<div className="flex-1 flex-row xl:mr-0">
|
||||||
<div className=" py-5 sm:p-6">
|
<div className="mb-6 rounded-md border">
|
||||||
{typeof me.data?.weekStart === "string" && (
|
<div>
|
||||||
<Schedule
|
{typeof me.data?.weekStart === "string" && (
|
||||||
control={control}
|
<Schedule
|
||||||
name="schedule"
|
control={control}
|
||||||
weekStart={
|
name="schedule"
|
||||||
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
weekStart={
|
||||||
me.data?.weekStart
|
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
||||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
|
me.data?.weekStart
|
||||||
}
|
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||||
/>
|
}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="my-6 rounded-md border">
|
||||||
|
{data?.workingHours && <DateOverride workingHours={data.workingHours} />}
|
||||||
</div>
|
</div>
|
||||||
{data?.workingHours && <DateOverride workingHours={data.workingHours} />}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||||
<div className="xl:max-w-80 mt-4 w-full pr-4 sm:p-0">
|
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||||
{t("timezone")}
|
{t("timezone")}
|
||||||
@@ -223,8 +227,8 @@ export default function Availability({ schedule }: { schedule: number }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr className="my-8" />
|
<hr className="my-6 mr-8" />
|
||||||
<div className="rounded-md">
|
<div className="hidden rounded-md md:block">
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
|
||||||
<div className="mt-3 flex">
|
<div className="mt-3 flex">
|
||||||
<Button href="/availability/troubleshoot" color="secondary">
|
<Button href="/availability/troubleshoot" color="secondary">
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { RRule } from "rrule";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||||
import { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations";
|
import {
|
||||||
|
getEventLocationValue,
|
||||||
|
getSuccessPageLocationMessage,
|
||||||
|
guessEventLocationType,
|
||||||
|
} from "@calcom/app-store/locations";
|
||||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||||
import { getEventName } from "@calcom/core/event";
|
import { getEventName } from "@calcom/core/event";
|
||||||
import dayjs, { ConfigType } from "@calcom/dayjs";
|
import dayjs, { ConfigType } from "@calcom/dayjs";
|
||||||
@@ -34,9 +38,10 @@ import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/t
|
|||||||
import { localStorage } from "@calcom/lib/webstorage";
|
import { localStorage } from "@calcom/lib/webstorage";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import { Prisma } from "@calcom/prisma/client";
|
||||||
|
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { Button, EmailInput, HeadSeo } from "@calcom/ui";
|
import { Button, EmailInput, HeadSeo } from "@calcom/ui";
|
||||||
import { FiX, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
|
import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
@@ -169,6 +174,7 @@ const querySchema = z.object({
|
|||||||
reschedule: stringToBoolean,
|
reschedule: stringToBoolean,
|
||||||
isSuccessBookingPage: z.string().optional(),
|
isSuccessBookingPage: z.string().optional(),
|
||||||
formerTime: z.string().optional(),
|
formerTime: z.string().optional(),
|
||||||
|
email: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Success(props: SuccessProps) {
|
export default function Success(props: SuccessProps) {
|
||||||
@@ -180,26 +186,29 @@ export default function Success(props: SuccessProps) {
|
|||||||
cancel: isCancellationMode,
|
cancel: isCancellationMode,
|
||||||
changes,
|
changes,
|
||||||
formerTime,
|
formerTime,
|
||||||
|
email,
|
||||||
} = querySchema.parse(router.query);
|
} = querySchema.parse(router.query);
|
||||||
|
|
||||||
if ((isCancellationMode || changes) && typeof window !== "undefined") {
|
if ((isCancellationMode || changes) && typeof window !== "undefined") {
|
||||||
window.scrollTo(0, document.body.scrollHeight);
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(props.bookingInfo.location)
|
const location: ReturnType<typeof getEventLocationValue> = props.bookingInfo.location
|
||||||
? props.bookingInfo.location[0]
|
? props.bookingInfo.location
|
||||||
: // If there is no location set then we default to Cal Video
|
: // If there is no location set then we default to Cal Video
|
||||||
"integrations:daily";
|
"integrations:daily";
|
||||||
|
|
||||||
|
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||||
|
props?.bookingInfo?.metadata || {}
|
||||||
|
)?.videoCallUrl;
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
// Can't use logger.error because it throws error on client. stdout isn't available to it.
|
// Can't use logger.error because it throws error on client. stdout isn't available to it.
|
||||||
console.error(`No location found `);
|
console.error(`No location found `);
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = props.bookingInfo?.user?.email;
|
|
||||||
const status = props.bookingInfo?.status;
|
const status = props.bookingInfo?.status;
|
||||||
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
|
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
|
||||||
const cancellationReason = props.bookingInfo.cancellationReason;
|
const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason;
|
||||||
|
|
||||||
const attendeeName =
|
const attendeeName =
|
||||||
typeof props?.bookingInfo?.attendees?.[0]?.name === "string"
|
typeof props?.bookingInfo?.attendees?.[0]?.name === "string"
|
||||||
@@ -255,6 +264,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
if (!sdkActionManager) return;
|
if (!sdkActionManager) return;
|
||||||
// TODO: We should probably make it consistent with Webhook payload. Some data is not available here, as and when requirement comes we can add
|
// TODO: We should probably make it consistent with Webhook payload. Some data is not available here, as and when requirement comes we can add
|
||||||
sdkActionManager.fire("bookingSuccessful", {
|
sdkActionManager.fire("bookingSuccessful", {
|
||||||
|
booking: bookingInfo,
|
||||||
eventType,
|
eventType,
|
||||||
date: date.toString(),
|
date: date.toString(),
|
||||||
duration: calculatedDuration,
|
duration: calculatedDuration,
|
||||||
@@ -279,8 +289,8 @@ export default function Success(props: SuccessProps) {
|
|||||||
|
|
||||||
function eventLink(): string {
|
function eventLink(): string {
|
||||||
const optional: { location?: string } = {};
|
const optional: { location?: string } = {};
|
||||||
if (location) {
|
if (locationVideoCallUrl) {
|
||||||
optional["location"] = location;
|
optional["location"] = locationVideoCallUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = createEvent({
|
const event = createEvent({
|
||||||
@@ -330,12 +340,17 @@ export default function Success(props: SuccessProps) {
|
|||||||
);
|
);
|
||||||
const customInputs = bookingInfo?.customInputs;
|
const customInputs = bookingInfo?.customInputs;
|
||||||
|
|
||||||
const locationToDisplay = getSuccessPageLocationMessage(location, t);
|
const locationToDisplay = getSuccessPageLocationMessage(
|
||||||
|
locationVideoCallUrl ? locationVideoCallUrl : location,
|
||||||
|
t,
|
||||||
|
bookingInfo.status
|
||||||
|
);
|
||||||
|
|
||||||
const hasSMSAttendeeAction =
|
const hasSMSAttendeeAction =
|
||||||
eventType.workflows.find((workflowEventType) =>
|
eventType.workflows.find((workflowEventType) =>
|
||||||
workflowEventType.workflow.steps.find((step) => step.action === WorkflowActions.SMS_ATTENDEE)
|
workflowEventType.workflow.steps.find((step) => step.action === WorkflowActions.SMS_ATTENDEE)
|
||||||
) !== undefined;
|
) !== undefined;
|
||||||
|
const providerName = guessEventLocationType(location)?.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
||||||
@@ -425,6 +440,17 @@ export default function Success(props: SuccessProps) {
|
|||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p className="text-gray-600 dark:text-gray-300">{getTitle()}</p>
|
<p className="text-gray-600 dark:text-gray-300">{getTitle()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{props.paymentStatus && (
|
||||||
|
<h4>
|
||||||
|
{!props.paymentStatus.success &&
|
||||||
|
!props.paymentStatus.refunded &&
|
||||||
|
t("booking_with_payment_cancelled")}
|
||||||
|
{props.paymentStatus.success &&
|
||||||
|
!props.paymentStatus.refunded &&
|
||||||
|
t("booking_with_payment_cancelled_already_paid")}
|
||||||
|
{props.paymentStatus.refunded && t("booking_with_payment_cancelled_refunded")}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
|
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
|
||||||
{(isCancelled || reschedule) && cancellationReason && (
|
{(isCancelled || reschedule) && cancellationReason && (
|
||||||
@@ -488,8 +514,14 @@ export default function Success(props: SuccessProps) {
|
|||||||
<div className="mt-3 font-medium">{t("where")}</div>
|
<div className="mt-3 font-medium">{t("where")}</div>
|
||||||
<div className="col-span-2 mt-3">
|
<div className="col-span-2 mt-3">
|
||||||
{locationToDisplay.startsWith("http") ? (
|
{locationToDisplay.startsWith("http") ? (
|
||||||
<a title="Meeting Link" href={locationToDisplay}>
|
<a
|
||||||
{locationToDisplay}
|
href={locationToDisplay}
|
||||||
|
target="_blank"
|
||||||
|
title={locationToDisplay}
|
||||||
|
className="flex items-center gap-2 text-gray-700 underline dark:text-gray-50"
|
||||||
|
rel="noreferrer">
|
||||||
|
{providerName || "Link"}
|
||||||
|
<FiExternalLink className="inline h-4 w-4 text-gray-700 dark:text-white" />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
locationToDisplay
|
locationToDisplay
|
||||||
@@ -515,7 +547,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
<>
|
<>
|
||||||
{eventTypeCustomFound?.type === "RADIO" && (
|
{eventTypeCustomFound?.type === "RADIO" && (
|
||||||
<>
|
<>
|
||||||
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||||
{eventTypeCustomFound.label}
|
{eventTypeCustomFound.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 mt-1 mb-2">
|
<div className="col-span-3 mt-1 mb-2">
|
||||||
@@ -539,7 +571,9 @@ export default function Success(props: SuccessProps) {
|
|||||||
)}
|
)}
|
||||||
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
|
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
|
||||||
<>
|
<>
|
||||||
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">{key}</div>
|
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
<div className="col-span-3 mt-2 mb-2">
|
<div className="col-span-3 mt-2 mb-2">
|
||||||
{typeof customInput === "boolean" ? (
|
{typeof customInput === "boolean" ? (
|
||||||
<p>{customInput ? "true" : "false"}</p>
|
<p>{customInput ? "true" : "false"}</p>
|
||||||
@@ -630,8 +664,8 @@ export default function Success(props: SuccessProps) {
|
|||||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||||
props.eventType.description
|
props.eventType.description
|
||||||
}` +
|
}` +
|
||||||
(typeof location === "string"
|
(typeof locationVideoCallUrl === "string"
|
||||||
? "&location=" + encodeURIComponent(location)
|
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
||||||
: "") +
|
: "") +
|
||||||
(props.eventType.recurringEvent
|
(props.eventType.recurringEvent
|
||||||
? "&recur=" +
|
? "&recur=" +
|
||||||
@@ -659,7 +693,10 @@ export default function Success(props: SuccessProps) {
|
|||||||
date.utc().format() +
|
date.utc().format() +
|
||||||
"&subject=" +
|
"&subject=" +
|
||||||
eventName
|
eventName
|
||||||
) + (location ? "&location=" + location : "")
|
) +
|
||||||
|
(locationVideoCallUrl
|
||||||
|
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
||||||
|
: "")
|
||||||
}
|
}
|
||||||
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
||||||
target="_blank">
|
target="_blank">
|
||||||
@@ -683,7 +720,10 @@ export default function Success(props: SuccessProps) {
|
|||||||
date.utc().format() +
|
date.utc().format() +
|
||||||
"&subject=" +
|
"&subject=" +
|
||||||
eventName
|
eventName
|
||||||
) + (location ? "&location=" + location : "")
|
) +
|
||||||
|
(locationVideoCallUrl
|
||||||
|
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
||||||
|
: "")
|
||||||
}
|
}
|
||||||
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
||||||
target="_blank">
|
target="_blank">
|
||||||
@@ -734,7 +774,7 @@ export default function Success(props: SuccessProps) {
|
|||||||
<EmailInput
|
<EmailInput
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
defaultValue={email || ""}
|
defaultValue={email}
|
||||||
className="mr- focus:border-brand border-bookinglightest dark:border-darkgray-300 mt-0 block w-full rounded-none rounded-l-md border-gray-300 shadow-sm focus:ring-black dark:bg-black dark:text-white sm:text-sm"
|
className="mr- focus:border-brand border-bookinglightest dark:border-darkgray-300 mt-0 block w-full rounded-none rounded-l-md border-gray-300 shadow-sm focus:ring-black dark:bg-black dark:text-white sm:text-sm"
|
||||||
placeholder="rick.astley@cal.com"
|
placeholder="rick.astley@cal.com"
|
||||||
/>
|
/>
|
||||||
@@ -982,7 +1022,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
endTime: true,
|
endTime: true,
|
||||||
location: true,
|
location: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
metadata: true,
|
||||||
cancellationReason: true,
|
cancellationReason: true,
|
||||||
|
rejectionReason: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -1085,6 +1127,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payment = await prisma.payment.findFirst({
|
||||||
|
where: {
|
||||||
|
bookingId: bookingInfo.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
success: true,
|
||||||
|
refunded: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const paymentStatus = {
|
||||||
|
success: payment?.success,
|
||||||
|
refunded: payment?.refunded,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
|
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
|
||||||
@@ -1094,6 +1150,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
dynamicEventName: bookingInfo?.eventType?.eventName || "",
|
dynamicEventName: bookingInfo?.eventType?.eventName || "",
|
||||||
bookingInfo,
|
bookingInfo,
|
||||||
|
paymentStatus: payment ? paymentStatus : null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,8 @@ export default function Bookings() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
shownBookings[booking.recurringEventId] = [booking];
|
shownBookings[booking.recurringEventId] = [booking];
|
||||||
|
} else if (status === "upcoming") {
|
||||||
|
return new Date(booking.startTime).toDateString() !== new Date().toDateString();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -121,7 +123,7 @@ export default function Bookings() {
|
|||||||
<p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("today")}</p>
|
<p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("today")}</p>
|
||||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||||
<table className="w-full max-w-full table-fixed">
|
<table className="w-full max-w-full table-fixed">
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white" data-testid="today-bookings">
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{bookingsToday.map((booking: BookingOutput) => (
|
{bookingsToday.map((booking: BookingOutput) => (
|
||||||
<BookingListItem
|
<BookingListItem
|
||||||
@@ -138,7 +140,6 @@ export default function Bookings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="pt-2 xl:pt-0">
|
<div className="pt-2 xl:pt-0">
|
||||||
{/* <p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("all")}</p> */}
|
|
||||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||||
<table className="w-full max-w-full table-fixed">
|
<table className="w-full max-w-full table-fixed">
|
||||||
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { privacyFilteredLocations, LocationObject } from "@calcom/core/location";
|
import { privacyFilteredLocations, LocationObject } from "@calcom/core/location";
|
||||||
import { parseRecurringEvent } from "@calcom/lib";
|
import { parseRecurringEvent } from "@calcom/lib";
|
||||||
|
import { getWorkingHours } from "@calcom/lib/availability";
|
||||||
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
|
import { availiblityPageEventTypeSelect } from "@calcom/prisma";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { getWorkingHours } from "@lib/availability";
|
|
||||||
import { GetBookingType } from "@lib/getBooking";
|
import { GetBookingType } from "@lib/getBooking";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import { EmbedProps } from "@lib/withEmbedSsr";
|
import { EmbedProps } from "@lib/withEmbedSsr";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
|
||||||
|
|
||||||
import { parseRecurringEvent } from "@calcom/lib";
|
import { parseRecurringEvent } from "@calcom/lib";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { z } from "zod";
|
|||||||
import { EventLocationType } from "@calcom/core/location";
|
import { EventLocationType } from "@calcom/core/location";
|
||||||
import { validateBookingLimitOrder } from "@calcom/lib";
|
import { validateBookingLimitOrder } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
|
||||||
import findDurationType from "@calcom/lib/findDurationType";
|
|
||||||
import getEventTypeById from "@calcom/lib/getEventTypeById";
|
import getEventTypeById from "@calcom/lib/getEventTypeById";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
@@ -64,7 +62,7 @@ export type FormValues = {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
}[];
|
}[];
|
||||||
customInputs: CustomInputParsed[];
|
customInputs: CustomInputParsed[];
|
||||||
schedule: number;
|
schedule: number | null;
|
||||||
periodType: PeriodType;
|
periodType: PeriodType;
|
||||||
periodDays: number;
|
periodDays: number;
|
||||||
periodCountCalendarDays: "1" | "0";
|
periodCountCalendarDays: "1" | "0";
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { Fragment, useEffect, useState } from "react";
|
import { FC, useEffect, useState, memo } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateEventTypeButton,
|
CreateEventTypeButton,
|
||||||
@@ -11,8 +12,12 @@ import {
|
|||||||
import Shell from "@calcom/features/shell/Shell";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||||
|
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
import { RouterOutputs, trpc, TRPCClientError } from "@calcom/trpc/react";
|
import { RouterOutputs, trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarGroup,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
@@ -28,9 +33,8 @@ import {
|
|||||||
EmptyScreen,
|
EmptyScreen,
|
||||||
showToast,
|
showToast,
|
||||||
Switch,
|
Switch,
|
||||||
Avatar,
|
|
||||||
AvatarGroup,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
HorizontalTabs,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import {
|
import {
|
||||||
FiArrowDown,
|
FiArrowDown,
|
||||||
@@ -74,6 +78,40 @@ interface EventTypeListProps {
|
|||||||
types: EventType[];
|
types: EventType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MobileTeamsTabProps {
|
||||||
|
eventTypeGroups: EventTypeGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
teamId: z.nullable(z.coerce.number()).optional().default(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
|
||||||
|
const { eventTypeGroups } = props;
|
||||||
|
|
||||||
|
const tabs = eventTypeGroups.map((item) => ({
|
||||||
|
name: item.profile.name ?? "",
|
||||||
|
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types",
|
||||||
|
avatar: item.profile.image ?? `${WEBAPP_URL}/${item.profile.slug}/avatar.png`,
|
||||||
|
}));
|
||||||
|
const { data } = useTypedQuery(querySchema);
|
||||||
|
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HorizontalTabs tabs={tabs} />
|
||||||
|
{events.length && (
|
||||||
|
<EventTypeList
|
||||||
|
types={events[0].eventTypes}
|
||||||
|
group={events[0]}
|
||||||
|
groupIndex={0}
|
||||||
|
readOnly={events[0].metadata.readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => {
|
const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
@@ -97,12 +135,15 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<EventTypeDescription eventType={type} />
|
<EventTypeDescription
|
||||||
|
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
|
||||||
|
eventType={type}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MemoizedItem = React.memo(Item);
|
const MemoizedItem = memo(Item);
|
||||||
|
|
||||||
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
|
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -192,7 +233,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// inject selection data into url for correct router history
|
// inject selection data into url for correct router history
|
||||||
const openDuplicateModal = (eventType: EventType) => {
|
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
|
||||||
const query = {
|
const query = {
|
||||||
...router.query,
|
...router.query,
|
||||||
dialog: "duplicate-event-type",
|
dialog: "duplicate-event-type",
|
||||||
@@ -201,6 +242,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
slug: eventType.slug,
|
slug: eventType.slug,
|
||||||
id: eventType.id,
|
id: eventType.id,
|
||||||
length: eventType.length,
|
length: eventType.length,
|
||||||
|
pageSlug: group.profile.slug,
|
||||||
};
|
};
|
||||||
|
|
||||||
router.push(
|
router.push(
|
||||||
@@ -344,15 +386,13 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Dropdown modal={false}>
|
<Dropdown modal={false}>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||||
asChild
|
|
||||||
data-testid={"event-type-options-" + type.id}
|
|
||||||
className="radix-state-open:rounded-r-md">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
StartIcon={FiMoreHorizontal}
|
StartIcon={FiMoreHorizontal}
|
||||||
|
className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
@@ -370,7 +410,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid={"event-type-duplicate-" + type.id}
|
data-testid={"event-type-duplicate-" + type.id}
|
||||||
StartIcon={FiCopy}
|
StartIcon={FiCopy}
|
||||||
onClick={() => openDuplicateModal(type)}>
|
onClick={() => openDuplicateModal(type, group)}>
|
||||||
{t("duplicate")}
|
{t("duplicate")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -415,37 +455,30 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<Link href={calLink} target="_blank">
|
<DropdownItem
|
||||||
<Button
|
href={calLink}
|
||||||
color="minimal"
|
target="_blank"
|
||||||
StartIcon={FiExternalLink}
|
StartIcon={FiExternalLink}
|
||||||
className="w-full rounded-none">
|
className="w-full rounded-none">
|
||||||
{t("preview")}
|
{t("preview")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<Button
|
<DropdownItem
|
||||||
type="button"
|
|
||||||
color="minimal"
|
|
||||||
className="w-full rounded-none text-left"
|
|
||||||
data-testid={"event-type-duplicate-" + type.id}
|
data-testid={"event-type-duplicate-" + type.id}
|
||||||
StartIcon={FiClipboard}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(calLink);
|
navigator.clipboard.writeText(calLink);
|
||||||
showToast(t("link_copied"), "success");
|
showToast(t("link_copied"), "success");
|
||||||
}}>
|
}}
|
||||||
|
StartIcon={FiClipboard}
|
||||||
|
className="w-full rounded-none text-left">
|
||||||
{t("copy_link")}
|
{t("copy_link")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isNativeShare ? (
|
{isNativeShare ? (
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<Button
|
<DropdownItem
|
||||||
type="button"
|
|
||||||
color="minimal"
|
|
||||||
className="w-full rounded-none"
|
|
||||||
data-testid={"event-type-duplicate-" + type.id}
|
data-testid={"event-type-duplicate-" + type.id}
|
||||||
StartIcon={FiUpload}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator
|
navigator
|
||||||
.share({
|
.share({
|
||||||
@@ -455,44 +488,41 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||||||
})
|
})
|
||||||
.then(() => showToast(t("link_shared"), "success"))
|
.then(() => showToast(t("link_shared"), "success"))
|
||||||
.catch(() => showToast(t("failed"), "error"));
|
.catch(() => showToast(t("failed"), "error"));
|
||||||
}}>
|
}}
|
||||||
|
StartIcon={FiUpload}
|
||||||
|
className="w-full rounded-none">
|
||||||
{t("share")}
|
{t("share")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<Button
|
<DropdownItem
|
||||||
type="button"
|
|
||||||
onClick={() => router.push("/event-types/" + type.id)}
|
onClick={() => router.push("/event-types/" + type.id)}
|
||||||
color="minimal"
|
StartIcon={FiEdit}
|
||||||
className="w-full rounded-none"
|
className="w-full rounded-none">
|
||||||
StartIcon={FiEdit}>
|
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<Button
|
<DropdownItem
|
||||||
type="button"
|
onClick={() => openDuplicateModal(type, group)}
|
||||||
color="minimal"
|
|
||||||
className="w-full rounded-none"
|
|
||||||
data-testid={"event-type-duplicate-" + type.id}
|
|
||||||
StartIcon={FiCopy}
|
StartIcon={FiCopy}
|
||||||
onClick={() => openDuplicateModal(type)}>
|
data-testid={"event-type-duplicate-" + type.id}>
|
||||||
{t("duplicate")}
|
{t("duplicate")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="outline-none">
|
<DropdownMenuItem className="outline-none">
|
||||||
<Button
|
<DropdownItem
|
||||||
|
color="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
setDeleteDialogTypeId(type.id);
|
setDeleteDialogTypeId(type.id);
|
||||||
}}
|
}}
|
||||||
color="destructive"
|
|
||||||
StartIcon={FiTrash}
|
StartIcon={FiTrash}
|
||||||
className="w-full rounded-none">
|
className="w-full rounded-none">
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</Button>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
@@ -585,6 +615,8 @@ const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer);
|
|||||||
const EventTypesPage = () => {
|
const EventTypesPage = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Shell heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")} CTA={<CTA />}>
|
<Shell heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")} CTA={<CTA />}>
|
||||||
@@ -592,26 +624,40 @@ const EventTypesPage = () => {
|
|||||||
customLoader={<SkeletonLoader />}
|
customLoader={<SkeletonLoader />}
|
||||||
success={({ data }) => (
|
success={({ data }) => (
|
||||||
<>
|
<>
|
||||||
{data.eventTypeGroups.map((group, index) => (
|
{data.eventTypeGroups.length > 1 ? (
|
||||||
<Fragment key={group.profile.slug}>
|
<>
|
||||||
{/* hide list heading when there is only one (current user) */}
|
{isMobile ? (
|
||||||
{(data.eventTypeGroups.length !== 1 || group.teamId) && (
|
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
|
||||||
<EventTypeListHeading
|
) : (
|
||||||
profile={group.profile}
|
data.eventTypeGroups.map((group, index) => (
|
||||||
membershipCount={group.metadata.membershipCount}
|
<div className="flex flex-col" key={group.profile.slug}>
|
||||||
teamId={group.teamId}
|
<EventTypeListHeading
|
||||||
/>
|
profile={group.profile}
|
||||||
|
membershipCount={group.metadata.membershipCount}
|
||||||
|
teamId={group.teamId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventTypeList
|
||||||
|
types={group.eventTypes}
|
||||||
|
group={group}
|
||||||
|
groupIndex={index}
|
||||||
|
readOnly={group.metadata.readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
<EventTypeList
|
</>
|
||||||
types={group.eventTypes}
|
) : data.eventTypeGroups.length === 1 ? (
|
||||||
group={group}
|
<EventTypeList
|
||||||
groupIndex={index}
|
types={data.eventTypeGroups[0].eventTypes}
|
||||||
readOnly={group.metadata.readOnly}
|
group={data.eventTypeGroups[0]}
|
||||||
/>
|
groupIndex={0}
|
||||||
</Fragment>
|
readOnly={data.eventTypeGroups[0].metadata.readOnly}
|
||||||
))}
|
/>
|
||||||
|
) : (
|
||||||
|
<CreateFirstEventTypeView />
|
||||||
|
)}
|
||||||
|
|
||||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
|
|
||||||
<EmbedDialog />
|
<EmbedDialog />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { z } from "zod";
|
|||||||
import { getSession } from "@calcom/lib/auth";
|
import { getSession } from "@calcom/lib/auth";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { User } from "@calcom/prisma/client";
|
import prisma from "@calcom/prisma";
|
||||||
import { Button, StepCard, Steps } from "@calcom/ui";
|
import { Button, StepCard, Steps } from "@calcom/ui";
|
||||||
|
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
|
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
|
||||||
@@ -60,7 +59,6 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
|||||||
`${t("set_availability_getting_started_subtitle_1")}`,
|
`${t("set_availability_getting_started_subtitle_1")}`,
|
||||||
`${t("set_availability_getting_started_subtitle_2")}`,
|
`${t("set_availability_getting_started_subtitle_2")}`,
|
||||||
],
|
],
|
||||||
skipText: `${t("set_my_availability_later")}`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: `${t("nearly_there")}`,
|
title: `${t("nearly_there")}`,
|
||||||
@@ -107,7 +105,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</header>
|
</header>
|
||||||
<Steps maxSteps={steps.length} currentStep={currentStepIndex} navigateToStep={goToIndex} />
|
<Steps maxSteps={steps.length} currentStep={currentStepIndex + 1} navigateToStep={goToIndex} />
|
||||||
</div>
|
</div>
|
||||||
<StepCard>
|
<StepCard>
|
||||||
{currentStep === "user-settings" && <UserSettings user={user} nextStep={() => goToIndex(1)} />}
|
{currentStep === "user-settings" && <UserSettings user={user} nextStep={() => goToIndex(1)} />}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button } from "@calcom/ui";
|
||||||
|
|
||||||
|
export default function MaintenancePage() {
|
||||||
|
const { t, isLocaleReady } = useLocale();
|
||||||
|
if (!isLocaleReady) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-100">
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
{t("under_maintenance")} | {APP_NAME}
|
||||||
|
</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<div className="m-auto rounded-md bg-white p-10 text-right ltr:text-left">
|
||||||
|
<h1 className="text-2xl font-medium text-black">{t("under_maintenance")}</h1>
|
||||||
|
<p className="mt-4 mb-6 max-w-2xl text-sm text-gray-600">
|
||||||
|
{t("under_maintenance_description", { appName: APP_NAME })}
|
||||||
|
</p>
|
||||||
|
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { GetServerSidePropsContext } from "next";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
||||||
import { Meta } from "@calcom/ui";
|
|
||||||
|
|
||||||
import { getLayout } from "@components/auth/layouts/AdminLayout";
|
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
|
||||||
|
|
||||||
function AdminUsersView() {
|
|
||||||
const { t } = useLocale();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Meta title={t("users")} description={t("users_description")} />
|
|
||||||
<h1>{t("users_listing")}</h1>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
AdminUsersView.getLayout = getLayout;
|
|
||||||
|
|
||||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
|
||||||
const ssr = await ssrInit(context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
trpcState: ssr.dehydrate(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminUsersView;
|
|
||||||
Loaded 100 of 388 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user