Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605f9c61f3 | ||
|
|
564862369f | ||
|
|
be6cf4705c | ||
|
|
a2457ab619 | ||
|
|
867966dd75 | ||
|
|
08ad4c07ea | ||
|
|
78da57055e | ||
|
|
d5c3d9d967 | ||
|
|
aa0e9e6a02 | ||
|
|
cad97f1779 | ||
|
|
31ee689672 | ||
|
|
3edb6f5ffd | ||
|
|
a8825badec | ||
|
|
04c634ec4b | ||
|
|
8dd9b8d74b | ||
|
|
0365157638 | ||
|
|
a571d40958 | ||
|
|
69491d5700 | ||
|
|
20022e2cdd | ||
|
|
492858b45b | ||
|
|
b49ad8294b | ||
|
|
aa30ff7d1a | ||
|
|
cfa720a523 | ||
|
|
9ab196c30e | ||
|
|
47e13cd2fd | ||
|
|
74ab6d209e | ||
|
|
46f0b55a45 | ||
|
|
e4893c2218 | ||
|
|
fa17139e8f | ||
|
|
1cdc97dd37 | ||
|
|
f630e8af40 | ||
|
|
fe0745121b | ||
|
|
f6d7568d0a | ||
|
|
520e7fe036 | ||
|
|
1bf50e09cd | ||
|
|
d1ca7b4bdd | ||
|
|
4e2473531f | ||
|
|
efb9bf7116 | ||
|
|
49ee5d300b | ||
|
|
1512551903 | ||
|
|
1b459eb212 | ||
|
|
e4eb21bf8d | ||
|
|
79f7a16f62 | ||
|
|
7fc884912e | ||
|
|
20b3efbcb6 | ||
|
|
427b19238d | ||
|
|
696122a701 | ||
|
|
7d9c21cf9a | ||
|
|
eb93524f49 | ||
|
|
07fd3ad29f | ||
|
|
389633394b | ||
|
|
d27817ad51 | ||
|
|
7af23305eb | ||
|
|
db947591ad | ||
|
|
2ef4a05750 | ||
|
|
f1ac45ee19 | ||
|
|
03c9fc7952 | ||
|
|
de2a56ebcb | ||
|
|
0bae688da1 | ||
|
|
7967a65ade | ||
|
|
1cd96d4c25 | ||
|
|
19bc329377 | ||
|
|
0c29f2a135 | ||
|
|
a145de56ee | ||
|
|
d2352da54b | ||
|
|
5d7685f2c0 | ||
|
|
ee8b2e67f0 | ||
|
|
a0c9123ee7 | ||
|
|
131611e114 | ||
|
|
b3407e19b5 | ||
|
|
0d4b0847d4 | ||
|
|
5d8575abf4 | ||
|
|
631bf4d9fe | ||
|
|
8fd1d76227 | ||
|
|
22b189cb19 | ||
|
|
27c29c65e2 | ||
|
|
f0c7d7ff29 | ||
|
|
679965782e | ||
|
|
650ecc7a85 | ||
|
|
8d27dcdea3 | ||
|
|
6128be8860 | ||
|
|
13577a5dbe | ||
|
|
9ba13a3f3c | ||
|
|
9ac562cfba | ||
|
|
e1411b3fb2 | ||
|
|
0f89415ec3 | ||
|
|
c6ff3dca4a | ||
|
|
e001c0b88f | ||
|
|
5cbd334a42 | ||
|
|
470ef737b5 | ||
|
|
f3e8bed1cf | ||
|
|
750362c0cf | ||
|
|
3811dd564d | ||
|
|
99fe67b879 | ||
|
|
2685e8154f | ||
|
|
4308ea8d52 | ||
|
|
358aa8be2b | ||
|
|
b20c0df4e8 | ||
|
|
09af14b3f4 | ||
|
|
77df4117d0 | ||
|
|
84f7924215 | ||
|
|
c8554e6878 | ||
|
|
5993e0842c | ||
|
|
7179b09d7b | ||
|
|
325dc9805f | ||
|
|
0f6a678b74 | ||
|
|
96308cd607 | ||
|
|
91464fb5e5 | ||
|
|
49d6dd9802 | ||
|
|
54cefcb16e | ||
|
|
f20d78bec1 | ||
|
|
089f52b425 | ||
|
|
47bf6d27d9 | ||
|
|
2cc937ba87 | ||
|
|
a57519040e | ||
|
|
3924731266 | ||
|
|
74986bb6a3 | ||
|
|
9568e16ef6 | ||
|
|
e88716758e | ||
|
|
fcb06a131b | ||
|
|
d778fe6a1b | ||
|
|
877f72d11a | ||
|
|
f728272c7f | ||
|
|
84fd277a97 | ||
|
|
ae0ed4176b | ||
|
|
ec4228ab6a | ||
|
|
19b1f8a05f | ||
|
|
1ba6b08edf | ||
|
|
f027f018ff | ||
|
|
9fd7ca65b5 | ||
|
|
c0f4c8e035 | ||
|
|
ed750c8df1 | ||
|
|
d545a9e127 | ||
|
|
6c55138589 | ||
|
|
18414e857a | ||
|
|
1c8f9cb3fd | ||
|
|
0871314d53 | ||
|
|
02d70c63a5 | ||
|
|
71a3374930 | ||
|
|
a22e4eed32 | ||
|
|
1625f462c2 |
@@ -15,16 +15,22 @@
|
||||
# - LARK
|
||||
# - WEB3
|
||||
# - SALESFORCE
|
||||
# - ZOHOCRM
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
# - DAILY.CO VIDEO
|
||||
# Enables Cal Video. to get your key
|
||||
# 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||
# 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
# @see https://github.com/calcom/cal.com#obtaining-daily-api-credentials
|
||||
|
||||
DAILY_API_KEY=
|
||||
DAILY_SCALE_PLAN=''
|
||||
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# Needed to enable Google Calendar integration and Login with Google
|
||||
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
|
||||
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS=
|
||||
|
||||
# To enable Login with Google you need to:
|
||||
@@ -109,4 +115,10 @@ INFURA_API_KEY=""
|
||||
# Used for the Salesforce (Sales Cloud) app
|
||||
SALESFORCE_CONSUMER_KEY=""
|
||||
SALESFORCE_CONSUMER_SECRET=""
|
||||
|
||||
# - ZOHOCRM
|
||||
# Used for the Zoho CRM integration
|
||||
ZOHOCRM_CLIENT_ID=""
|
||||
ZOHOCRM_CLIENT_SECRET=""
|
||||
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -22,6 +22,13 @@ CALCOM_LICENSE_KEY=
|
||||
|
||||
# - DATABASE ************************************************************************************************
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
|
||||
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
|
||||
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
|
||||
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
|
||||
# PRISMA_GENERATE_DATAPROXY=true
|
||||
PRISMA_GENERATE_DATAPROXY=
|
||||
|
||||
# ***********************************************************************************************************
|
||||
|
||||
# - SHARED **************************************************************************************************
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
packages/prisma/zod
|
||||
apps/web/public/embed
|
||||
@@ -3,7 +3,7 @@ description: "Cache or restore if necessary"
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v16.x
|
||||
default: v18.x
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -20,6 +20,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/apps/web/.next
|
||||
${{ github.workspace }}/apps/web/public/embed
|
||||
**/.turbo/**
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
name: Yarn install
|
||||
description: "Install all NPM dependencies, caches them and restores if necessary"
|
||||
########################################################################################
|
||||
# "yarn install" composite action for yarn 2/3/4+ and "nodeLinker: node-modules" #
|
||||
#--------------------------------------------------------------------------------------#
|
||||
# Cache: #
|
||||
# - Downloaded zip archive (multi-arch, preserved across yarn.lock changes) #
|
||||
# - Yarn install state (discarded on yarn.lock changes) #
|
||||
# References: #
|
||||
# - bench: https://gist.github.com/belgattitude/0ecd26155b47e7be1be6163ecfbb0f0b #
|
||||
# - vs @setup/node: https://github.com/actions/setup-node/issues/325 #
|
||||
########################################################################################
|
||||
|
||||
name: "Yarn install"
|
||||
description: "Run yarn install with node_modules linker and cache enabled"
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v16.x
|
||||
default: v18.x
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -11,9 +23,40 @@ runs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
cache: "yarn"
|
||||
- name: Yarn install
|
||||
- name: Expose yarn config as "$GITHUB_OUTPUT"
|
||||
id: yarn-config
|
||||
shell: bash
|
||||
run: |
|
||||
yarn install --prefer-offline --frozen-lockfile
|
||||
echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Yarn rotates the downloaded cache archives, @see https://github.com/actions/setup-node/issues/325
|
||||
# Yarn cache is also reusable between arch and os.
|
||||
- name: Restore yarn cache
|
||||
uses: actions/cache@v3
|
||||
id: yarn-download-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
|
||||
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: |
|
||||
yarn-download-cache-
|
||||
|
||||
# Invalidated on yarn.lock changes
|
||||
- name: Restore yarn install state
|
||||
id: yarn-install-state-cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .yarn/ci-cache/
|
||||
key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
yarn install --inline-builds
|
||||
yarn prisma generate
|
||||
env:
|
||||
# CI optimizations. Overrides yarnrc.yml options (or their defaults) in the CI action.
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS: "false" # So it doesn't try to remove our private submodule deps
|
||||
YARN_ENABLE_GLOBAL_CACHE: "false" # Use local cache folder to keep downloaded archives
|
||||
YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz # Very small speedup when lock does not change
|
||||
# Other environment variables
|
||||
HUSKY: "0" # By default do not run HUSKY install
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Cleanup Preview Databases
|
||||
|
||||
env:
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_PROJECT_ID: cl4u26bwz7962859ply7ibuo43t
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
delete:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: snaplet/action@v1
|
||||
if: ${{ env.SNAPLET_ACCESS_TOKEN && env.SNAPLET_PROJECT_ID }}
|
||||
with:
|
||||
delete: true
|
||||
@@ -18,5 +18,3 @@ jobs:
|
||||
days-before-stale: 60
|
||||
include-only-assigned: true
|
||||
days-before-close: -1
|
||||
|
||||
|
||||
|
||||
@@ -17,11 +17,14 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@1.4.9
|
||||
uses: crowdin/github-action@1.5.1
|
||||
with:
|
||||
upload_translations: true
|
||||
upload_sources: true
|
||||
download_translations: true
|
||||
pull_request_labels: "♻️ autoupdate, automerge"
|
||||
push_translations: true
|
||||
commit_message: "New Crowdin translations by Github Action"
|
||||
localization_branch_name: main
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# .github/workflows/preview.yml
|
||||
|
||||
name: Preview Environment
|
||||
|
||||
env:
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_PROJECT_ID: ${{ secrets.SNAPLET_PROJECT_ID }}
|
||||
VERCEL_ACCESS_TOKEN: ${{ secrets.VERCEL_ACCESS_TOKEN }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
db-preview-deploy:
|
||||
if: ${{ github.event.action == 'opened' || github.event.action == 'synchronize' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: snaplet
|
||||
uses: snaplet/action@v1
|
||||
- uses: snaplet/vercel-action@v1
|
||||
with:
|
||||
env: |
|
||||
DATABASE_URL=${{ steps.snaplet.outputs.database-url }}
|
||||
db-preview-delete:
|
||||
if: ${{ github.event.action == 'closed' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: snaplet/action@v1
|
||||
with:
|
||||
delete: true
|
||||
- uses: snaplet/vercel-action@v1
|
||||
with:
|
||||
delete: true
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Staging DB Snapshot
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1-5" # At 04:00 on every day-of-week from Monday through Friday.
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
db-staging-snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Snaplet CLI
|
||||
run: curl -sL https://app.snaplet.dev/get-cli/ | bash
|
||||
- name: Restore Snapshot
|
||||
run: snaplet snapshot restore -y
|
||||
env:
|
||||
SNAPLET_DATABASE_URL: ${{ secrets.SNAPLET_DATA_TARGET_DB_URL }}
|
||||
SNAPLET_ACCESS_TOKEN: ${{ secrets.SNAPLET_ACCESS_TOKEN }}
|
||||
SNAPLET_DATABASE_ID: ${{ secrets.SNAPLET_DATABASE_ID }}
|
||||
@@ -0,0 +1,36 @@
|
||||
name: E2E Embed tests and booking flow(for non-embed as well)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-playwright-install
|
||||
- uses: ./.github/actions/env-read-file
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn test-e2e:embed-react
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results
|
||||
@@ -25,19 +25,12 @@ jobs:
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn turbo run embed-tests-update-snapshots:ci --scope=@calcom/embed-react --concurrency=1
|
||||
run: yarn test-e2e:embed
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload embed-core results
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-embed-core
|
||||
path: packages/embeds/embed-core/playwright/results
|
||||
|
||||
- name: Upload embed-react results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-embed-react
|
||||
path: packages/embeds/embed-react/playwright/results
|
||||
name: test-results
|
||||
path: test-results
|
||||
|
||||
@@ -20,7 +20,7 @@ env:
|
||||
INPUT_ENV_PAYMENT_FEE_FIXED: 10
|
||||
INPUT_ENV_SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
INPUT_ENV_SAML_ADMINS: pro@example.com
|
||||
INPUT_ENV_NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
INPUT_ENV_NEXTAUTH_URL: http://127.0.0.1:3000/api/auth
|
||||
INPUT_ENV_NEXT_PUBLIC_IS_E2E: 1
|
||||
# INPUT_ENV_EMAIL_FROM: e2e@cal.com
|
||||
# INPUT_ENV_EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ozaytsev86/create-env-file@v1
|
||||
with:
|
||||
file-name: ${{ github.workspace }}/gh.env
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
outputs:
|
||||
app-store: ${{ steps.filter.outputs.app-store }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
embed-react: ${{ steps.filter.outputs.embed-react }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
@@ -34,6 +35,9 @@ jobs:
|
||||
embed:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
embed-react:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
|
||||
env:
|
||||
name: Create env file
|
||||
@@ -81,6 +85,13 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: E2E React embeds tests
|
||||
if: ${{ needs.changes.outputs.embed-react == 'true' }}
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
needs: build
|
||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||
|
||||
@@ -87,3 +87,12 @@ apps/api
|
||||
apps/website
|
||||
apps/console
|
||||
apps/auth
|
||||
|
||||
# Yarn Modern
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
@@ -14,3 +14,4 @@ public
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
packages/prisma/zod
|
||||
apps/web/public/embed
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.4.1.cjs
|
||||
@@ -88,6 +88,7 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
||||
- [React](https://reactjs.org/?ref=cal.com)
|
||||
- [Tailwind](https://tailwindcss.com/?ref=cal.com)
|
||||
- [Prisma](https://prisma.io/?ref=cal.com)
|
||||
- [Daily.co](https://go.cal.com/daily)
|
||||
|
||||
## Stay Up-to-Date
|
||||
|
||||
@@ -432,7 +433,7 @@ following
|
||||
|
||||
### Obtaining Daily API Credentials
|
||||
|
||||
1. Open [Daily](https://www.daily.co/) and sign into your account.
|
||||
1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information
|
||||
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
3. Copy your API key.
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
@@ -451,6 +452,19 @@ following
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
### Obtaining ZohoCRM Client ID and Secret
|
||||
|
||||
1. Open [Zoho API Console](https://api-console.zoho.com/) and sign into your account, or create a new one.
|
||||
2. From within the API console page, go to "Applications".
|
||||
3. Click "ADD CLIENT" button top right and select "Server-based Applications".
|
||||
4. Fill in any information you want in the "Client Details" tab
|
||||
5. Go to tab "Client Secret" tab.
|
||||
6. Now copy the Client ID and Client Secret to your .env file into the `ZOHOCRM_CLIENT_ID` and `ZOHOCRM_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zohocrm/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Settings" section check the "Multi-DC" option if you wish to use the same OAuth credentials for all data centers.
|
||||
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
||||
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
||||
|
||||
## Workflows
|
||||
|
||||
### Setting up SendGrid for Email reminders
|
||||
|
||||
@@ -1 +1 @@
|
||||
14.17
|
||||
v18
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/config": "*",
|
||||
"eslint": "^8.34.0"
|
||||
"@calcom/config": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
node_modules
|
||||
prisma/zod
|
||||
public/embed
|
||||
|
||||
@@ -1 +1 @@
|
||||
v16
|
||||
v18
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public/embed
|
||||
@@ -94,16 +94,6 @@ const DestinationCalendarSelector = ({
|
||||
styles={{
|
||||
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
option: (defaultStyles, state) => ({
|
||||
...defaultStyles,
|
||||
backgroundColor: state.isSelected
|
||||
? state.isFocused
|
||||
? "var(--brand-color)"
|
||||
: "var(--brand-color)"
|
||||
: state.isFocused
|
||||
? "var(--brand-color-dark-mode)"
|
||||
: "var(--brand-text-color)",
|
||||
}),
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
|
||||
@@ -327,10 +327,10 @@ const embeds: {
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="2" fill="#E1E1E1" />
|
||||
<rect x="48" y="86.5" width="48" height="4" rx="1" fill="#E1E1E1" />
|
||||
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="6" fill="#F3F4F6" />
|
||||
<rect x="48" y="86.5" width="48" height="4" rx="6" fill="#F3F4F6" />
|
||||
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
@@ -378,9 +378,9 @@ const embeds: {
|
||||
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="6" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
@@ -400,12 +400,12 @@ const embeds: {
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="2" fill="#292929" />
|
||||
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="6" fill="#F3F4F6" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="6" fill="#292929" />
|
||||
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
|
||||
</svg>
|
||||
),
|
||||
@@ -426,62 +426,65 @@ const embeds: {
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="108" y="85" width="80" height="8" rx="2" fill="#E1E1E1" />
|
||||
<rect x="108" y="97" width="48" height="4" rx="1" fill="#E1E1E1" />
|
||||
<rect x="109" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="110" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="133" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="136" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M121 135H127V133H121V135ZM128 136V142H130V136H128ZM127 143H121V145H127V143ZM120 142V136H118V142H120ZM121 143C120.448 143 120 142.552 120 142H118C118 143.657 119.343 145 121 145V143ZM128 142C128 142.552 127.552 143 127 143V145C128.657 145 130 143.657 130 142H128ZM127 135C127.552 135 128 135.448 128 136H130C130 134.343 128.657 133 127 133V135ZM121 133C119.343 133 118 134.343 118 136H120C120 135.448 120.448 135 121 135V133Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="133" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="181" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="181" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="172" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" stroke="#292929" />
|
||||
<rect x="24" y="0.50293" width="260" height="24" rx="6" fill="#F3F4F6" />
|
||||
<rect x="24" y="35" width="259" height="192" rx="5.5" fill="#F9FAFB" />
|
||||
<g filter="url(#filter0_i_3223_14162)">
|
||||
<rect opacity="0.8" x="40" y="99" width="24" height="24" rx="2" fill="#E5E7EB" />
|
||||
<rect x="40" y="127" width="48" height="8" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="139" width="82" height="8" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="151" width="34" height="4" rx="1" fill="#E5E7EB" />
|
||||
<rect x="40" y="159" width="34" height="4" rx="1" fill="#E5E7EB" />
|
||||
</g>
|
||||
<rect x="152" y="48" width="2" height="169" rx="2" fill="#E5E7EB" />
|
||||
|
||||
<rect opacity="0.8" x="176" y="84" width="80" height="8" rx="2" fill="#E5E7EB" />
|
||||
<rect x="176" y="96" width="48" height="4" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="109" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="201" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="109" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="123" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="135" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="187.3" y="133.4" width="9" height="9" rx="1.5" stroke="#0D121D" />
|
||||
<rect x="201" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="237" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="249" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="135" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="147" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="147" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="249" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="147" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="159" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="159" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="249" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="261" y="159" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="177" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="189" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="201" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="213" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="225" y="171" width="6" height="6" rx="1" fill="#0D121D" />
|
||||
<rect x="237" y="171" width="6" height="6" rx="1" fill="#E5E7EB" />
|
||||
<rect x="24" y="35" width="259" height="192" rx="5.5" stroke="#101010" />
|
||||
<rect x="24" y="241.503" width="260" height="24" rx="6" fill="#F3F4F6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
@@ -629,9 +632,9 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DialogContent type="creation" size="lg">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
<DialogContent className="rounded-lg p-10" type="creation" size="lg">
|
||||
<div className="mb-2">
|
||||
<h3 className="mb-2 text-2xl font-bold leading-none text-gray-900" id="modal-title">
|
||||
{t("how_you_want_add_cal_site", { appName: APP_NAME })}
|
||||
</h3>
|
||||
<div>
|
||||
@@ -641,7 +644,7 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
<div className="flex items-start">
|
||||
{embeds.map((embed, index) => (
|
||||
<button
|
||||
className="w-1/3 border border-transparent p-3 text-left hover:rounded-md hover:border-gray-200 hover:bg-gray-100 ltr:mr-2 rtl:ml-2"
|
||||
className="hover:border-primary-900 w-1/3 rounded-md border border-gray-200 bg-gray-50 p-6 text-left hover:rounded-md hover:bg-gray-100 ltr:mr-4 ltr:last:mr-0 rtl:ml-4 rtl:last:ml-0"
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
@@ -649,11 +652,11 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
embedType: embed.type,
|
||||
});
|
||||
}}>
|
||||
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
||||
<div className="order-none box-border flex-none rounded-md border border-solid bg-white">
|
||||
{embed.illustration}
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-gray-900">{embed.title}</div>
|
||||
<p className="text-sm text-gray-500">{embed.subtitle}</p>
|
||||
<div className="mt-4 font-semibold leading-none text-gray-900">{embed.title}</div>
|
||||
<p className="mt-2 text-sm text-gray-500">{embed.subtitle}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AdminRequired } from "components/ui/AdminRequired";
|
||||
import noop from "lodash/noop";
|
||||
import { noop } from "lodash";
|
||||
import type { LinkProps } from "next/link";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
@@ -45,12 +46,12 @@ const Component = ({
|
||||
tos,
|
||||
privacy,
|
||||
isProOnly,
|
||||
images,
|
||||
descriptionItems,
|
||||
isTemplate,
|
||||
dependencies,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t } = useLocale();
|
||||
const hasImages = images && images.length > 0;
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
@@ -94,17 +95,25 @@ const Component = ({
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasImages && (
|
||||
{hasDescriptionItems && (
|
||||
<div className="align-center mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||
{images ? (
|
||||
images.map((img) => (
|
||||
<img
|
||||
key={img}
|
||||
src={img}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
))
|
||||
{descriptionItems ? (
|
||||
descriptionItems.map((descriptionItem, index) =>
|
||||
typeof descriptionItem === "object" ? (
|
||||
<div
|
||||
key={`iframe-${index}`}
|
||||
className="mr-4 max-h-full min-h-[315px] min-w-[90%] max-w-full snap-center last:mb-0 lg:mb-4 lg:mr-0 [&_iframe]:h-full [&_iframe]:min-h-[315px] [&_iframe]:w-full">
|
||||
<iframe allowFullScreen {...descriptionItem.iframe} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={descriptionItem}
|
||||
src={descriptionItem}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<SkeletonText />
|
||||
)}
|
||||
@@ -113,7 +122,7 @@ const Component = ({
|
||||
<div
|
||||
className={classNames(
|
||||
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
|
||||
hasImages && "lg:ml-8"
|
||||
hasDescriptionItems && "lg:ml-8"
|
||||
)}>
|
||||
<div className="mb-8 flex pt-4">
|
||||
<header>
|
||||
@@ -354,7 +363,7 @@ export default function App(props: {
|
||||
privacy?: string;
|
||||
licenseRequired: AppType["licenseRequired"];
|
||||
isProOnly: AppType["isProOnly"];
|
||||
images?: string[];
|
||||
descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
|
||||
isTemplate?: boolean;
|
||||
disableInstall?: boolean;
|
||||
dependencies?: string[];
|
||||
|
||||
@@ -96,16 +96,6 @@ const DestinationCalendarSelector = ({
|
||||
styles={{
|
||||
placeholder: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
singleValue: (styles) => ({ ...styles, ...content(hidePlaceholder) }),
|
||||
option: (defaultStyles, state) => ({
|
||||
...defaultStyles,
|
||||
backgroundColor: state.isSelected
|
||||
? state.isFocused
|
||||
? "var(--brand-color)"
|
||||
: "var(--brand-color)"
|
||||
: state.isFocused
|
||||
? "var(--brand-color-dark-mode)"
|
||||
: "var(--brand-text-color)",
|
||||
}),
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
|
||||
@@ -24,6 +24,7 @@ type AvailableTimesProps = {
|
||||
eventTypeSlug: string;
|
||||
date?: Dayjs;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
bookingAttendees?: number | null;
|
||||
slots?: Slot[];
|
||||
isLoading: boolean;
|
||||
ethSignature?: string;
|
||||
@@ -39,6 +40,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
timeFormat,
|
||||
onTimeFormatChange,
|
||||
seatsPerTimeSlot,
|
||||
bookingAttendees,
|
||||
ethSignature,
|
||||
}) => {
|
||||
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
||||
@@ -92,7 +94,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
pathname: string;
|
||||
query: Record<string, string | number | string[] | undefined>;
|
||||
query: Record<string, string | number | string[] | undefined | TimeFormat>;
|
||||
};
|
||||
const bookingUrl: BookingURL = {
|
||||
pathname: router.pathname.endsWith("/embed") ? "../book" : "book",
|
||||
@@ -101,6 +103,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
date: dayjs.utc(slot.time).tz(timeZone()).format(),
|
||||
type: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
timeFormat,
|
||||
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
||||
...(ethSignature ? { ethSignature } : {}),
|
||||
@@ -116,18 +119,28 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
bookingUrl.query.bookingUid = slot.bookingUid;
|
||||
}
|
||||
|
||||
let slotFull, notEnoughSeats;
|
||||
|
||||
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
|
||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot)
|
||||
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
|
||||
|
||||
return (
|
||||
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
||||
{/* ^ data-slot-owner is helpful in debugging and used to identify the owners of the slot. Owners are the users which have the timeslot in their schedule. It doesn't consider if a user has that timeslot booked */}
|
||||
{/* Current there is no way to disable Next.js Links */}
|
||||
{seatsPerTimeSlot && slot.attendees && slot.attendees >= seatsPerTimeSlot ? (
|
||||
{seatsPerTimeSlot && slot.attendees && (slotFull || notEnoughSeats) ? (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-primary-500 dark:bg-darkgray-200 dark:text-darkgray-900 mb-2 block rounded-sm border bg-white py-2 font-medium opacity-25 dark:border-transparent ",
|
||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}>
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && <p className="text-sm">{t("booking_full")}</p>}
|
||||
{notEnoughSeats ? (
|
||||
<p className="text-sm">{t("not_enough_seats")}</p>
|
||||
) : slots ? (
|
||||
<p className="text-sm">{t("booking_full")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
|
||||
@@ -135,7 +135,6 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
<li key={idx}>
|
||||
<Badge
|
||||
variant="gray"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
duration === dur.toString()
|
||||
? "bg-darkgray-200 text-darkgray-900 dark:bg-darkmodebrand dark:!text-darkmodebrandcontrast"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useState } from "react";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
@@ -46,7 +48,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
@@ -94,6 +99,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
if (!booking.seatsReferences[0]) {
|
||||
return undefined;
|
||||
}
|
||||
return booking.seatsReferences[0].referenceUid;
|
||||
};
|
||||
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
@@ -135,7 +147,8 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}`,
|
||||
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}
|
||||
`,
|
||||
icon: FiX,
|
||||
},
|
||||
{
|
||||
@@ -146,7 +159,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
id: "reschedule",
|
||||
icon: FiClock,
|
||||
label: t("reschedule_booking"),
|
||||
href: `/reschedule/${booking.uid}`,
|
||||
href: `/reschedule/${booking.uid}${
|
||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "reschedule_request",
|
||||
@@ -179,14 +194,15 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<div className="ml-1 mr-8 flex text-gray-500" data-testid="request_reschedule_sent">
|
||||
<FiSend className="-mt-[1px] w-4 rotate-45" />
|
||||
<p className="ml-2 ">{t("reschedule_request_sent")}</p>
|
||||
</div>
|
||||
<Badge startIcon={FiSend} size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
@@ -224,7 +240,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const showRecordingsButtons =
|
||||
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||
|
||||
const title = decodeURIComponent(booking.title);
|
||||
const title = booking.title;
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -386,13 +402,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block text-left text-sm md:hidden">
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
<td className="flex w-full justify-end py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||
@@ -422,7 +438,10 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const now = new Date();
|
||||
const recurringCount = recurringDates.filter((date) => {
|
||||
return (
|
||||
@@ -452,7 +471,7 @@ const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookings
|
||||
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
|
||||
{formatTime(aDate, user?.timeFormat, user?.timeZone)}
|
||||
{" - "}
|
||||
{dayjs(aDate).format("D MMMM YYYY")}
|
||||
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
|
||||
</p>
|
||||
);
|
||||
})}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
@@ -23,18 +23,26 @@ type Props = {
|
||||
setIsCancellationMode: (value: boolean) => void;
|
||||
theme: string | null;
|
||||
allRemainingBookings: boolean;
|
||||
seatReferenceUid?: string;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { booking, allRemainingBookings } = props;
|
||||
const { booking, allRemainingBookings, seatReferenceUid } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
useTheme(props.theme);
|
||||
|
||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||
if (node !== null) {
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
@@ -53,6 +61,7 @@ export default function CancelBooking(props: Props) {
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<label className="text-bookingdark font-medium dark:text-white">{t("cancellation_reason")}</label>
|
||||
<TextArea
|
||||
ref={cancelBookingRef}
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
@@ -68,21 +77,21 @@ export default function CancelBooking(props: Props) {
|
||||
{t("nevermind")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex justify-center"
|
||||
className="dark:bg-darkmodebrand dark:hover:border-darkmodebrand dark:text-darkmodebrandcontrast bg-brand hover:border-brand text-brandcontrast flex justify-center"
|
||||
data-testid="cancel"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const payload = {
|
||||
id: booking?.id,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
};
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
uid: booking?.uid,
|
||||
cancellationReason: cancellationReason,
|
||||
allRemainingBookings,
|
||||
// @NOTE: very important this shouldn't cancel with number ID use uid instead
|
||||
seatReferenceUid,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -78,6 +78,7 @@ export const SlotPicker = ({
|
||||
recurringEventCount,
|
||||
users,
|
||||
seatsPerTimeSlot,
|
||||
bookingAttendees,
|
||||
weekStart = 0,
|
||||
ethSignature,
|
||||
}: {
|
||||
@@ -89,6 +90,7 @@ export const SlotPicker = ({
|
||||
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||
timeZone?: string;
|
||||
seatsPerTimeSlot?: number;
|
||||
bookingAttendees?: number;
|
||||
recurringEventCount?: number;
|
||||
users: string[];
|
||||
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
@@ -131,10 +133,7 @@ export const SlotPicker = ({
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
usernameList: users,
|
||||
startTime:
|
||||
browsingDate === undefined || browsingDate.get("month") === dayjs.tz(undefined, timeZone).get("month")
|
||||
? dayjs.tz(undefined, timeZone).subtract(2, "days").startOf("day")
|
||||
: browsingDate?.startOf("month"),
|
||||
startTime: dayjs().startOf("day"),
|
||||
endTime: browsingDate?.endOf("month"),
|
||||
timeZone,
|
||||
duration,
|
||||
@@ -192,6 +191,7 @@ export const SlotPicker = ({
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
bookingAttendees={bookingAttendees}
|
||||
recurringCount={recurringEventCount}
|
||||
ethSignature={ethSignature}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import type { ITimezoneOption } from "@calcom/ui";
|
||||
import { TimezoneSelect } from "@calcom/ui";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { timeZone } from "../../lib/clock";
|
||||
|
||||
type Props = {
|
||||
@@ -15,8 +12,6 @@ type Props = {
|
||||
|
||||
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const query = useMeQuery();
|
||||
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTimeZone(timeZone());
|
||||
@@ -28,59 +23,16 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||
}
|
||||
}, [selectedTimeZone, onSelectTimeZone]);
|
||||
|
||||
const customStyles = {
|
||||
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
|
||||
padding: "10px 12px !important",
|
||||
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
|
||||
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
|
||||
...(userTheme === "dark" && {
|
||||
color: state.isSelected || state.isFocused ? "white" : "#80868B",
|
||||
}),
|
||||
}),
|
||||
control: () => ({
|
||||
display: "flex",
|
||||
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"
|
||||
classNames={{
|
||||
singleValue: () => "dark:text-darkgray-600 text-gray-600",
|
||||
menu: () => "!w-64 max-w-[90vw]",
|
||||
}}
|
||||
variant="minimal"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
className="flex h-6 text-sm font-medium"
|
||||
styles={customStyles}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@ export function TimezoneDropdown({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit max-w-[20rem] items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
|
||||
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
|
||||
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -23,8 +23,9 @@ import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { FiCreditCard, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||
import { FiCreditCard, FiUser, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
|
||||
@@ -52,6 +53,7 @@ const dateQuerySchema = z.object({
|
||||
rescheduleUid: z.string().optional().default(""),
|
||||
date: z.string().optional().default(""),
|
||||
timeZone: z.string().optional().default(""),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
@@ -124,6 +126,15 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
: undefined,
|
||||
];
|
||||
|
||||
const { data: bookingAttendees } = trpc.viewer.bookings.getBookingAttendees.useQuery(
|
||||
{
|
||||
seatReferenceUid: rescheduleUid,
|
||||
},
|
||||
{
|
||||
enabled: !!(rescheduleUid && eventType.seatsPerTimeSlot),
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
|
||||
<HeadSeo
|
||||
@@ -143,6 +154,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
nofollow: eventType.hidden,
|
||||
noindex: eventType.hidden,
|
||||
}}
|
||||
isBrandingHidden={restProps.isBrandingHidden}
|
||||
/>
|
||||
<BookingPageTagManager eventType={eventType} />
|
||||
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
|
||||
@@ -172,6 +184,22 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<BookingDescription profile={profile} eventType={eventType} rescheduleUid={rescheduleUid}>
|
||||
{rescheduleUid && eventType.seatsPerTimeSlot && bookingAttendees && (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex flex-nowrap items-center text-sm font-medium",
|
||||
"dark:text-darkgray-600 text-gray-600",
|
||||
"ltr:mr-[10px] rtl:ml-[10px]"
|
||||
)}>
|
||||
<FiUser
|
||||
className={classNames(
|
||||
"min-h-4 min-w-4 ml-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]",
|
||||
"mt-[2px]"
|
||||
)}
|
||||
/>{" "}
|
||||
{t("event_type_seats", { numberOfSeats: bookingAttendees })}
|
||||
</div>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<FiRefreshCcw className="float-left mt-[7px] ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] " />
|
||||
@@ -247,6 +275,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
bookingAttendees={bookingAttendees || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
ethSignature={gateState.rainbowToken}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
@@ -15,7 +15,6 @@ import { getEventLocationType, locationKeyToString } from "@calcom/app-store/loc
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useEmbedNonStylesConfig,
|
||||
@@ -36,19 +35,19 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { Button, Form, Tooltip } from "@calcom/ui";
|
||||
import { FiAlertTriangle, FiCalendar, FiRefreshCw, FiUser } from "@calcom/ui/components/icon";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
import { parseRecurringDates, parseDate } from "@lib/parseDate";
|
||||
|
||||
import type { Gate, GateState } from "@components/Gates";
|
||||
import Gates from "@components/Gates";
|
||||
@@ -58,6 +57,8 @@ import type { BookPageProps } from "../../../pages/[user]/book";
|
||||
import type { HashLinkPageProps } from "../../../pages/d/[link]/book";
|
||||
import type { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||
|
||||
const Toaster = dynamic(() => import("react-hot-toast").then((mod) => mod.Toaster), { ssr: false });
|
||||
|
||||
/** These are like 40kb that not every user needs */
|
||||
const BookingDescriptionPayment = dynamic(
|
||||
() => import("@components/booking/BookingDescriptionPayment")
|
||||
@@ -78,6 +79,7 @@ const BookingFields = ({
|
||||
const { t } = useLocale();
|
||||
const { watch, setValue } = useFormContext();
|
||||
const locationResponse = watch("responses.location");
|
||||
const currentView = rescheduleUid ? "reschedule" : "";
|
||||
|
||||
return (
|
||||
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
|
||||
@@ -87,12 +89,16 @@ const BookingFields = ({
|
||||
// Allowing a system field to be edited might require sending emails to attendees, so we need to be careful
|
||||
let readOnly =
|
||||
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
|
||||
|
||||
let noLabel = false;
|
||||
let hidden = !!field.hidden;
|
||||
const fieldViews = field.views;
|
||||
|
||||
if (fieldViews && !fieldViews.find((view) => view.id === currentView)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (field.name === SystemField.Enum.rescheduleReason) {
|
||||
if (!rescheduleUid) {
|
||||
return null;
|
||||
}
|
||||
// rescheduleReason is a reschedule specific field and thus should be editable during reschedule
|
||||
readOnly = false;
|
||||
}
|
||||
@@ -115,7 +121,10 @@ const BookingFields = ({
|
||||
}
|
||||
|
||||
// We don't show `notes` field during reschedule
|
||||
if (field.name === SystemField.Enum.notes && !!rescheduleUid) {
|
||||
if (
|
||||
(field.name === SystemField.Enum.notes || field.name === SystemField.Enum.guests) &&
|
||||
!!rescheduleUid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -177,9 +186,26 @@ const BookingFields = ({
|
||||
);
|
||||
};
|
||||
|
||||
const routerQuerySchema = z
|
||||
.object({
|
||||
timeFormat: z.nativeEnum(TimeFormat),
|
||||
rescheduleUid: z.string().optional(),
|
||||
date: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((date) => {
|
||||
if (date === undefined) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const BookingPage = ({
|
||||
eventType,
|
||||
booking,
|
||||
currentSlotBooking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
recurringEventCount,
|
||||
@@ -215,20 +241,6 @@ const BookingPage = ({
|
||||
duration = Number(queryDuration);
|
||||
}
|
||||
|
||||
// This is a workaround for forcing the same time format for both server side rendering and client side rendering
|
||||
// At initial render, we use the default time format which is 12H
|
||||
const [withDefaultTimeFormat, setWithDefaultTimeFormat] = useState(true);
|
||||
const parseDateFunc = useCallback(
|
||||
(date: string | null | Dayjs) => {
|
||||
return parseDate(date, i18n, withDefaultTimeFormat);
|
||||
},
|
||||
[withDefaultTimeFormat]
|
||||
);
|
||||
// After intial render on client side, we let parseDateFunc to use the time format from the localStorage
|
||||
useEffect(() => {
|
||||
setWithDefaultTimeFormat(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
@@ -242,12 +254,12 @@ const BookingPage = ({
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { uid, paymentUid } = responseData;
|
||||
const { uid } = responseData;
|
||||
|
||||
if (paymentUid) {
|
||||
if ("paymentUid" in responseData && !!responseData.paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
paymentUid,
|
||||
paymentUid: responseData.paymentUid,
|
||||
date,
|
||||
name: bookingForm.getValues("responses.name"),
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
@@ -262,6 +274,7 @@ const BookingPage = ({
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
seatReferenceUid: "seatReferenceUid" in responseData ? responseData.seatReferenceUid : null,
|
||||
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||
},
|
||||
});
|
||||
@@ -275,6 +288,7 @@ const BookingPage = ({
|
||||
return router.push({
|
||||
pathname: `/booking/${uid}`,
|
||||
query: {
|
||||
isSuccessBookingPage: true,
|
||||
allRemainingBookings: true,
|
||||
email: bookingForm.getValues("responses.email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
@@ -284,11 +298,17 @@ const BookingPage = ({
|
||||
},
|
||||
});
|
||||
|
||||
const rescheduleUid = router.query.rescheduleUid as string;
|
||||
const {
|
||||
data: { timeFormat, rescheduleUid, date },
|
||||
} = useTypedQuery(routerQuerySchema);
|
||||
|
||||
useTheme(profile.theme);
|
||||
const date = asStringOrNull(router.query.date);
|
||||
|
||||
const querySchema = getBookingResponsesPartialSchema({
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
eventType: {
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
},
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
});
|
||||
|
||||
const parsedQuery = querySchema.parse({
|
||||
@@ -368,7 +388,8 @@ const BookingPage = ({
|
||||
const bookingFormSchema = z
|
||||
.object({
|
||||
responses: getBookingResponsesSchema({
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
eventType: { bookingFields: getBookingFieldsWithSystemFields(eventType) },
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
}),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -386,30 +407,21 @@ const BookingPage = ({
|
||||
// Calculate the booking date(s)
|
||||
let recurringStrings: string[] = [],
|
||||
recurringDates: Date[] = [];
|
||||
const parseRecurringDatesFunc = useCallback(
|
||||
(date: string | null | Dayjs, recurringEvent: RecurringEvent, recurringCount: number) => {
|
||||
return parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
timeZone: timeZone(),
|
||||
recurringEvent: recurringEvent,
|
||||
recurringCount: recurringCount,
|
||||
withDefaultTimeFormat: withDefaultTimeFormat,
|
||||
},
|
||||
i18n
|
||||
);
|
||||
},
|
||||
[withDefaultTimeFormat, date, eventType.recurringEvent, recurringEventCount]
|
||||
);
|
||||
|
||||
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
|
||||
[recurringStrings, recurringDates] = parseRecurringDatesFunc(
|
||||
date,
|
||||
eventType.recurringEvent,
|
||||
parseInt(recurringEventCount.toString())
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
timeZone: timeZone(),
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: parseInt(recurringEventCount.toString()),
|
||||
selectedTimeFormat: timeFormat,
|
||||
},
|
||||
i18n
|
||||
);
|
||||
}
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
const bookEvent = (bookingValues: BookingFormValues) => {
|
||||
telemetry.event(
|
||||
top !== window ? telemetryEventTypes.embedBookingConfirmed : telemetryEventTypes.bookingConfirmed,
|
||||
{ isTeamBooking: document.URL.includes("team/") }
|
||||
@@ -432,9 +444,9 @@ const BookingPage = ({
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...booking,
|
||||
start: dayjs(recurringDate).format(),
|
||||
end: dayjs(recurringDate).add(duration, "minute").format(),
|
||||
...bookingValues,
|
||||
start: dayjs(recurringDate).utc().format(),
|
||||
end: dayjs(recurringDate).utc().add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
@@ -452,20 +464,21 @@ const BookingPage = ({
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
start: dayjs(date).tz(timeZone()).format(),
|
||||
end: dayjs(date).tz(timeZone()).add(duration, "minute").format(),
|
||||
...bookingValues,
|
||||
start: dayjs(date).utc().format(),
|
||||
end: dayjs(date).utc().add(duration, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
bookingUid: router.query.bookingUid as string,
|
||||
bookingUid: (router.query.bookingUid as string) || booking?.uid,
|
||||
user: router.query.user,
|
||||
metadata,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
ethSignature: gateState.rainbowToken,
|
||||
seatReferenceUid: router.query.seatReferenceUid as string,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -534,7 +547,7 @@ const BookingPage = ({
|
||||
<div className="text-sm font-medium">
|
||||
{isClientTimezoneAvailable &&
|
||||
(rescheduleUid || !eventType.recurringEvent?.freq) &&
|
||||
`${parseDateFunc(date)}`}
|
||||
`${parseDate(date, i18n, timeFormat)}`}
|
||||
{isClientTimezoneAvailable &&
|
||||
!rescheduleUid &&
|
||||
eventType.recurringEvent?.freq &&
|
||||
@@ -564,7 +577,7 @@ const BookingPage = ({
|
||||
<FiCalendar className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{isClientTimezoneAvailable &&
|
||||
typeof booking.startTime === "string" &&
|
||||
parseDateFunc(dayjs(booking.startTime))}
|
||||
parseDate(dayjs(booking.startTime), i18n, timeFormat)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -572,23 +585,27 @@ const BookingPage = ({
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<FiUser
|
||||
className={`ml-[2px] mt-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px] ${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
: currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
: currentSlotBooking &&
|
||||
currentSlotBooking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
} mb-2 font-medium`}>
|
||||
{booking
|
||||
? eventType.seatsPerTimeSlot - booking.attendees.length
|
||||
{currentSlotBooking
|
||||
? eventType.seatsPerTimeSlot - currentSlotBooking.attendees.length
|
||||
: eventType.seatsPerTimeSlot}{" "}
|
||||
/ {eventType.seatsPerTimeSlot} {t("seats_available")}
|
||||
</p>
|
||||
@@ -620,6 +637,7 @@ const BookingPage = ({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="dark:bg-darkmodebrand dark:hover:border-darkmodebrand dark:text-darkmodebrandcontrast bg-brand hover:border-brand text-brandcontrast"
|
||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||
loading={mutation.isLoading || recurringMutation.isLoading}>
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
@@ -633,6 +651,7 @@ const BookingPage = ({
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Toaster position="bottom-right" />
|
||||
</Gates>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Input } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui";
|
||||
import { FiMapPin } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -52,7 +53,7 @@ const LocationInput = (props: {
|
||||
const { control } = useFormContext() as typeof locationFormMethods;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
return (
|
||||
<input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
<Input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
return (
|
||||
@@ -171,7 +172,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
id="locationInput"
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
defaultValue={
|
||||
defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined
|
||||
|
||||
@@ -4,13 +4,15 @@ import type { OptionProps, SingleValueProps } from "react-select";
|
||||
import { components } from "react-select";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { NewScheduleButton } from "@calcom/features/schedules";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Badge, Button, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
|
||||
import { FiExternalLink, FiGlobe } from "@calcom/ui/components/icon";
|
||||
import { Badge, Button, Select, SettingsToggle, SkeletonText, EmptyScreen } from "@calcom/ui";
|
||||
import { FiExternalLink, FiGlobe, FiClock } from "@calcom/ui/components/icon";
|
||||
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
|
||||
@@ -52,23 +54,24 @@ const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||
|
||||
const AvailabilitySelect = ({
|
||||
className = "",
|
||||
isLoading,
|
||||
schedules,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
name: string;
|
||||
value: number;
|
||||
isLoading: boolean;
|
||||
schedules: RouterOutputs["viewer"]["availability"]["list"]["schedules"] | [];
|
||||
onBlur: () => void;
|
||||
onChange: (value: AvailabilityOption | null) => void;
|
||||
}) => {
|
||||
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
if (isLoading) {
|
||||
return <SelectSkeletonLoader />;
|
||||
}
|
||||
|
||||
const schedules = data?.schedules || [];
|
||||
|
||||
const options = schedules.map((schedule) => ({
|
||||
value: schedule.id,
|
||||
label: schedule.name,
|
||||
@@ -107,6 +110,7 @@ const EventTypeScheduleDetails = () => {
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
|
||||
const scheduleId = watch("schedule");
|
||||
|
||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
|
||||
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
|
||||
@@ -174,6 +178,19 @@ const EventTypeScheduleDetails = () => {
|
||||
|
||||
const EventTypeSchedule = () => {
|
||||
const { t } = useLocale();
|
||||
const { data: schedules, isLoading } = trpc.viewer.availability.list.useQuery();
|
||||
|
||||
if (!schedules?.schedules.length && !isLoading)
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon={FiClock}
|
||||
headline={t("new_schedule_heading")}
|
||||
description={t("new_schedule_description")}
|
||||
buttonRaw={<NewScheduleButton fromEventType />}
|
||||
border={false}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -183,14 +200,18 @@ const EventTypeSchedule = () => {
|
||||
<Controller
|
||||
name="schedule"
|
||||
render={({ field }) => (
|
||||
<AvailabilitySelect
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<AvailabilitySelect
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
schedules={schedules?.schedules || []}
|
||||
isLoading={isLoading}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -264,6 +264,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
defaultValue={!!eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
data-testid="offer-seats-toggle"
|
||||
title={t("offer_seats")}
|
||||
description={t("offer_seats_description")}
|
||||
checked={value}
|
||||
|
||||
@@ -88,6 +88,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="0"
|
||||
min={0}
|
||||
className="mb-0 h-[38px] rounded-[4px] ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<input type="hidden" ref={ref} {...passThroughProps} />
|
||||
@@ -424,7 +425,7 @@ const IntervalLimitItem = ({
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
containerClassName={`${textFieldSuffix ? "w-36" : "w-16"} -mb-1`}
|
||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||
placeholder={`${value}`}
|
||||
min={step}
|
||||
step={step}
|
||||
|
||||
@@ -198,8 +198,16 @@ export const EventSetupTab = (
|
||||
|
||||
return (
|
||||
<li
|
||||
onClick={() => {
|
||||
locationFormMethods.setValue("locationType", location.type);
|
||||
locationFormMethods.unregister("locationLink");
|
||||
locationFormMethods.unregister("locationAddress");
|
||||
locationFormMethods.unregister("locationPhoneNumber");
|
||||
setEditingLocationType(location.type);
|
||||
openLocationModal(location.type);
|
||||
}}
|
||||
key={`${location.type}${index}`}
|
||||
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2">
|
||||
className="mb-2 rounded-md border border-gray-300 py-1.5 px-2 hover:cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
|
||||
@@ -247,7 +247,7 @@ function EventTypeSingleLayout({
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
StartIcon={FiTrash}
|
||||
tooltip={t("delete")}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -22,23 +25,30 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { t } = useLocale();
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
||||
const telemetry = useTelemetry();
|
||||
const userSettingsSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
|
||||
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
|
||||
}),
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
} = useForm<z.infer<typeof userSettingsSchema>>({
|
||||
defaultValues: {
|
||||
name: user?.name || "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(userSettingsSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.event(telemetryEventTypes.onboardingStarted);
|
||||
}, [telemetry]);
|
||||
|
||||
const defaultOptions = { required: true, maxLength: 255 };
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const onSuccess = async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
@@ -67,7 +77,9 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
{t("full_name")}
|
||||
</label>
|
||||
<input
|
||||
{...register("name", defaultOptions)}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
})}
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
@@ -77,7 +89,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
/>
|
||||
{errors.name && (
|
||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||
{t("required")}
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,9 @@ export function CalendarSwitch(props: {
|
||||
/>
|
||||
{props.defaultSelected && (
|
||||
<Badge variant="gray">
|
||||
<FiArrowLeft className="mr-1" /> {t("adding_events_to")}
|
||||
<div className="flex">
|
||||
<FiArrowLeft className="mr-1" /> {t("adding_events_to")}
|
||||
</div>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import type { TeamPageProps } from "pages/team/[slug]";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
type TeamType = TeamPageProps["team"];
|
||||
type TeamType = NonNullable<TeamWithMembers>;
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number];
|
||||
|
||||
@@ -57,7 +57,7 @@ const Members = ({ members, teamName }: { members: MembersType; teamName: string
|
||||
);
|
||||
};
|
||||
|
||||
const Team = ({ team }: Omit<TeamPageProps, "trpcState">) => {
|
||||
const Team = ({ team }: { team: TeamType }) => {
|
||||
return (
|
||||
<div>
|
||||
<Members members={team.members} teamName={team.name} />
|
||||
|
||||
@@ -3,8 +3,7 @@ import { components } from "react-select";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
||||
export type LocationOption = {
|
||||
label: string;
|
||||
@@ -17,21 +16,11 @@ export type SingleValueLocationOption = SingleValue<LocationOption>;
|
||||
|
||||
export type GroupOptionType = GroupBase<LocationOption>;
|
||||
|
||||
const OptionWithIcon = ({
|
||||
icon,
|
||||
isSelected,
|
||||
label,
|
||||
}: {
|
||||
icon?: string;
|
||||
isSelected?: boolean;
|
||||
label: string;
|
||||
}) => {
|
||||
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5" />}
|
||||
<span className={classNames("text-sm font-medium", isSelected ? "text-white" : "text-gray-900")}>
|
||||
{label}
|
||||
</span>
|
||||
<span className={classNames("text-sm font-medium")}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -44,7 +33,7 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} isSelected={props.isSelected} />
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.Option>
|
||||
),
|
||||
SingleValue: (props) => (
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
@@ -58,6 +60,11 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
|
||||
return <I18nextAdapter {...passedProps} />;
|
||||
};
|
||||
|
||||
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
|
||||
const flags = useFlags();
|
||||
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
|
||||
}
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
const session = trpc.viewer.public.session.useQuery().data;
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
@@ -85,7 +92,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
storageKey={storageKey}
|
||||
forcedTheme={forcedTheme}
|
||||
attribute="class">
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
<FeatureFlagsProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
|
||||
type BookingSelect = {
|
||||
description: true;
|
||||
@@ -45,16 +44,14 @@ function getResponsesFromOldBooking(
|
||||
};
|
||||
}
|
||||
|
||||
async function getBooking(
|
||||
prisma: PrismaClient,
|
||||
uid: string,
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">
|
||||
) {
|
||||
async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
const rawBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
startTime: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
@@ -65,6 +62,12 @@ async function getBooking(
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -74,9 +77,7 @@ async function getBooking(
|
||||
return rawBooking;
|
||||
}
|
||||
|
||||
const booking = getBookingWithResponses(rawBooking, {
|
||||
bookingFields,
|
||||
});
|
||||
const booking = getBookingWithResponses(rawBooking);
|
||||
|
||||
if (booking) {
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
@@ -96,16 +97,11 @@ export const getBookingWithResponses = <
|
||||
};
|
||||
}>
|
||||
>(
|
||||
booking: T,
|
||||
eventType: {
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
|
||||
}
|
||||
booking: T
|
||||
) => {
|
||||
return {
|
||||
...booking,
|
||||
responses: getBookingResponsesPartialSchema({
|
||||
bookingFields: eventType.bookingFields,
|
||||
}).parse(booking.responses || getResponsesFromOldBooking(booking)),
|
||||
};
|
||||
responses: bookingResponsesDbSchema.parse(booking.responses || getResponsesFromOldBooking(booking)),
|
||||
} as Omit<T, "responses"> & { responses: z.infer<typeof bookingResponsesDbSchema> };
|
||||
};
|
||||
export default getBooking;
|
||||
|
||||
@@ -3,23 +3,22 @@ import { RRule } from "rrule";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { detectBrowserTimeFormat, TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { parseZone } from "./parseZone";
|
||||
|
||||
const processDate = (date: string | null | Dayjs, i18n: I18n, withDefaultTimeFormat: boolean) => {
|
||||
const processDate = (date: string | null | Dayjs, i18n: I18n, selectedTimeFormat?: TimeFormat) => {
|
||||
const parsedZone = parseZone(date);
|
||||
if (!parsedZone?.isValid()) return "Invalid date";
|
||||
const formattedTime = parsedZone?.format(
|
||||
withDefaultTimeFormat ? TimeFormat.TWELVE_HOUR : detectBrowserTimeFormat
|
||||
);
|
||||
const formattedTime = parsedZone?.format(selectedTimeFormat || detectBrowserTimeFormat);
|
||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
||||
};
|
||||
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n, withDefaultTimeFormat: boolean) => {
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n, selectedTimeFormat?: TimeFormat) => {
|
||||
if (!date) return ["No date"];
|
||||
return processDate(date, i18n, withDefaultTimeFormat);
|
||||
return processDate(date, i18n, selectedTimeFormat);
|
||||
};
|
||||
|
||||
export const parseRecurringDates = (
|
||||
@@ -28,13 +27,13 @@ export const parseRecurringDates = (
|
||||
timeZone,
|
||||
recurringEvent,
|
||||
recurringCount,
|
||||
withDefaultTimeFormat,
|
||||
selectedTimeFormat,
|
||||
}: {
|
||||
startDate: string | null | Dayjs;
|
||||
timeZone?: string;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
recurringCount: number;
|
||||
withDefaultTimeFormat: boolean;
|
||||
selectedTimeFormat?: TimeFormat;
|
||||
},
|
||||
i18n: I18n
|
||||
): [string[], Date[]] => {
|
||||
@@ -54,7 +53,7 @@ export const parseRecurringDates = (
|
||||
});
|
||||
const dateStrings = times.map((t) => {
|
||||
// finally; show in local timeZone again
|
||||
return processDate(t.tz(timeZone), i18n, withDefaultTimeFormat);
|
||||
return processDate(t.tz(timeZone), i18n, selectedTimeFormat);
|
||||
});
|
||||
|
||||
return [dateStrings, times.map((t) => t.toDate())];
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import type { Attendee, Booking } from "@prisma/client";
|
||||
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
paymentUid?: string;
|
||||
attendees: Attendee[];
|
||||
appsStatus?: AppsStatus[];
|
||||
};
|
||||
export type BookingResponse = Awaited<
|
||||
ReturnType<typeof import("@calcom/features/bookings/lib/handleNewBooking").default>
|
||||
>;
|
||||
|
||||
@@ -99,6 +99,14 @@ const nextConfig = {
|
||||
skipDefaultConversion: true,
|
||||
preventFullImport: true,
|
||||
},
|
||||
"@calcom/features/insights/components": {
|
||||
transform: "@calcom/features/insights/components/{{member}}",
|
||||
skipDefaultConversion: true,
|
||||
preventFullImport: true,
|
||||
},
|
||||
lodash: {
|
||||
transform: "lodash/{{member}}",
|
||||
},
|
||||
// TODO: We need to have all components in `@calcom/ui/components` in order to use this
|
||||
// "@calcom/ui": {
|
||||
// transform: "@calcom/ui/components/{{member}}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.6.12",
|
||||
"version": "2.7.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -19,18 +19,18 @@
|
||||
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": "16.x || 18.x",
|
||||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
"node": "18",
|
||||
"yarn": "3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "1.7.1",
|
||||
"@boxyhq/saml-jackson": "1.8.2",
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/app-store-cli": "*",
|
||||
"@calcom/core": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/embed-core": "*",
|
||||
"@calcom/embed-react": "*",
|
||||
"@calcom/embed-snippet": "*",
|
||||
"@calcom/embed-core": "workspace:*",
|
||||
"@calcom/embed-react": "workspace:*",
|
||||
"@calcom/embed-snippet": "workspace:*",
|
||||
"@calcom/features": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
@@ -60,6 +60,7 @@
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^4.3.9",
|
||||
"@tremor/react": "^2.0.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@vercel/edge-config": "^0.1.1",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
@@ -69,6 +70,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"classnames": "^2.3.1",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"entities": "^4.4.0",
|
||||
"eslint-config-next": "^13.2.1",
|
||||
"googleapis": "^84.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -110,8 +112,8 @@
|
||||
"react-multi-email": "^0.5.3",
|
||||
"react-phone-number-input": "^3.2.7",
|
||||
"react-schemaorg": "^2.0.0",
|
||||
"react-select": "^5.4.0",
|
||||
"react-timezone-select": "^1.3.2",
|
||||
"react-select": "^5.7.0",
|
||||
"react-timezone-select": "^1.4.0",
|
||||
"react-use-intercom": "1.5.1",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.7",
|
||||
@@ -133,7 +135,7 @@
|
||||
"@calcom/config": "*",
|
||||
"@calcom/types": "*",
|
||||
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
|
||||
"@playwright/test": "^1.25.0",
|
||||
"@playwright/test": "^1.31.2",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@types/accept-language-parser": "1.5.2",
|
||||
"@types/async": "^3.2.15",
|
||||
@@ -161,7 +163,6 @@
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"detect-port": "^1.3.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"eslint": "^8.34.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"module-alias": "^2.2.2",
|
||||
"msw": "^0.42.3",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import {
|
||||
@@ -12,15 +14,13 @@ import {
|
||||
getUsernameList,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { bookEventTypeSelect } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import prisma, { bookEventTypeSelect } from "@calcom/prisma";
|
||||
import {
|
||||
customInputSchema,
|
||||
EventTypeMetaDataSchema,
|
||||
userMetadata as userMetadataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { GetBookingType } from "@lib/getBooking";
|
||||
import getBooking from "@lib/getBooking";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -70,11 +70,26 @@ export default function Book(props: BookPageProps) {
|
||||
|
||||
Book.isThemeSupported = true;
|
||||
|
||||
const querySchema = z.object({
|
||||
bookingUid: z.string().optional(),
|
||||
count: z.coerce.number().optional(),
|
||||
embed: z.string().optional(),
|
||||
rescheduleUid: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
/** This is the event "type" ID */
|
||||
type: z.coerce.number().optional(),
|
||||
user: z.string(),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
duration: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
|
||||
const eventTypeSlug = context.query.slug as string;
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
const query = querySchema.parse(context.query);
|
||||
const usernameList = getUsernameList(query.user);
|
||||
const eventTypeSlug = query.slug;
|
||||
const recurringEventCountQuery = query.count;
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
@@ -99,24 +114,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
if (!users.length) return { notFound: true };
|
||||
const [user] = users;
|
||||
const isDynamicGroupBooking = users.length > 1;
|
||||
const isDynamicGroupBooking = users.length > 1 && !!eventTypeSlug;
|
||||
|
||||
// Dynamic Group link doesn't need a type but it must have a slug
|
||||
if ((!isDynamicGroupBooking && !context.query.type) || (isDynamicGroupBooking && !eventTypeSlug)) {
|
||||
if ((!isDynamicGroupBooking && !query.type) || (users.length > 1 && !eventTypeSlug)) {
|
||||
return { notFound: true };
|
||||
}
|
||||
|
||||
const eventTypeRaw =
|
||||
usernameList.length > 1
|
||||
? getDefaultEvent(eventTypeSlug)
|
||||
: await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: parseInt(asStringOrThrow(context.query.type)),
|
||||
},
|
||||
select: {
|
||||
...bookEventTypeSelect,
|
||||
},
|
||||
});
|
||||
const eventTypeRaw = isDynamicGroupBooking
|
||||
? getDefaultEvent(eventTypeSlug)
|
||||
: await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: query.type,
|
||||
},
|
||||
select: {
|
||||
...bookEventTypeSelect,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
const eventType = {
|
||||
@@ -178,22 +192,66 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
})[0];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (context.query.rescheduleUid || context.query.bookingUid) {
|
||||
booking = await getBooking(
|
||||
prisma,
|
||||
context.query.rescheduleUid
|
||||
? (context.query.rescheduleUid as string)
|
||||
: (context.query.bookingUid as string),
|
||||
eventTypeObject.bookingFields
|
||||
);
|
||||
// If rescheduleUid and event has seats lets convert Uid to bookingUid
|
||||
let rescheduleUid = query.rescheduleUid;
|
||||
const rescheduleEventTypeHasSeats = query.rescheduleUid && eventTypeRaw.seatsPerTimeSlot;
|
||||
let attendeeEmail: string;
|
||||
let bookingUidWithSeats: string | null = null;
|
||||
|
||||
if (rescheduleEventTypeHasSeats) {
|
||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
||||
where: {
|
||||
referenceUid: query.rescheduleUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attendee: true,
|
||||
booking: {
|
||||
select: {
|
||||
uid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
attendeeEmail = bookingSeat.attendee.email;
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicNames = isDynamicGroupBooking
|
||||
? users.map((user) => {
|
||||
return user.name || "";
|
||||
})
|
||||
: [];
|
||||
if (query.duration) {
|
||||
// If it's not reschedule but event Type has seats we should obtain
|
||||
// the bookingUid regardless and use it to get the booking
|
||||
const currentSeats = await prisma.booking.findFirst({
|
||||
where: {
|
||||
eventTypeId: eventTypeRaw.id,
|
||||
startTime: dayjs(query.date).toISOString(),
|
||||
endTime: dayjs(query.date).add(query.duration, "minutes").toISOString(),
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
},
|
||||
});
|
||||
if (currentSeats && currentSeats) {
|
||||
bookingUidWithSeats = currentSeats.uid;
|
||||
}
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid || query.bookingUid || bookingUidWithSeats) {
|
||||
booking = await getBooking(prisma, rescheduleUid || query.bookingUid || bookingUidWithSeats || "");
|
||||
}
|
||||
|
||||
if (rescheduleEventTypeHasSeats && booking?.attendees && booking?.attendees.length > 0) {
|
||||
const currentAttendee = booking?.attendees.find((attendee) => {
|
||||
return attendee.email === attendeeEmail;
|
||||
});
|
||||
if (currentAttendee) {
|
||||
booking.attendees = [currentAttendee] || [];
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicNames = isDynamicGroupBooking ? users.map((user) => user.name || "") : [];
|
||||
|
||||
const profile = isDynamicGroupBooking
|
||||
? {
|
||||
@@ -203,9 +261,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
theme: null,
|
||||
brandColor: "",
|
||||
darkBrandColor: "",
|
||||
allowDynamicBooking: !users.some((user) => {
|
||||
return !user.allowDynamicBooking;
|
||||
}),
|
||||
allowDynamicBooking: !users.some((user) => !user.allowDynamicBooking),
|
||||
eventName: getDynamicEventName(dynamicNames, eventTypeSlug),
|
||||
}
|
||||
: {
|
||||
@@ -222,23 +278,26 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const recurringEventCount =
|
||||
(eventType.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
|
||||
? parseInt(recurringEventCountQuery)
|
||||
(recurringEventCountQuery <= eventType.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
const currentSlotBooking = await getBooking(prisma, bookingUidWithSeats || "");
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
profile,
|
||||
eventType: eventTypeObject,
|
||||
booking,
|
||||
currentSlotBooking: currentSlotBooking,
|
||||
recurringEventCount,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
isEmbed: typeof context.query.embed === "string",
|
||||
isEmbed: !!query.embed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import type { ResetPasswordRequest } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendPasswordResetEmail } from "@calcom/emails";
|
||||
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
|
||||
import rateLimit from "@calcom/lib/rateLimit";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const limiter = rateLimit({
|
||||
intervalInMs: 60 * 1000, // 1 minute
|
||||
});
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const t = await getTranslation(req.body.language ?? "en", "common");
|
||||
const email = z
|
||||
.string()
|
||||
.email()
|
||||
.transform((val) => val.toLowerCase())
|
||||
.parse(req.body?.email);
|
||||
|
||||
const { isRateLimited } = limiter.check(10, email); // 10 requests per minute
|
||||
|
||||
if (isRateLimited) {
|
||||
return res.status(429).json({ message: "Too Many Requests." });
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).end();
|
||||
@@ -17,7 +34,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
try {
|
||||
const maybeUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: req.body?.email?.toLowerCase(),
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
|
||||
@@ -60,7 +60,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
|
||||
res.write(
|
||||
renderEmail("OrganizerScheduledEmail", {
|
||||
renderEmail("OrganizerRequestEmail", {
|
||||
calEvent: evt,
|
||||
attendee: evt.organizer,
|
||||
})
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ImageResponse } from "@vercel/og";
|
||||
import type { NextApiRequest } from "next";
|
||||
import { remark } from "remark";
|
||||
import type { SatoriOptions } from "satori";
|
||||
import strip from "strip-markdown";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
|
||||
const calFont = fetch(new URL("../../../../public/fonts/cal.ttf", import.meta.url)).then((res) =>
|
||||
res.arrayBuffer()
|
||||
@@ -75,10 +74,13 @@ export default async function handler(req: NextApiRequest) {
|
||||
meetingImage: searchParams.get("meetingImage"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const title_ = md.render(title).replace(/(<([^>]+)>)/gi, "");
|
||||
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
title={title}
|
||||
title={title_}
|
||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||
/>
|
||||
@@ -108,8 +110,8 @@ export default async function handler(req: NextApiRequest) {
|
||||
description: searchParams.get("description"),
|
||||
imageType,
|
||||
});
|
||||
const description_ = await (await remark().use(strip).process(description)).toString();
|
||||
const img = new ImageResponse(<Generic title={title} description={description_} />, ogConfig) as {
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig) as {
|
||||
body: Buffer;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import matter from "gray-matter";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { GetStaticPaths, GetStaticPropsContext } from "next";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -13,6 +14,23 @@ import App from "@components/apps/App";
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true });
|
||||
|
||||
const sourceSchema = z.object({
|
||||
content: z.string(),
|
||||
data: z.object({
|
||||
description: z.string().optional(),
|
||||
items: z
|
||||
.array(
|
||||
z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
iframe: z.object({ src: z.string() }),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
|
||||
return (
|
||||
<App
|
||||
@@ -33,7 +51,7 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
|
||||
email={data.email}
|
||||
licenseRequired={data.licenseRequired}
|
||||
isProOnly={data.isProOnly}
|
||||
images={source.data?.items as string[] | undefined}
|
||||
descriptionItems={source.data?.items as string[] | undefined}
|
||||
isTemplate={data.isTemplate}
|
||||
dependencies={data.dependencies}
|
||||
// tos="https://zoom.us/terms"
|
||||
@@ -85,10 +103,11 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
source = singleApp.description;
|
||||
}
|
||||
|
||||
const { content, data } = matter(source);
|
||||
const result = matter(source);
|
||||
const { content, data } = sourceSchema.parse({ content: result.content, data: result.data });
|
||||
if (data.items) {
|
||||
data.items = data.items.map((item: string) => {
|
||||
if (!item.includes("/api/app-store")) {
|
||||
data.items = data.items.map((item) => {
|
||||
if (typeof item === "string" && !item.includes("/api/app-store")) {
|
||||
// Make relative paths absolute
|
||||
return `/api/app-store/${appDirname}/${item}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ResetPasswordRequest } from "@prisma/client";
|
||||
import debounce from "lodash/debounce";
|
||||
import { debounce } from "lodash";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken } from "next-auth/react";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
@@ -22,7 +22,7 @@ type Props = {
|
||||
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||
const { t } = useLocale();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<{ message: string } | null>(null);
|
||||
const [, setError] = React.useState<{ message: string } | null>(null);
|
||||
const [success, setSuccess] = React.useState(false);
|
||||
|
||||
const [password, setPassword] = React.useState("");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { debounce } from "lodash";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken } from "next-auth/react";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function Logout(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button href="/auth/login" passHref className="flex w-full justify-center">
|
||||
<Button href="/auth/login" className="flex w-full justify-center">
|
||||
{t("go_back_login")}
|
||||
</Button>
|
||||
</AuthContainer>
|
||||
|
||||
@@ -94,6 +94,7 @@ export default function Availability() {
|
||||
data: { schedule: scheduleId },
|
||||
} = useTypedQuery(querySchema);
|
||||
|
||||
const { fromEventType } = router.query;
|
||||
const { timeFormat } = me.data || { timeFormat: null };
|
||||
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId },
|
||||
@@ -153,7 +154,7 @@ export default function Availability() {
|
||||
|
||||
return (
|
||||
<Shell
|
||||
backPath="/availability"
|
||||
backPath={fromEventType ? true : "/availability"}
|
||||
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
|
||||
heading={
|
||||
<Controller
|
||||
@@ -200,7 +201,14 @@ export default function Availability() {
|
||||
<VerticalDivider />
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button StartIcon={FiTrash} variant="icon" color="destructive" aria-label={t("delete")} />
|
||||
<Button
|
||||
StartIcon={FiTrash}
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
aria-label={t("delete")}
|
||||
disabled={schedule?.isLastSchedule}
|
||||
tooltip={t("requires_at_least_one_schedule")}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
@@ -223,7 +231,7 @@ export default function Availability() {
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="w-full">
|
||||
<div className="mt-4 w-full md:mt-0">
|
||||
<Form
|
||||
form={form}
|
||||
id="availability-form"
|
||||
|
||||
@@ -93,7 +93,13 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
updateDefault={updateMutation.mutate}
|
||||
deleteFunction={deleteMutation.mutate}
|
||||
deleteFunction={() => {
|
||||
if (schedules.length === 1) {
|
||||
showToast(t("requires_at_least_one_schedule"), "error");
|
||||
} else {
|
||||
deleteMutation.mutate;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
@@ -46,7 +47,7 @@ import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Badge, Button, EmailInput, HeadSeo } from "@calcom/ui";
|
||||
import { Button, EmailInput, HeadSeo, Badge } from "@calcom/ui";
|
||||
import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
@@ -179,9 +180,10 @@ const querySchema = z.object({
|
||||
cancel: stringToBoolean,
|
||||
changes: stringToBoolean,
|
||||
reschedule: stringToBoolean,
|
||||
isSuccessBookingPage: z.string().optional(),
|
||||
isSuccessBookingPage: stringToBoolean,
|
||||
formerTime: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export default function Success(props: SuccessProps) {
|
||||
@@ -194,11 +196,9 @@ export default function Success(props: SuccessProps) {
|
||||
changes,
|
||||
formerTime,
|
||||
email,
|
||||
seatReferenceUid,
|
||||
} = querySchema.parse(router.query);
|
||||
|
||||
if ((isCancellationMode || changes) && typeof window !== "undefined") {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
const tz =
|
||||
(isSuccessBookingPage
|
||||
? props.bookingInfo.attendees.find((attendee) => attendee.email === email)?.timeZone
|
||||
@@ -210,10 +210,6 @@ export default function Success(props: SuccessProps) {
|
||||
props?.bookingInfo?.metadata || {}
|
||||
)?.videoCallUrl;
|
||||
|
||||
if (!location) {
|
||||
// Can't use logger.error because it throws error on client. stdout isn't available to it.
|
||||
console.error(`No location found `);
|
||||
}
|
||||
const status = props.bookingInfo?.status;
|
||||
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
|
||||
const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason;
|
||||
@@ -236,12 +232,24 @@ export default function Success(props: SuccessProps) {
|
||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||
|
||||
function setIsCancellationMode(value: boolean) {
|
||||
if (value) router.query.cancel = "true";
|
||||
else delete router.query.cancel;
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query },
|
||||
});
|
||||
const query_ = { ...router.query };
|
||||
|
||||
if (value) {
|
||||
query_.cancel = "true";
|
||||
} else {
|
||||
if (query_.cancel) {
|
||||
delete query_.cancel;
|
||||
}
|
||||
}
|
||||
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: { ...query_ },
|
||||
},
|
||||
undefined,
|
||||
{ scroll: false }
|
||||
);
|
||||
}
|
||||
|
||||
const eventNameObject = {
|
||||
@@ -258,8 +266,19 @@ export default function Success(props: SuccessProps) {
|
||||
const giphyImage = giphyAppData?.thankYouPage;
|
||||
|
||||
const eventName = getEventName(eventNameObject, true);
|
||||
const needsConfirmation = eventType.requiresConfirmation && reschedule != true;
|
||||
const isCancelled = status === "CANCELLED" || status === "REJECTED";
|
||||
// Confirmation can be needed in two cases as of now
|
||||
// - Event Type has require confirmation option enabled always
|
||||
// - EventType has conditionally enabled confirmation option based on how far the booking is scheduled.
|
||||
// - It's a paid event and payment is pending.
|
||||
const needsConfirmation = bookingInfo.status === BookingStatus.PENDING;
|
||||
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
|
||||
|
||||
const isCancelled =
|
||||
status === "CANCELLED" ||
|
||||
status === "REJECTED" ||
|
||||
(!!seatReferenceUid &&
|
||||
!bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid));
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
@@ -343,7 +362,6 @@ export default function Success(props: SuccessProps) {
|
||||
return t("emailed_you_and_attendees" + titleSuffix);
|
||||
}
|
||||
|
||||
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
|
||||
useTheme(isSuccessBookingPage ? props.profile.theme : "light");
|
||||
const title = t(
|
||||
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
|
||||
@@ -417,7 +435,7 @@ export default function Success(props: SuccessProps) {
|
||||
: "",
|
||||
isCancelled ? "h-12 w-12 rounded-full bg-red-100" : ""
|
||||
)}>
|
||||
{giphyImage && !needsConfirmation && (
|
||||
{giphyImage && !needsConfirmation && !isCancelled && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={giphyImage} alt="Gif from Giphy" />
|
||||
)}
|
||||
@@ -437,7 +455,9 @@ export default function Success(props: SuccessProps) {
|
||||
? t("submitted_recurring")
|
||||
: t("submitted")
|
||||
: isCancelled
|
||||
? t("event_cancelled")
|
||||
? seatReferenceUid
|
||||
? t("no_longer_attending")
|
||||
: t("event_cancelled")
|
||||
: props.recurringBookings
|
||||
? t("meeting_is_scheduled_recurring")
|
||||
: t("meeting_is_scheduled")}
|
||||
@@ -469,7 +489,7 @@ export default function Success(props: SuccessProps) {
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{props.bookingInfo.title}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
{reschedule && !!formerTime && (
|
||||
@@ -501,25 +521,23 @@ export default function Success(props: SuccessProps) {
|
||||
<>
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2 last:mb-0">
|
||||
<>
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<p>
|
||||
<span className="mr-2">{bookingInfo.user.name}</span>
|
||||
<Badge variant="blue" bold>
|
||||
{t("Host")}
|
||||
</Badge>
|
||||
</p>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<span className="mr-2">{bookingInfo.user.name}</span>
|
||||
<Badge variant="blue">{t("Host")}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee) => (
|
||||
<div key={attendee.name} className="mb-3 last:mb-0">
|
||||
{attendee.name && <p>{attendee.name}</p>}
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee) => (
|
||||
<div key={attendee.name + attendee.email} className="mb-3 last:mb-0">
|
||||
{attendee.name && <p>{attendee.name}</p>}
|
||||
<p data-testid={`attendee-${attendee.email}`} className="text-bookinglight">
|
||||
{attendee.email}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -592,8 +610,10 @@ export default function Success(props: SuccessProps) {
|
||||
|
||||
{!props.recurringBookings && (
|
||||
<span className="text-bookinglight inline text-gray-700">
|
||||
<span className="underline">
|
||||
<Link href={`/reschedule/${bookingInfo?.uid}`} legacyBehavior>
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
</span>
|
||||
@@ -623,6 +643,7 @@ export default function Success(props: SuccessProps) {
|
||||
setIsCancellationMode={setIsCancellationMode}
|
||||
theme={isSuccessBookingPage ? props.profile.theme : "light"}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
seatReferenceUid={seatReferenceUid}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
@@ -630,7 +651,7 @@ export default function Success(props: SuccessProps) {
|
||||
!needsConfirmation &&
|
||||
!isCancellationMode &&
|
||||
!isCancelled &&
|
||||
calculatedDuration && (
|
||||
!!calculatedDuration && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
|
||||
@@ -738,6 +759,7 @@ export default function Success(props: SuccessProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{session === null && !(userIsOwner || props.hideBranding) && (
|
||||
<>
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300 mt-8" />
|
||||
@@ -1005,7 +1027,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const bookingInfoRaw = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
uid: await maybeGetBookingUidFromSeat(prisma, uid),
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
@@ -1047,6 +1069,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
seatsReferences: {
|
||||
select: {
|
||||
referenceUid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!bookingInfoRaw) {
|
||||
@@ -1064,8 +1091,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
const bookingInfo = getBookingWithResponses(bookingInfoRaw, eventTypeRaw);
|
||||
|
||||
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
|
||||
@@ -1116,19 +1142,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
handleSeatsEventTypeOnBooking(eventType, bookingInfo, email);
|
||||
}
|
||||
|
||||
let recurringBookings = null;
|
||||
if (bookingInfo.recurringEventId) {
|
||||
// We need to get the dates for the bookings to be able to show them in the UI
|
||||
recurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: bookingInfo.recurringEventId,
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
bookingId: bookingInfo.id,
|
||||
@@ -1138,21 +1151,30 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
refunded: true,
|
||||
},
|
||||
});
|
||||
const paymentStatus = {
|
||||
success: payment?.success,
|
||||
refunded: payment?.refunded,
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
|
||||
profile,
|
||||
eventType,
|
||||
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
|
||||
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
||||
trpcState: ssr.dehydrate(),
|
||||
dynamicEventName: bookingInfo?.eventType?.eventName || "",
|
||||
bookingInfo,
|
||||
paymentStatus: payment ? paymentStatus : null,
|
||||
paymentStatus: payment,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getRecurringBookings(recurringEventId: string | null) {
|
||||
if (!recurringEventId) return null;
|
||||
const recurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId,
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
},
|
||||
});
|
||||
return recurringBookings.map((obj) => obj.startTime.toString());
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
profile,
|
||||
eventType: eventTypeObject,
|
||||
booking: null,
|
||||
currentSlotBooking: null,
|
||||
trpcState: ssr.dehydrate(),
|
||||
recurringEventCount,
|
||||
isDynamicGroupBooking: false,
|
||||
|
||||
@@ -351,11 +351,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
|
||||
{type.hidden && (
|
||||
<Badge variant="gray" size="lg">
|
||||
{t("hidden")}
|
||||
</Badge>
|
||||
)}
|
||||
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
|
||||
<Tooltip content={t("show_eventtype_on_profile")}>
|
||||
<div className="self-center rounded-md p-2 hover:bg-gray-200">
|
||||
<Switch
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
BookingStatusLineChart,
|
||||
LeastBookedTeamMembersTable,
|
||||
MostBookedTeamMembersTable,
|
||||
PopularEventsTable,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { useFilterContext } from "@calcom/features/insights/context/provider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, ButtonGroup } from "@calcom/ui";
|
||||
import { FiRefreshCcw, FiUserPlus, FiUsers } from "@calcom/ui/components/icon";
|
||||
|
||||
const Heading = () => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
filter: { selectedTeamName },
|
||||
} = useFilterContext();
|
||||
return (
|
||||
<div className="min-w-52">
|
||||
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 hidden truncate text-xl font-semibold tracking-wide text-black md:block xl:max-w-full">
|
||||
{t("analytics_for_organisation", {
|
||||
organisationName: selectedTeamName,
|
||||
})}
|
||||
</h3>
|
||||
<p className="hidden text-sm text-gray-500 md:block">{t("subtitle_analytics")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function InsightsPage() {
|
||||
const { t } = useLocale();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const features = [
|
||||
{
|
||||
icon: <FiUsers className="h-5 w-5 text-red-500" />,
|
||||
title: t("view_bookings_across"),
|
||||
description: t("view_bookings_across_description"),
|
||||
},
|
||||
{
|
||||
icon: <FiRefreshCcw className="h-5 w-5 text-blue-500" />,
|
||||
title: t("identify_booking_trends"),
|
||||
description: t("identify_booking_trends_description"),
|
||||
},
|
||||
{
|
||||
icon: <FiUserPlus className="h-5 w-5 text-green-500" />,
|
||||
title: t("spot_popular_event_types"),
|
||||
description: t("spot_popular_event_types_description"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell>
|
||||
<UpgradeTip
|
||||
title={t("make_informed_decisions")}
|
||||
description={t("make_informed_decisions_description")}
|
||||
features={features}
|
||||
background="/banners/insights.jpg"
|
||||
buttons={
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
<Button color="secondary" href="https://go.cal.com/insights" target="_blank">
|
||||
{t("learn_more")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
}>
|
||||
{!user ? (
|
||||
<></>
|
||||
) : (
|
||||
<FiltersProvider>
|
||||
<div className="mb-4 mt-0 ml-auto flex w-full flex-wrap justify-between md:-mt-8">
|
||||
<Heading />
|
||||
<Filters />
|
||||
</div>
|
||||
<div className="mb-4 space-y-6">
|
||||
<BookingKPICards />
|
||||
|
||||
<BookingStatusLineChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<PopularEventsTable />
|
||||
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<MostBookedTeamMembersTable />
|
||||
<LeastBookedTeamMembersTable />
|
||||
</div>
|
||||
<small className="block text-center text-gray-600">
|
||||
{t("looking_for_more_analytics")}
|
||||
<a
|
||||
className="text-blue-500 hover:underline"
|
||||
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
|
||||
{" "}
|
||||
{t("contact_support")}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</FiltersProvider>
|
||||
)}
|
||||
</UpgradeTip>
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,28 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { URLSearchParams } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
|
||||
import { asStringOrUndefined } from "@lib/asStringOrNull";
|
||||
|
||||
export default function Type() {
|
||||
// Just redirect to the schedule page to reschedule it.
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { uid: bookingId } = z
|
||||
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
|
||||
.parse(context.query);
|
||||
let seatReferenceUid;
|
||||
const uid = await maybeGetBookingUidFromSeat(prisma, bookingId);
|
||||
if (uid) {
|
||||
seatReferenceUid = bookingId;
|
||||
}
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: asStringOrUndefined(context.query.uid),
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
@@ -42,9 +51,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!booking) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
notFound: true;
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
|
||||
@@ -66,9 +73,16 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
: booking.user?.username || "rick") /* This shouldn't happen */ +
|
||||
"/" +
|
||||
eventType?.slug;
|
||||
const destinationUrl = new URLSearchParams();
|
||||
if (seatReferenceUid) {
|
||||
destinationUrl.set("rescheduleUid", seatReferenceUid);
|
||||
} else {
|
||||
destinationUrl.set("rescheduleUid", bookingId);
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/" + eventPage + "?rescheduleUid=" + context.query.uid,
|
||||
destination: `/${eventPage}?${destinationUrl.toString()}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
|
||||
|
||||
import { getLayout } from "@components/auth/layouts/AdminLayout";
|
||||
|
||||
const FlagsPage = () => <FlagListingView />;
|
||||
|
||||
FlagsPage.getLayout = getLayout;
|
||||
|
||||
export default FlagsPage;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { HelpScout, useChat } from "react-live-chat-loader";
|
||||
|
||||
import { useIntercom } from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
@@ -33,15 +32,13 @@ const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
|
||||
|
||||
const BillingView = () => {
|
||||
const { t } = useLocale();
|
||||
const [, loadChat] = useChat();
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const { open } = useIntercom();
|
||||
const router = useRouter();
|
||||
const returnTo = router.asPath;
|
||||
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
|
||||
|
||||
const onContactSupportClick = () => {
|
||||
setShowChat(true);
|
||||
loadChat({ open: true });
|
||||
open();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -61,7 +58,6 @@ const BillingView = () => {
|
||||
{t("contact_support")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
{showChat && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import { signOut } from "next-auth/react";
|
||||
import type { BaseSyntheticEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
@@ -315,13 +317,23 @@ const ProfileForm = ({
|
||||
extraField?: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const emailMd5 = crypto
|
||||
.createHash("md5")
|
||||
.update(defaultValues.email || "example@example.com")
|
||||
.digest("hex");
|
||||
|
||||
const profileFormSchema = z.object({
|
||||
username: z.string(),
|
||||
avatar: z.string(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(FULL_NAME_LENGTH_MAX_LIMIT, {
|
||||
message: t("max_limit_allowed_hint", { limit: FULL_NAME_LENGTH_MAX_LIMIT }),
|
||||
}),
|
||||
email: z.string().email(),
|
||||
bio: z.string(),
|
||||
});
|
||||
|
||||
const formMethods = useForm<FormValues>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(profileFormSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -338,7 +350,7 @@ const ProfileForm = ({
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Avatar alt="" imageSrc={value} gravatarFallbackMd5={emailMd5} size="lg" />
|
||||
<Avatar alt="" imageSrc={value} gravatarFallbackMd5="fallback" size="lg" />
|
||||
<div className="ltr:ml-4 rtl:mr-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
|
||||
@@ -21,7 +21,8 @@ const PasswordView = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const metadata = userMetadata.parse(user?.metadata);
|
||||
const metadata = userMetadata.safeParse(user?.metadata);
|
||||
const sessionTimeout = metadata.success ? metadata.data?.sessionTimeout : undefined;
|
||||
|
||||
const sessionMutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -36,10 +37,10 @@ const PasswordView = () => {
|
||||
const previousValue = utils.viewer.me.getData();
|
||||
const previousMetadata = userMetadata.parse(previousValue?.metadata);
|
||||
|
||||
if (previousValue && metadata?.sessionTimeout) {
|
||||
if (previousValue && sessionTimeout) {
|
||||
utils.viewer.me.setData(undefined, {
|
||||
...previousValue,
|
||||
metadata: { ...previousMetadata, sessionTimeout: metadata?.sessionTimeout },
|
||||
metadata: { ...previousMetadata, sessionTimeout: sessionTimeout },
|
||||
});
|
||||
}
|
||||
return { previousValue };
|
||||
@@ -81,19 +82,19 @@ const PasswordView = () => {
|
||||
defaultValues: {
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
sessionTimeout: metadata?.sessionTimeout,
|
||||
sessionTimeout,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionTimeoutWatch = formMethods.watch("sessionTimeout");
|
||||
|
||||
const handleSubmit = (values: ChangePasswordSessionFormValues) => {
|
||||
const { oldPassword, newPassword, sessionTimeout } = values;
|
||||
const { oldPassword, newPassword, sessionTimeout: newSessionTimeout } = values;
|
||||
if (oldPassword && newPassword) {
|
||||
passwordMutation.mutate({ oldPassword, newPassword });
|
||||
}
|
||||
if (metadata?.sessionTimeout !== sessionTimeout) {
|
||||
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout } });
|
||||
if (sessionTimeout !== newSessionTimeout) {
|
||||
sessionMutation.mutate({ metadata: { ...metadata, sessionTimeout: newSessionTimeout } });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,6 +107,7 @@ const PasswordView = () => {
|
||||
|
||||
const passwordMinLength = data?.user.role === "USER" ? 7 : 15;
|
||||
const isUser = data?.user.role === "USER";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("password")} description={t("password_description")} />
|
||||
@@ -176,8 +178,8 @@ const PasswordView = () => {
|
||||
<Select
|
||||
options={timeoutOptions}
|
||||
defaultValue={
|
||||
metadata?.sessionTimeout
|
||||
? timeoutOptions.find((tmo) => tmo.value === metadata.sessionTimeout)
|
||||
sessionTimeout
|
||||
? timeoutOptions.find((tmo) => tmo.value === sessionTimeout)
|
||||
: timeoutOptions[1]
|
||||
}
|
||||
isSearchable={false}
|
||||
|
||||
@@ -13,7 +13,8 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { Avatar, Button, HeadSeo, AvatarGroup } from "@calcom/ui";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Avatar, AvatarGroup, Button, EmptyScreen, HeadSeo } from "@calcom/ui";
|
||||
import { FiArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
@@ -24,13 +25,15 @@ import Team from "@components/team/screens/Team";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
function TeamPage({ team }: TeamPageProps) {
|
||||
function TeamPage({ team, isUnpublished }: TeamPageProps) {
|
||||
useTheme(team.theme);
|
||||
const showMembers = useToggleQuery("members");
|
||||
const { t } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
const telemetry = useTelemetry();
|
||||
const router = useRouter();
|
||||
const teamName = team.name || "Nameless Team";
|
||||
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.event(
|
||||
@@ -39,6 +42,18 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
);
|
||||
}, [telemetry, router.asPath]);
|
||||
|
||||
if (isUnpublished) {
|
||||
return (
|
||||
<div className="m-8 flex items-center justify-center">
|
||||
<EmptyScreen
|
||||
avatar={<Avatar alt={teamName} imageSrc={getPlaceholderAvatar(team.logo, team.name)} size="lg" />}
|
||||
headline={t("team_is_unpublished", { team: teamName })}
|
||||
description={t("team_is_unpublished_description")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EventTypes = () => (
|
||||
<ul className="dark:border-darkgray-300 rounded-md border border-gray-200">
|
||||
{team.eventTypes.map((type, index) => (
|
||||
@@ -78,12 +93,8 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
</ul>
|
||||
);
|
||||
|
||||
const teamName = team.name || "Nameless Team";
|
||||
|
||||
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<HeadSeo
|
||||
title={teamName}
|
||||
description={teamName}
|
||||
@@ -124,7 +135,6 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="mt-8 flex justify-center text-center dark:text-white">
|
||||
<Button
|
||||
color="minimal"
|
||||
@@ -140,7 +150,7 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,6 +158,25 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const ssr = await ssrInit(context);
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
|
||||
const unpublishedTeam = await prisma.team.findFirst({
|
||||
where: {
|
||||
metadata: {
|
||||
path: ["requestedSlug"],
|
||||
equals: slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (unpublishedTeam) {
|
||||
return {
|
||||
props: {
|
||||
isUnpublished: true,
|
||||
team: unpublishedTeam,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
const team = await getTeamWithMembers(undefined, slug);
|
||||
|
||||
if (!team) return { notFound: true } as { notFound: true };
|
||||
@@ -165,7 +194,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
team,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
};
|
||||
|
||||
export default TeamPage;
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { privacyFilteredLocations } from "@calcom/core/location";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -177,7 +176,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBooking(prisma, rescheduleUid, getBookingFieldsWithSystemFields(eventTypeObject));
|
||||
booking = await getBooking(prisma, rescheduleUid);
|
||||
}
|
||||
|
||||
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
@@ -24,6 +25,11 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||
|
||||
TeamBookingPage.isThemeSupported = true;
|
||||
|
||||
const querySchema = z.object({
|
||||
rescheduleUid: z.string().optional(),
|
||||
bookingUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||
@@ -119,8 +125,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
})[0];
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (context.query.rescheduleUid) {
|
||||
booking = await getBooking(prisma, context.query.rescheduleUid as string, eventTypeObject.bookingFields);
|
||||
const { rescheduleUid, bookingUid } = querySchema.parse(context.query);
|
||||
if (rescheduleUid || bookingUid) {
|
||||
booking = await getBooking(prisma, rescheduleUid || bookingUid || "");
|
||||
}
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
@@ -145,6 +152,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
eventType: eventTypeObject,
|
||||
recurringEventCount,
|
||||
booking,
|
||||
currentSlotBooking: null,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: false,
|
||||
hashedLink: null,
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import DailyIframe from "@daily-co/daily-js";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Head from "next/head";
|
||||
import { useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { FiChevronRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
export default function JoinCall(props: JoinCallPageProps) {
|
||||
const { t } = useLocale();
|
||||
const { meetingUrl, meetingPassword } = props;
|
||||
const { meetingUrl, meetingPassword, booking } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const callFrame = DailyIframe.createFrame({
|
||||
@@ -53,7 +59,6 @@ export default function JoinCall(props: JoinCallPageProps) {
|
||||
<>
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={APP_NAME + " Video"} />
|
||||
<meta name="description" content={t("quick_video_meeting")} />
|
||||
<meta property="og:image" content={SEO_IMG_OGIMG_VIDEO} />
|
||||
<meta property="og:type" content="website" />
|
||||
@@ -77,6 +82,120 @@ export default function JoinCall(props: JoinCallPageProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<VideoMeetingInfo booking={booking} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProgressBarProps {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
function ProgressBar(props: ProgressBarProps) {
|
||||
const { t } = useLocale();
|
||||
const { startTime, endTime } = props;
|
||||
const currentTime = dayjs().second(0).millisecond(0);
|
||||
const startingTime = dayjs(startTime).second(0).millisecond(0);
|
||||
const isPast = currentTime.isAfter(startingTime);
|
||||
const currentDifference = dayjs().diff(startingTime, "minutes");
|
||||
const startDuration = dayjs(endTime).diff(startingTime, "minutes");
|
||||
const [duration] = useState(() => {
|
||||
if (currentDifference >= 0 && isPast) {
|
||||
return startDuration - currentDifference;
|
||||
} else {
|
||||
return startDuration;
|
||||
}
|
||||
});
|
||||
|
||||
const prev = startDuration - duration;
|
||||
const percentage = prev * (100 / startDuration);
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
{duration} {t("minutes")}
|
||||
</p>
|
||||
<div className="relative h-2 max-w-xl overflow-hidden rounded-full">
|
||||
<div className="absolute h-full w-full bg-gray-500/10" />
|
||||
<div className={classNames("relative h-full bg-green-500")} style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoMeetingInfo {
|
||||
booking: JoinCallPageProps["booking"];
|
||||
}
|
||||
|
||||
export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { booking } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const endTime = new Date(booking.endTime);
|
||||
const startTime = new Date(booking.startTime);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={classNames(
|
||||
"no-scrollbar fixed top-0 left-0 z-30 flex h-full w-64 transform justify-between overflow-x-hidden overflow-y-scroll transition-all duration-300 ease-in-out",
|
||||
open ? "translate-x-0" : "-translate-x-[232px]"
|
||||
)}>
|
||||
<main className="prose-sm prose max-w-64 prose-a:text-white prose-h3:text-white prose-h3:font-cal scroll-bar scrollbar-track-w-20 w-full overflow-scroll overflow-x-hidden border-r border-gray-300/20 bg-black/80 p-4 text-white shadow-sm backdrop-blur-lg">
|
||||
<h3>{t("what")}:</h3>
|
||||
<p>{booking.title}</p>
|
||||
<h3>{t("invitee_timezone")}:</h3>
|
||||
<p>{booking.user?.timeZone}</p>
|
||||
<h3>{t("when")}:</h3>
|
||||
<p>
|
||||
{formatToLocalizedDate(startTime)} <br />
|
||||
{formatToLocalizedTime(startTime)}
|
||||
</p>
|
||||
<h3>{t("time_left")}</h3>
|
||||
<ProgressBar
|
||||
key={String(open)}
|
||||
endTime={endTime.toISOString()}
|
||||
startTime={startTime.toISOString()}
|
||||
/>
|
||||
|
||||
<h3>{t("who")}:</h3>
|
||||
<p>
|
||||
{booking?.user?.name} - {t("organizer")}:{" "}
|
||||
<a href={`mailto:${booking?.user?.email}`}>{booking?.user?.email}</a>
|
||||
</p>
|
||||
|
||||
{booking.attendees.length
|
||||
? booking.attendees.map((attendee) => (
|
||||
<p key={attendee.id}>
|
||||
{attendee.name} – <a href={`mailto:${attendee.email}`}>{attendee.email}</a>
|
||||
</p>
|
||||
))
|
||||
: null}
|
||||
|
||||
{booking.description && (
|
||||
<>
|
||||
<h3>{t("description")}:</h3>
|
||||
|
||||
<div
|
||||
className="prose-sm prose prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: md.render(booking.description ?? "") }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
aria-label={`${open ? "close" : "open"} booking description sidebar`}
|
||||
className="h-20 w-6 rounded-r-md border border-l-0 border-gray-300/20 bg-black/60 text-white shadow-sm backdrop-blur-lg"
|
||||
onClick={() => setOpen(!open)}>
|
||||
<FiChevronRight
|
||||
aria-hidden
|
||||
className={classNames(open && "rotate-180", "w-5 transition-all duration-300 ease-in-out")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -93,10 +212,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
uid: true,
|
||||
description: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
credentials: true,
|
||||
timeZone: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
references: {
|
||||
@@ -157,6 +280,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
...(typeof bookingObj.references[0].meetingPassword === "string" && {
|
||||
meetingPassword: bookingObj.references[0].meetingPassword,
|
||||
}),
|
||||
booking: bookingObj,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,63 +5,35 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { Button, HeadSeo } from "@calcom/ui";
|
||||
import { FiArrowRight, FiCalendar, FiX } from "@calcom/ui/components/icon";
|
||||
import { Button, HeadSeo, EmptyScreen } from "@calcom/ui";
|
||||
import { FiArrowRight, FiCalendar, FiClock } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function MeetingNotStarted(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<HeadSeo title="Meeting Unavailable" description="Meeting Unavailable" />
|
||||
<>
|
||||
<HeadSeo title={t("this_meeting_has_not_started_yet")} description={props.booking.title} />
|
||||
<main className="mx-auto my-24 max-w-3xl">
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<FiX className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
|
||||
This meeting has not started yet
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-b py-4">
|
||||
<h2 className="font-cal mb-2 text-center text-lg font-medium text-gray-600">
|
||||
{props.booking.title}
|
||||
</h2>
|
||||
<p className="text-center text-gray-500">
|
||||
<FiCalendar className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
{dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<p className="text-sm text-gray-500">
|
||||
This meeting will be accessible 60 minutes in advance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 text-center sm:mt-6">
|
||||
<div className="mt-5">
|
||||
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
Icon={FiClock}
|
||||
headline={t("this_meeting_has_not_started_yet")}
|
||||
description={
|
||||
<>
|
||||
<h2 className="mb-2 text-center font-medium">{props.booking.title}</h2>
|
||||
<p className="text-center text-gray-500">
|
||||
<FiCalendar className="mr-1 -mt-1 inline-block h-4 w-4" />
|
||||
{dayjs(props.booking.startTime).format(detectBrowserTimeFormat + ", dddd DD MMMM YYYY")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
buttonRaw={
|
||||
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, HeadSeo } from "@calcom/ui";
|
||||
import { Button, EmptyScreen, HeadSeo } from "@calcom/ui";
|
||||
import { FiX, FiArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function NoMeetingFound() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<HeadSeo title={t("no_meeting_found")} description={t("no_meeting_found")} />
|
||||
<main className="mx-auto my-24 max-w-3xl">
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className="inline-block transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<FiX className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-headline">
|
||||
{t("no_meeting_found")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<p className="text-center text-sm text-gray-500">{t("no_meeting_found_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 text-center sm:mt-6">
|
||||
<div className="mt-5">
|
||||
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
|
||||
{t("go_back_home")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
Icon={FiX}
|
||||
headline={t("no_meeting_found")}
|
||||
description={t("no_meeting_found_description")}
|
||||
buttonRaw={
|
||||
<Button data-testid="return-home" href="/event-types" EndIcon={FiArrowRight}>
|
||||
{t("go_back_home")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,4 +132,26 @@ test.describe("pro user", () => {
|
||||
// This is the only booking in there that needed confirmation and now it should be empty screen
|
||||
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("can book with multiple guests", async ({ page, users }) => {
|
||||
const additionalGuests = ["test@gmail.com", "test2@gmail.com"];
|
||||
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await page.fill('[name="name"]', "test1234");
|
||||
await page.fill('[name="email"]', "test1234@example.com");
|
||||
await page.locator('[data-testid="add-guests"]').click();
|
||||
|
||||
await page.locator('input[type="email"]').nth(1).fill(additionalGuests[0]);
|
||||
await page.locator('[data-testid="add-another-guest"]').click();
|
||||
await page.locator('input[type="email"]').nth(2).fill(additionalGuests[1]);
|
||||
|
||||
await page.locator('[data-testid="confirm-book-button"]').click();
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
additionalGuests.forEach(async (email) => {
|
||||
await expect(page.locator(`[data-testid="attendee-${email}"]`)).toHaveText(email);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewSeatedEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
|
||||
const slug = "seats";
|
||||
const user = await users.create({
|
||||
eventTypes: [
|
||||
{ title: "Seated event", slug, seatsPerTimeSlot: 10, requiresConfirmation: true, length: 30 },
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
|
||||
return { user, eventType };
|
||||
}
|
||||
|
||||
async function createUserWithSeatedEventAndAttendees(
|
||||
fixtures: Pick<Fixtures, "users" | "bookings">,
|
||||
attendees: Prisma.AttendeeCreateManyBookingInput[]
|
||||
) {
|
||||
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
|
||||
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
// startTime with 1 day from now and endTime half hour after
|
||||
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: attendees,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { user, eventType, booking };
|
||||
}
|
||||
|
||||
test.describe("Booking with Seats", () => {
|
||||
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
|
||||
const user = await users.create({ name: "Seated event" });
|
||||
await user.login();
|
||||
const eventTitle = "My 2-seated event";
|
||||
await createNewSeatedEventType(page, { eventTitle });
|
||||
await expect(page.locator(`text=${eventTitle} event type updated successfully`)).toBeVisible();
|
||||
});
|
||||
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
|
||||
const slug = "my-2-seated-event";
|
||||
const user = await users.create({
|
||||
name: "Seated event user",
|
||||
eventTypes: [
|
||||
{ title: "My 2-seated event", slug, length: 60, seatsPerTimeSlot: 2, seatsShowAttendees: true },
|
||||
],
|
||||
});
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname.endsWith("/book");
|
||||
},
|
||||
});
|
||||
const bookingUrl = page.url();
|
||||
await test.step("Attendee #1 can book a seated event time slot", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #2 can book the same seated event time slot", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #3 cannot book the same seated event time slot", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
|
||||
});
|
||||
});
|
||||
// TODO: Make E2E test: Attendee #1 should be able to cancel his booking
|
||||
// todo("Attendee #1 should be able to cancel his booking");
|
||||
// TODO: Make E2E test: Attendee #1 should be able to reschedule his booking
|
||||
// todo("Attendee #1 should be able to reschedule his booking");
|
||||
// TODO: Make E2E test: All attendees canceling should delete the booking for the User
|
||||
// todo("All attendees canceling should delete the booking for the User");
|
||||
|
||||
test.describe("Reschedule for booking with seats", () => {
|
||||
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[2].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[2].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// expect input to be filled with attendee number 3 data
|
||||
const thirdAttendeeElement = await page.locator("input[name=name]");
|
||||
const attendeeName = await thirdAttendeeElement.inputValue();
|
||||
expect(attendeeName).toBe("John Third");
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
// Should expect old booking to be accepted with two attendees
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
expect(oldBooking?.attendees.length).toBe(2);
|
||||
expect(oldBooking?.seatsReferences.length).toBe(2);
|
||||
});
|
||||
|
||||
test("Should reschedule booking with seats and if everyone rescheduled it should be deleted", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[0].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForNavigation({ url: /.*booking/ });
|
||||
|
||||
await page.goto(`/reschedule/${references[1].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForNavigation({ url: /.*booking/ });
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: {
|
||||
seatsReferences: true,
|
||||
attendees: true,
|
||||
eventType: {
|
||||
include: { users: true, hosts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(oldBooking?.attendees).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Should cancel with seats and have no attendees and cancelled", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
await user.login();
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
// Now we cancel the booking as the organizer
|
||||
await page.goto(`/booking/${booking.uid}?cancel=true`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
expect(oldBooking?.startTime).not.toBe(updatedBooking?.startTime);
|
||||
});
|
||||
|
||||
test("If rescheduled/cancelled booking with seats it should display the correct number of seats", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
`/booking/${references[0].referenceUid}?cancel=true&seatReferenceUid=${references[0].referenceUid}`
|
||||
);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
|
||||
await page.goto(`/reschedule/${references[1].referenceUid}`);
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(1).click();
|
||||
|
||||
// Validate that the number of seats its 10
|
||||
expect(await page.locator("text=9 / 10 Seats available").count()).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
|
||||
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
@@ -29,38 +29,17 @@ test.describe("Event Types tests", () => {
|
||||
});
|
||||
|
||||
test("can add new event type", async ({ page }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `hello ${nonce}`;
|
||||
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
await page.fill("[name=length]", "10");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname !== "/event-types";
|
||||
},
|
||||
});
|
||||
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("enabling recurring event comes with default options", async ({ page }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `my recurring event ${nonce}`;
|
||||
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
await page.fill("[name=length]", "15");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname !== "/event-types";
|
||||
},
|
||||
});
|
||||
await createNewEventType(page, { eventTitle });
|
||||
|
||||
await page.click("[data-testid=vertical-tab-recurring]");
|
||||
await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeHidden();
|
||||
@@ -140,7 +119,7 @@ test.describe("Event Types tests", () => {
|
||||
await page.locator("#location-select").click();
|
||||
await page.locator("text=In Person (Organizer Address)").click();
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(250);
|
||||
await page.waitForTimeout(1000);
|
||||
await page.locator('input[name="locationAddress"]').fill(inputText);
|
||||
await page.locator("[data-testid=display-location]").check();
|
||||
await page.locator("[data-testid=update-location]").click();
|
||||
|
||||
@@ -18,7 +18,18 @@ export const createBookingsFixture = (page: Page) => {
|
||||
userId: number,
|
||||
username: string | null,
|
||||
eventTypeId = -1,
|
||||
{ rescheduled = false, paid = false, status = "ACCEPTED" }: Partial<Prisma.BookingCreateInput> = {},
|
||||
{
|
||||
rescheduled = false,
|
||||
paid = false,
|
||||
status = "ACCEPTED",
|
||||
attendees = {
|
||||
create: {
|
||||
email: "attendee@example.com",
|
||||
name: "Attendee Example",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
},
|
||||
}: Partial<Prisma.BookingCreateInput> = {},
|
||||
startDateParam?: Date,
|
||||
endDateParam?: Date
|
||||
) => {
|
||||
@@ -36,13 +47,7 @@ export const createBookingsFixture = (page: Page) => {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "attendee@example.com",
|
||||
name: "Attendee Example",
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
},
|
||||
attendees,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
|
||||
@@ -69,61 +69,24 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
} = {}
|
||||
) => {
|
||||
const _user = await prisma.user.create({
|
||||
data: await createUser(workerInfo, opts),
|
||||
});
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
owner: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
title: "30 min",
|
||||
slug: "30-min",
|
||||
length: 30,
|
||||
},
|
||||
});
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
users: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
title: "Paid",
|
||||
slug: "paid",
|
||||
length: 30,
|
||||
price: 1000,
|
||||
},
|
||||
});
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
users: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
title: "Opt in",
|
||||
slug: "opt-in",
|
||||
requiresConfirmation: true,
|
||||
length: 30,
|
||||
},
|
||||
data: createUser(workerInfo, opts),
|
||||
});
|
||||
|
||||
let defaultEventTypes: SupportedTestEventTypes[] = [
|
||||
{ title: "30 min", slug: "30-min", length: 30 },
|
||||
{ title: "Paid", slug: "paid", length: 30, price: 1000 },
|
||||
{ title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 },
|
||||
];
|
||||
|
||||
if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes);
|
||||
for (const eventTypeData of defaultEventTypes) {
|
||||
eventTypeData.owner = { connect: { id: _user.id } };
|
||||
eventTypeData.users = { connect: { id: _user.id } };
|
||||
await prisma.eventType.create({
|
||||
data: eventTypeData,
|
||||
});
|
||||
}
|
||||
|
||||
if (scenario.seedRoutingForms) {
|
||||
await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
@@ -314,8 +277,14 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
};
|
||||
};
|
||||
|
||||
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
|
||||
_bookings?: PrismaType.BookingCreateInput[];
|
||||
};
|
||||
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & { timeZone?: TimeZoneEnum };
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test as base } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
@@ -18,6 +19,21 @@ export interface Fixtures {
|
||||
prisma: typeof prisma;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace PlaywrightTest {
|
||||
//FIXME: how to restrict it to Frame only
|
||||
interface Matchers<R> {
|
||||
toBeEmbedCalLink(
|
||||
calNamespace: string,
|
||||
// eslint-disable-next-line
|
||||
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
|
||||
expectedUrlDetails?: ExpectedUrlDetails
|
||||
): Promise<R>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-fixtures
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import { createServer } from "http";
|
||||
import noop from "lodash/noop";
|
||||
import { noop } from "lodash";
|
||||
|
||||
import { test } from "./fixtures";
|
||||
|
||||
@@ -133,10 +133,10 @@ export async function bookFirstEvent(page: Page) {
|
||||
await bookEventOnThisPage(page);
|
||||
}
|
||||
|
||||
export const bookTimeSlot = async (page: Page) => {
|
||||
export const bookTimeSlot = async (page: Page, opts?: { name?: string; email?: string }) => {
|
||||
// --- fill form
|
||||
await page.fill('[name="name"]', "Test Testson");
|
||||
await page.fill('[name="email"]', "test@example.com");
|
||||
await page.fill('[name="name"]', opts?.name ?? "Test Testson");
|
||||
await page.fill('[name="email"]', opts?.email ?? "test@example.com");
|
||||
await page.press('[name="email"]', "Enter");
|
||||
};
|
||||
// Provide an standalone localize utility not managed by next-i18n
|
||||
@@ -148,3 +148,25 @@ export async function localize(locale: string) {
|
||||
throw "No locale found for the given entry message";
|
||||
};
|
||||
}
|
||||
|
||||
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const eventTitle = args.eventTitle;
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
await page.fill("[name=length]", "10");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname !== "/event-types";
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
const eventTitle = args.eventTitle;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await page.locator('[data-testid="vertical-tab-event_advanced_tab_title"]').click();
|
||||
await page.locator('[data-testid="offer-seats-toggle"]').click();
|
||||
await page.locator('[data-testid="update-eventtype"]').click();
|
||||
};
|
||||
|
||||
@@ -33,56 +33,108 @@ test.describe("Manage Booking Questions", () => {
|
||||
await firstEventTypeElement.click();
|
||||
});
|
||||
|
||||
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
|
||||
await addQuestionAndSave({
|
||||
page,
|
||||
question: {
|
||||
name: "how_are_you",
|
||||
type: "Name",
|
||||
label: "How are you?",
|
||||
placeholder: "I'm fine, thanks",
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
|
||||
});
|
||||
});
|
||||
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
|
||||
const userFieldLocator = allFieldsLocator.nth(5);
|
||||
test.describe("For Team EventType", () => {
|
||||
test("Do a booking with a user added question and verify a few thing in b/w", async ({
|
||||
page,
|
||||
users,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
// Considering there are many steps in it, it would need more than default test timeout
|
||||
test.setTimeout(testInfo.timeout * 3);
|
||||
const user = await createAndLoginUserWithEventTypes({ users });
|
||||
|
||||
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
|
||||
// There are 2 labels right now. Will be one in future. The second one is hidden
|
||||
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
|
||||
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
|
||||
});
|
||||
const webhookReceiver = await addWebhook(user);
|
||||
|
||||
await test.step("Go to First Team Event", async () => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
|
||||
await firstEventTypeElement.click();
|
||||
});
|
||||
|
||||
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
|
||||
await toggleQuestionAndSave({
|
||||
name: "how_are_you",
|
||||
page,
|
||||
});
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
|
||||
await expect(formBuilderFieldLocator).toBeHidden();
|
||||
});
|
||||
});
|
||||
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Show Question Again", async () => {
|
||||
await toggleQuestionAndSave({
|
||||
name: "how_are_you",
|
||||
page,
|
||||
});
|
||||
});
|
||||
async function runTestStepsCommonForTeamAndUserEventType(
|
||||
page: Page,
|
||||
context: PlaywrightTestArgs["context"],
|
||||
webhookReceiver: {
|
||||
port: number;
|
||||
close: () => import("http").Server;
|
||||
requestList: (import("http").IncomingMessage & { body?: unknown })[];
|
||||
url: string;
|
||||
}
|
||||
) {
|
||||
await page.click('[href$="tabName=advanced"]');
|
||||
|
||||
await test.step('Try to book without providing "How are you?" response', async () => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
|
||||
await expectErrorToBeThereFor({ page, name: "how_are_you" });
|
||||
});
|
||||
});
|
||||
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
|
||||
await addQuestionAndSave({
|
||||
page,
|
||||
question: {
|
||||
name: "how_are_you",
|
||||
type: "Name",
|
||||
label: "How are you?",
|
||||
placeholder: "I'm fine, thanks",
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
await test.step("Do a booking", async () => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
|
||||
const userFieldLocator = allFieldsLocator.nth(5);
|
||||
|
||||
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
|
||||
// There are 2 labels right now. Will be one in future. The second one is hidden
|
||||
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
|
||||
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
|
||||
await toggleQuestionAndSave({
|
||||
name: "how_are_you",
|
||||
page,
|
||||
});
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
|
||||
await expect(formBuilderFieldLocator).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Show Question Again", async () => {
|
||||
await toggleQuestionAndSave({
|
||||
name: "how_are_you",
|
||||
page,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Try to book without providing "How are you?" response', async () => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
|
||||
await expectErrorToBeThereFor({ page, name: "how_are_you" });
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Make rescheduleReason required - It won't be required for a fresh booking", async () => {
|
||||
await toggleQuestionRequireStatusAndSave({
|
||||
required: true,
|
||||
name: "rescheduleReason",
|
||||
page,
|
||||
});
|
||||
});
|
||||
|
||||
const previewTabPage =
|
||||
await test.step("Do a booking and notice that we can book without giving a value for rescheduleReason", async () => {
|
||||
return await doOnFreshPreview(
|
||||
page,
|
||||
context,
|
||||
async (page) => {
|
||||
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
|
||||
await expect(formBuilderFieldLocator).toBeVisible();
|
||||
expect(
|
||||
@@ -133,146 +185,16 @@ test.describe("Manage Booking Questions", () => {
|
||||
value: "I am great!",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("For Team EventType", () => {
|
||||
test("Do a booking with a user added question and verify a few thing in b/w", async ({
|
||||
page,
|
||||
users,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
// Considering there are many steps in it, it would need more than default test timeout
|
||||
test.setTimeout(testInfo.timeout * 3);
|
||||
const user = await createAndLoginUserWithEventTypes({ users });
|
||||
|
||||
const webhookReceiver = await addWebhook(user);
|
||||
|
||||
await test.step("Go to First Team Event", async () => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
|
||||
await firstEventTypeElement.click();
|
||||
});
|
||||
|
||||
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function runTestStepsCommonForTeamAndUserEventType(
|
||||
page: Page,
|
||||
context: PlaywrightTestArgs["context"],
|
||||
webhookReceiver: {
|
||||
port: number;
|
||||
close: () => import("http").Server;
|
||||
requestList: (import("http").IncomingMessage & { body?: unknown })[];
|
||||
url: string;
|
||||
}
|
||||
) {
|
||||
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
|
||||
await addQuestionAndSave({
|
||||
page,
|
||||
question: {
|
||||
name: "how_are_you",
|
||||
type: "Name",
|
||||
label: "How are you?",
|
||||
placeholder: "I'm fine, thanks",
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
|
||||
const userFieldLocator = allFieldsLocator.nth(5);
|
||||
|
||||
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
|
||||
// There are 2 labels right now. Will be one in future. The second one is hidden
|
||||
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
|
||||
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Hide Question and see that it's not shown on Booking Page", async () => {
|
||||
await toggleQuestionAndSave({
|
||||
name: "how_are_you",
|
||||
page,
|
||||
});
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
|
||||
await expect(formBuilderFieldLocator).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Show Question Again", async () => {
|
||||
await toggleQuestionAndSave({
|
||||
name: "how_are_you",
|
||||
page,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Try to book without providing "How are you?" response', async () => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
|
||||
await expectErrorToBeThereFor({ page, name: "how_are_you" });
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Do a booking", async () => {
|
||||
await doOnFreshPreview(page, context, async (page) => {
|
||||
const formBuilderFieldLocator = page.locator('[data-fob-field-name="how_are_you"]');
|
||||
await expect(formBuilderFieldLocator).toBeVisible();
|
||||
expect(await formBuilderFieldLocator.locator('[name="how_are_you"]').getAttribute("placeholder")).toBe(
|
||||
"I'm fine, thanks"
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(await formBuilderFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
|
||||
await formBuilderFieldLocator.locator('[name="how_are_you"]').fill("I am great!");
|
||||
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
expect(
|
||||
await page.locator('[data-testid="field-response"][data-fob-field="how_are_you"]').innerText()
|
||||
).toBe("I am great!");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
|
||||
const [request] = webhookReceiver.requestList;
|
||||
|
||||
const payload = (request.body as any).payload as any;
|
||||
|
||||
expect(payload.responses).toMatchObject({
|
||||
email: {
|
||||
label: "email_address",
|
||||
value: "booker@example.com",
|
||||
},
|
||||
how_are_you: {
|
||||
label: "How are you?",
|
||||
value: "I am great!",
|
||||
},
|
||||
name: {
|
||||
label: "your_name",
|
||||
value: "Booker",
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.location).toBe("integrations:daily");
|
||||
|
||||
expect(payload.attendees[0]).toMatchObject({
|
||||
name: "Booker",
|
||||
email: "booker@example.com",
|
||||
});
|
||||
|
||||
expect(payload.userFieldsResponses).toMatchObject({
|
||||
how_are_you: {
|
||||
label: "How are you?",
|
||||
value: "I am great!",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Do a reschedule and notice that we can't book without giving a value for rescheduleReason", async () => {
|
||||
const page = previewTabPage;
|
||||
await rescheduleFromTheLinkOnPage({ page });
|
||||
await page.pause();
|
||||
await expectErrorToBeThereFor({ page, name: "rescheduleReason" });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,7 +255,6 @@ async function addQuestionAndSave({
|
||||
required?: boolean;
|
||||
};
|
||||
}) {
|
||||
await page.click('[href$="tabName=advanced"]');
|
||||
await page.click('[data-testid="add-field"]');
|
||||
|
||||
if (question.type !== undefined) {
|
||||
@@ -379,11 +300,15 @@ async function expectErrorToBeThereFor({ page, name }: { page: Page; name: strin
|
||||
async function doOnFreshPreview(
|
||||
page: Page,
|
||||
context: PlaywrightTestArgs["context"],
|
||||
callback: (page: Page) => Promise<void>
|
||||
callback: (page: Page) => Promise<void>,
|
||||
persistTab = false
|
||||
) {
|
||||
const previewTabPage = await openBookingFormInPreviewTab(context, page);
|
||||
await callback(previewTabPage);
|
||||
await previewTabPage.close();
|
||||
if (!persistTab) {
|
||||
await previewTabPage.close();
|
||||
}
|
||||
return previewTabPage;
|
||||
}
|
||||
|
||||
async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) {
|
||||
@@ -391,6 +316,25 @@ async function toggleQuestionAndSave({ name, page }: { name: string; page: Page
|
||||
await saveEventType(page);
|
||||
}
|
||||
|
||||
async function toggleQuestionRequireStatusAndSave({
|
||||
required,
|
||||
name,
|
||||
page,
|
||||
}: {
|
||||
required: boolean;
|
||||
name: string;
|
||||
page: Page;
|
||||
}) {
|
||||
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="edit-field-action"]').click();
|
||||
await page
|
||||
.locator('[data-testid="edit-field-dialog"]')
|
||||
.locator('[data-testid="field-required"] button')
|
||||
.locator(`text=${required ? "Yes" : "No"}`)
|
||||
.click();
|
||||
await page.locator('[data-testid="field-add-save"]').click();
|
||||
await saveEventType(page);
|
||||
}
|
||||
|
||||
async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<typeof createUsersFixture> }) {
|
||||
const user = await users.create(null, {
|
||||
hasTeam: true,
|
||||
@@ -399,6 +343,16 @@ async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<t
|
||||
return user;
|
||||
}
|
||||
|
||||
async function rescheduleFromTheLinkOnPage({ page }: { page: Page }) {
|
||||
await page.locator('[data-testid="reschedule-link"]').click();
|
||||
await page.waitForLoadState();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await page.waitForNavigation({
|
||||
url: (url) => url.pathname.endsWith("/book"),
|
||||
});
|
||||
await page.click('[data-testid="confirm-reschedule-button"]');
|
||||
}
|
||||
|
||||
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
|
||||
const previewTabPromise = context.waitForEvent("page");
|
||||
await page.locator('[data-testid="preview-button"]').click();
|
||||
|
||||
@@ -26,7 +26,11 @@ test.describe("Onboarding", () => {
|
||||
await page.locator("input[name=username]").fill("new user onboarding");
|
||||
await page.locator("input[name=name]").fill("new user 2");
|
||||
await page.locator("input[role=combobox]").click();
|
||||
await page.locator("text=Europe/London GMT +0:00").click();
|
||||
await page
|
||||
.locator("*")
|
||||
.filter({ hasText: /^Europe\/London/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ test.afterEach(({ users }) => users.deleteAll());
|
||||
test.describe("Reschedule Tests", async () => {
|
||||
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
@@ -45,15 +45,18 @@ test.describe("Reschedule Tests", async () => {
|
||||
});
|
||||
|
||||
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
|
||||
test.skip(true, "TODO: Re-enable after v1.7 launch");
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.CANCELLED,
|
||||
rescheduled: true,
|
||||
});
|
||||
|
||||
await page.goto(`/${user.username}/${user.eventTypes[0].slug}?rescheduleUid=${booking.uid}`);
|
||||
const formerTimeElement = page.locator('[data-testid="former_time_p_desktop"]');
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
const formerTimeElement = page.locator('[data-testid="former_time_p"]');
|
||||
await expect(formerTimeElement).toBeVisible();
|
||||
await booking.delete();
|
||||
});
|
||||
@@ -90,13 +93,16 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// NOTE: remove if old booking should not be deleted
|
||||
expect(await booking.self()).toBeNull();
|
||||
|
||||
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking.uid } });
|
||||
const rescheduledBooking = await prisma.booking.findFirst({ where: { uid: booking.uid } });
|
||||
|
||||
expect(newBooking).not.toBeNull();
|
||||
expect(rescheduledBooking).toBeNull();
|
||||
|
||||
await prisma.booking.delete({ where: { id: newBooking?.id } });
|
||||
});
|
||||
|
||||
@@ -106,6 +112,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
await user.getPaymentCredential();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
rescheduled: true,
|
||||
@@ -142,7 +149,6 @@ test.describe("Reschedule Tests", async () => {
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/.*payment/);
|
||||
await payment.delete();
|
||||
});
|
||||
|
||||
test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => {
|
||||
@@ -150,6 +156,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
await user.login();
|
||||
await user.getPaymentCredential();
|
||||
await users.logout();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
rescheduled: true,
|
||||
@@ -165,12 +172,11 @@ test.describe("Reschedule Tests", async () => {
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
await payment.delete();
|
||||
});
|
||||
|
||||
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
@@ -191,6 +197,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
test("Opt in event should be ACCEPTED when rescheduled by OWNER", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
|
||||
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke="white" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke="white" stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
|
Before Width: | Height: | Size: 56 KiB |
@@ -25,7 +25,7 @@
|
||||
"rejection_confirmation": "Reject the booking",
|
||||
"manage_this_event": "Manage this event",
|
||||
"invite_team_member": "Invite team member",
|
||||
"invite_team_notifcation_badge":"Inv.",
|
||||
"invite_team_notifcation_badge": "Inv.",
|
||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
||||
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
|
||||
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
|
||||
@@ -90,6 +90,7 @@
|
||||
"new_event_scheduled_recurring": "A new recurring event has been scheduled.",
|
||||
"invitee_email": "Invitee Email",
|
||||
"invitee_timezone": "Invitee Time Zone",
|
||||
"time_left": "Time left",
|
||||
"event_type": "Event Type",
|
||||
"enter_meeting": "Enter Meeting",
|
||||
"video_call_provider": "Video call provider",
|
||||
@@ -110,7 +111,7 @@
|
||||
"hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.",
|
||||
"link_expires": "p.s. It expires in {{expiresIn}} hours.",
|
||||
"upgrade_to_per_seat": "Upgrade to Per-Seat",
|
||||
"seat_options_doesnt_support_confirmation":"Seats option doesn't support confirmation requirement",
|
||||
"seat_options_doesnt_support_confirmation": "Seats option doesn't support confirmation requirement",
|
||||
"team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.",
|
||||
"team_upgrade_banner_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.",
|
||||
"team_upgrade_banner_action": "Upgrade here",
|
||||
@@ -432,6 +433,7 @@
|
||||
"password_hint_min": "Minimum 7 characters long",
|
||||
"password_hint_admin_min": "Minimum 15 characters long",
|
||||
"password_hint_num": "Contain at least 1 number",
|
||||
"max_limit_allowed_hint": "Must be {{limit}} or fewer characters long",
|
||||
"invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters",
|
||||
"incorrect_password": "Password is incorrect.",
|
||||
"incorrect_username_password": "Username or password is incorrect.",
|
||||
@@ -861,7 +863,7 @@
|
||||
"add_new_calendar": "Add a new calendar",
|
||||
"set_calendar": "Set where to add new events to when you're booked.",
|
||||
"delete_schedule": "Delete schedule",
|
||||
"delete_schedule_description":"Deleting a schedule will remove it from all event types. This action cannot be undone.",
|
||||
"delete_schedule_description": "Deleting a schedule will remove it from all event types. This action cannot be undone.",
|
||||
"schedule_created_successfully": "{{scheduleName}} schedule created successfully",
|
||||
"availability_updated_successfully": "{{scheduleName}} schedule updated successfully",
|
||||
"schedule_deleted_successfully": "Schedule deleted successfully",
|
||||
@@ -1328,7 +1330,7 @@
|
||||
"password_reset_leading": "If you don't receive an email soon, check that the email address you entered is correct, check your spam folder or reach out to support if the issue persists.",
|
||||
"password_updated": "Password updated!",
|
||||
"pending_payment": "Pending payment",
|
||||
"pending_invites":"Pending Invites",
|
||||
"pending_invites": "Pending Invites",
|
||||
"confirmation_page_rainbow": "Token gate your event with tokens or NFTs on Ethereum, Polygon, and more.",
|
||||
"not_on_cal": "Not on {{appName}}",
|
||||
"no_calendar_installed": "No calendar installed",
|
||||
@@ -1478,10 +1480,10 @@
|
||||
"add_an_option": "Add an option",
|
||||
"radio": "Radio",
|
||||
"google_meet_warning": "In order to use Google Meet you must set your destination calendar to a Google Calendar",
|
||||
"individual":"Individual",
|
||||
"all_bookings_filter_label":"All Bookings",
|
||||
"all_users_filter_label":"All Users",
|
||||
"your_bookings_filter_label":"Your Bookings",
|
||||
"individual": "Individual",
|
||||
"all_bookings_filter_label": "All Bookings",
|
||||
"all_users_filter_label": "All Users",
|
||||
"your_bookings_filter_label": "Your Bookings",
|
||||
"meeting_url_variable": "Meeting url",
|
||||
"meeting_url_info": "The event meeting conference url",
|
||||
"date_overrides": "Date overrides",
|
||||
@@ -1576,16 +1578,16 @@
|
||||
"delete_sso_configuration_confirmation": "Yes, delete {{connectionType}} configuration",
|
||||
"delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.",
|
||||
"organizer_timezone": "Organizer timezone",
|
||||
"email_no_user_cta":"Create your account",
|
||||
"email_user_cta":"View Invitation",
|
||||
"email_no_user_invite_heading":"You’ve been invited to join a team on {{appName}}",
|
||||
"email_no_user_invite_subheading":"{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email_no_user_invite_steps_intro":"We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your team in no time.",
|
||||
"email_no_user_step_one":"Choose your username",
|
||||
"email_no_user_step_two":"Connect your calendar account",
|
||||
"email_no_user_step_three":"Set your Availability",
|
||||
"email_no_user_step_four":"Join {{teamName}}",
|
||||
"email_no_user_signoff":"Happy Scheduling from the {{appName}} team",
|
||||
"email_no_user_cta": "Create your account",
|
||||
"email_user_cta": "View Invitation",
|
||||
"email_no_user_invite_heading": "You’ve been invited to join a team on {{appName}}",
|
||||
"email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.",
|
||||
"email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your team in no time.",
|
||||
"email_no_user_step_one": "Choose your username",
|
||||
"email_no_user_step_two": "Connect your calendar account",
|
||||
"email_no_user_step_three": "Set your Availability",
|
||||
"email_no_user_step_four": "Join {{teamName}}",
|
||||
"email_no_user_signoff": "Happy Scheduling from the {{appName}} team",
|
||||
"impersonation_user_tip": "You are about to impersonate a user, which means you can make changes on their behalf. Please be careful.",
|
||||
"available_variables": "Available variables",
|
||||
"scheduler": "{Scheduler}",
|
||||
@@ -1597,10 +1599,15 @@
|
||||
"create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.",
|
||||
"collective_or_roundrobin": "Collective or round-robin",
|
||||
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.",
|
||||
"event_no_longer_attending_subject": "No longer attending {{title}} at {{date}}",
|
||||
"no_longer_attending": "You are no longer attending this event",
|
||||
"attendee_no_longer_attending_subject": "An attendee is no longer attending {{title}} at {{date}}",
|
||||
"attendee_no_longer_attending": "An attendee is no longer attending your event",
|
||||
"attendee_no_longer_attending_subtitle": "{{name}} has cancelled. This means a seat has opened up for this time slot",
|
||||
"create_event_on": "Create event on",
|
||||
"default_app_link_title":"Set a default app link",
|
||||
"default_app_link_description":"Setting a default app link allows all newly created event types to use the app link you set.",
|
||||
"change_default_conferencing_app":"Set as default",
|
||||
"default_app_link_title": "Set a default app link",
|
||||
"default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.",
|
||||
"change_default_conferencing_app": "Set as default",
|
||||
"under_maintenance": "Down for maintenance",
|
||||
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
|
||||
"event_type_seats": "{{numberOfSeats}} seats",
|
||||
@@ -1612,33 +1619,45 @@
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
|
||||
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
|
||||
"booking_confirmation_failed": "Booking confirmation failed",
|
||||
"not_enough_seats": "Not enough seats",
|
||||
"form_builder_field_already_exists": "A field with this name already exists",
|
||||
"form_builder_field_add_subtitle": "Customize the questions asked on the booking page",
|
||||
"form_builder_system_field_cant_delete": "This system field can't be removed.",
|
||||
"form_builder_system_field_cant_toggle": "This system field can't be toggled.",
|
||||
"get_started_zapier_templates": "Get started with Zapier templates",
|
||||
"team_is_unpublished": "{{team}} is unpublished",
|
||||
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.",
|
||||
"team_member": "Team member",
|
||||
"a_routing_form": "A Routing Form",
|
||||
"form_description_placeholder": "Form Description",
|
||||
"keep_me_connected_with_form": "Keep me connected with the form",
|
||||
"fields_in_form_duplicated":"Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.",
|
||||
"fields_in_form_duplicated": "Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.",
|
||||
"form_deleted": "Form deleted",
|
||||
"delete_form": "Are you sure you want to delete this form?",
|
||||
"delete_form_action": "Yes, delete Form",
|
||||
"delete_form_confirmation":"Anyone who you've shared the link with will no longer be able to access it.",
|
||||
"delete_form_confirmation_2":"All associated responses will be deleted.",
|
||||
"delete_form_confirmation": "Anyone who you've shared the link with will no longer be able to access it.",
|
||||
"delete_form_confirmation_2": "All associated responses will be deleted.",
|
||||
"typeform_redirect_url_copied": "Typeform Redirect URL copied! You can go and set the URL in Typeform form.",
|
||||
"modifications_in_fields_warning": "Modifications in fields and routes of following forms will be reflected in this form.",
|
||||
"connected_forms": "Connected Forms",
|
||||
"form_modifications_warning": "Following forms would be affected when you modify fields or routes here.",
|
||||
"responses_collection_waiting_description": "Wait for some time for responses to be collected. You can go and submit the form yourself as well.",
|
||||
"this_is_what_your_users_would_see":"This is what your users would see",
|
||||
"this_is_what_your_users_would_see": "This is what your users would see",
|
||||
"identifies_name_field": "Identifies field by this name.",
|
||||
"add_1_option_per_line": "Add 1 option per line",
|
||||
"select_a_router": "Select a router",
|
||||
"add_a_new_route": "Add a new Route",
|
||||
"make_informed_decisions": "Make informed decisions with Insights",
|
||||
"make_informed_decisions_description": "Our Insights dashboard surfaces all activity across your team and shows you trends that enable better team scheduling and decision making.",
|
||||
"view_bookings_across": "View bookings across all members",
|
||||
"view_bookings_across_description": "See who’s receiving the most bookings and ensure the best distribution across your team",
|
||||
"identify_booking_trends": "Identify booking trends",
|
||||
"identify_booking_trends_description": "See what times of the week and what times during the day are popular for your bookers",
|
||||
"spot_popular_event_types": "Spot popular event types",
|
||||
"spot_popular_event_types_description": "See which of your event types are receiving the most clicks and bookings",
|
||||
"no_responses_yet": "No responses yet",
|
||||
"this_will_be_the_placeholder": "This will be the placeholder",
|
||||
"this_meeting_has_not_started_yet": "This meeting has not started yet",
|
||||
"this_app_requires_connected_account": "{{appName}} requires a connected {{dependencyName}} account",
|
||||
"connect_app": "Connect {{dependencyName}}",
|
||||
"app_is_connected": "{{dependencyName}} is connected",
|
||||
@@ -1646,8 +1665,28 @@
|
||||
"verification_code": "Verification code",
|
||||
"can_you_try_again": "Can you try again with a different time?",
|
||||
"verify": "Verify",
|
||||
"invalid_event_name_variables":"There is an invalid variable in your event name",
|
||||
"invalid_event_name_variables": "There is an invalid variable in your event name",
|
||||
"select_all": "Select All",
|
||||
"default_conferencing_bulk_title": "Bulk update existing event types",
|
||||
"default_conferencing_bulk_description": "Update the locations for the selected event types"
|
||||
"requires_at_least_one_schedule": "You are required to have at least one schedule",
|
||||
"default_conferencing_bulk_description": "Update the locations for the selected event types",
|
||||
"looking_for_more_analytics": "Looking for more analytics?",
|
||||
"add_filter": "Add filter",
|
||||
"select_user": "Select User",
|
||||
"select_event_type": "Select Event Type",
|
||||
"select_date_range": "Select Date Range",
|
||||
"popular_events": "Popular Events",
|
||||
"no_event_types_found": "No event types found",
|
||||
"average_event_duration": "Average Event Duration",
|
||||
"most_booked_members": "Most Booked Members",
|
||||
"least_booked_members": "Least Booked Members",
|
||||
"events_created": "Events Created",
|
||||
"events_completed": "Events Completed",
|
||||
"events_cancelled": "Events Cancelled",
|
||||
"events_rescheduled": "Events Rescheduled",
|
||||
"from_last_period": "from last period",
|
||||
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
|
||||
"analytics_for_organisation": "Analytics for {{organisationName}}",
|
||||
"subtitle_analytics": "This is a organisation analytics",
|
||||
"event_trends": "Event Trends"
|
||||
}
|
||||
|
||||