Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa383d671b | ||
|
|
77bfd3773b | ||
|
|
4cd32fca4a | ||
|
|
c374f4fa20 | ||
|
|
2ddada49f9 | ||
|
|
feb8218e4e | ||
|
|
df4fedd61d | ||
|
|
d80581ad58 | ||
|
|
65687d6110 | ||
|
|
b3c973e824 | ||
|
|
913466d06d | ||
|
|
18c1a6a3a2 | ||
|
|
fe17203cea | ||
|
|
3a6f05f1e3 | ||
|
|
5ebbd514d8 | ||
|
|
58b439ca65 | ||
|
|
c32aadf297 | ||
|
|
736c30f134 | ||
|
|
7b169ebfc2 | ||
|
|
8f5843ab95 | ||
|
|
57d17b74c3 | ||
|
|
d40b934866 | ||
|
|
2e5c0c6332 | ||
|
|
b03bcc7470 | ||
|
|
2da5b19a76 | ||
|
|
0ee3b26ca8 | ||
|
|
e836a042e9 | ||
|
|
37038a0cbf | ||
|
|
8b47c064ff | ||
|
|
8a2da017ff | ||
|
|
2f8280a6ee | ||
|
|
4e1e2577eb | ||
|
|
84da528d4d | ||
|
|
29ceaee8d2 | ||
|
|
d28c914d3c | ||
|
|
34d3bdf868 | ||
|
|
58836eefbf | ||
|
|
4ab6c2b3da | ||
|
|
e1601960df | ||
|
|
c97fecf436 | ||
|
|
804ba04983 | ||
|
|
163444a0f9 | ||
|
|
b0da827483 | ||
|
|
1b468b9ae7 | ||
|
|
2fa83bd512 | ||
|
|
7eecc311e0 | ||
|
|
6c8b428577 | ||
|
|
699c55da18 | ||
|
|
74a30a180e | ||
|
|
ce8e1d52da | ||
|
|
6f8ea490d0 | ||
|
|
a2fd5ba2a2 | ||
|
|
48c8b749ce | ||
|
|
faaf935724 | ||
|
|
0c94b0a30a | ||
|
|
a25195cf20 | ||
|
|
56a2b66190 | ||
|
|
a0f55707e2 | ||
|
|
b247075a19 | ||
|
|
25ec5313fb | ||
|
|
33f35e77fd | ||
|
|
69808bb9a4 | ||
|
|
18b6a93476 | ||
|
|
e627cc51fd | ||
|
|
b349def58b | ||
|
|
6a6faf30ff | ||
|
|
5616dba4db | ||
|
|
bca1a1b86c | ||
|
|
ddf7a234ab | ||
|
|
fa7260d72a | ||
|
|
5f9f6ef025 | ||
|
|
3d41c64d81 | ||
|
|
8a9b985760 | ||
|
|
c8956680ad | ||
|
|
c2668acd33 | ||
|
|
89bf8321ba | ||
|
|
da625f1527 | ||
|
|
2dddd4ce77 | ||
|
|
cc1d606ba8 | ||
|
|
262c8cf37c | ||
|
|
4366405b21 | ||
|
|
07399974ce | ||
|
|
9d7cd73c7f | ||
|
|
1551a293c5 | ||
|
|
47e948fbbc | ||
|
|
c8b01f6992 | ||
|
|
f3143d99ee | ||
|
|
61a5c3a3b5 | ||
|
|
056edd8ec2 | ||
|
|
d8387835a8 | ||
|
|
26ea249481 | ||
|
|
2d26d73eb3 | ||
|
|
b8b24c04dc | ||
|
|
62dcd6612c | ||
|
|
295748adca | ||
|
|
6e93224c9b | ||
|
|
4d8198d113 | ||
|
|
5fe0ca7913 | ||
|
|
cfb625e934 | ||
|
|
e12b21a73c | ||
|
|
841346c5f2 | ||
|
|
1270742078 | ||
|
|
517cfde5b8 | ||
|
|
51bf613621 | ||
|
|
f4c3ab6431 | ||
|
|
e8acc3e500 | ||
|
|
a6ba0f5b57 | ||
|
|
b82d924f03 | ||
|
|
1cdb51f424 | ||
|
|
aca0dc1b01 | ||
|
|
e5e8c33c34 | ||
|
|
c76847a8f1 | ||
|
|
7b1ced914b | ||
|
|
a6cd173910 | ||
|
|
dcd39c3f04 | ||
|
|
aa0a1fcc95 | ||
|
|
76cf352bce | ||
|
|
86ab19be30 | ||
|
|
e4b766b9d6 | ||
|
|
2529f71770 | ||
|
|
9ea0f02c93 | ||
|
|
fb1828c8f9 | ||
|
|
12f2c1f431 | ||
|
|
29e9bd0ee7 | ||
|
|
9c794bac1a | ||
|
|
feca7c23fb | ||
|
|
31599cdd26 | ||
|
|
e018eb9ebe | ||
|
|
a3f3f1f263 | ||
|
|
d19eaae5b6 | ||
|
|
4eecd42204 | ||
|
|
b9f90144c5 | ||
|
|
8b07b839e9 | ||
|
|
2a98ca4b91 | ||
|
|
b9064b1f72 | ||
|
|
0ec71e52ef | ||
|
|
c20835a4c8 | ||
|
|
8c63b42cec | ||
|
|
ddb752102f | ||
|
|
ef0a7950cf | ||
|
|
641301afab | ||
|
|
7d1bc5f0c5 | ||
|
|
aaccfa49ce | ||
|
|
1b1f3ddfd9 | ||
|
|
f7eda5aa3a | ||
|
|
b2b846647d | ||
|
|
4edc973876 | ||
|
|
3b0126ff35 | ||
|
|
cc4192817b | ||
|
|
a8a10c0f35 | ||
|
|
5f7d904349 |
@@ -39,6 +39,8 @@ SAML_DATABASE_URL=
|
||||
SAML_ADMINS=
|
||||
# NEXT_PUBLIC_HOSTED_CAL_FEATURES=1
|
||||
NEXT_PUBLIC_HOSTED_CAL_FEATURES=
|
||||
# For additional security set to a random secret and use that value as the client_secret during the OAuth 2.0 flow.
|
||||
SAML_CLIENT_SECRET_VERIFIER=
|
||||
|
||||
# If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line.
|
||||
# @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js
|
||||
@@ -76,6 +78,10 @@ NEXT_PUBLIC_ZENDESK_KEY=
|
||||
# Help Scout Config
|
||||
NEXT_PUBLIC_HELPSCOUT_KEY=
|
||||
|
||||
# Fresh Chat Config
|
||||
NEXT_PUBLIC_FRESHCHAT_TOKEN=
|
||||
NEXT_PUBLIC_FRESHCHAT_HOST=
|
||||
|
||||
# Inbox to send user feedback
|
||||
SEND_FEEDBACK_EMAIL=
|
||||
|
||||
|
||||
+12
-11
@@ -17,16 +17,17 @@ scripts/ @calcom/core
|
||||
apps/docs @calcom/docs
|
||||
apps/swagger @calcom/docs
|
||||
|
||||
packages/app-store-cli @hariombalhara
|
||||
packages/dayjs @zomars
|
||||
packages/emails @zomars
|
||||
packages/embeds @hariombalhara
|
||||
packages/eslint-plugin @zomars
|
||||
packages/features @zomars
|
||||
packages/features/kbar @alishaz-polymath
|
||||
packages/features/ee/impersonation @sean-brydon
|
||||
packages/features/ee/payments @alannnc
|
||||
packages/features/ee/workflows @CarinaWolli
|
||||
packages/features/tips @PeerRich
|
||||
packages/app-store-cli @hariombalhara @calcom/core
|
||||
packages/dayjs @zomars @calcom/core
|
||||
packages/emails @zomars @calcom/core
|
||||
packages/embeds @hariombalhara @calcom/core
|
||||
packages/eslint-plugin @zomars @calcom/core
|
||||
packages/features @zomars @calcom/core
|
||||
packages/features/kbar @alishaz-polymath @calcom/core
|
||||
packages/features/ee/impersonation @sean-brydon @calcom/core
|
||||
packages/features/ee/payments @alannnc @calcom/core
|
||||
packages/features/ee/workflows @CarinaWolli @calcom/core
|
||||
packages/features/tips @PeerRich @calcom/core
|
||||
packages/ui @calcom/ui
|
||||
packages/config @calcom/core
|
||||
packages/app-store/ee/routing-forms @hariombalhara @calcom/core
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
name: Cache production build binaries
|
||||
description: "Cache or restore if necessary"
|
||||
env:
|
||||
node_version: v16.x
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v16.x
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Cache production build
|
||||
uses: actions/cache@v3
|
||||
id: cache-build
|
||||
env:
|
||||
cache-name: prod-build
|
||||
key-1: ${{ env.node_version }}-${{ hashFiles('yarn.lock') }}
|
||||
key-2: ${{ hashFiles('apps/web/next.config.js') }}
|
||||
key-1: ${{ inputs.node_version }}-${{ hashFiles('yarn.lock') }}
|
||||
key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }}
|
||||
key-3: ${{ github.event.pull_request.number || github.ref }}
|
||||
# Ensures production-build.yml will always be fresh
|
||||
key-4: ${{ github.sha }}
|
||||
with:
|
||||
path: |
|
||||
apps/web/.next
|
||||
node_modules/.cache/turbo
|
||||
${{ github.workspace }}/apps/web/.next
|
||||
**/.turbo/**
|
||||
**/dist/**
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-
|
||||
${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||
- run: yarn build
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
|
||||
@@ -2,7 +2,8 @@ name: Cache database between jobs
|
||||
description: "Cache or restore if necessary"
|
||||
inputs:
|
||||
DATABASE_URL:
|
||||
required: true
|
||||
required: false
|
||||
default: "postgresql://postgres:@localhost:5432/calendso"
|
||||
path:
|
||||
required: false
|
||||
default: "backups/backup.sql"
|
||||
@@ -14,11 +15,14 @@ runs:
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
cache-name: cache-db
|
||||
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
|
||||
key-2: ${{ github.event.pull_request.number || github.ref }}
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ github.sha }}
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}
|
||||
- run: yarn db-seed
|
||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
- name: Postgres Dump Backup
|
||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||
uses: tj-actions/pg-dump@v2.3
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: "Set environment variables"
|
||||
description: "Configures environment variables for a workflow"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Create env file
|
||||
uses: actions/cache@v3
|
||||
id: env-cache
|
||||
with:
|
||||
path: gh.env
|
||||
key: env-cache-${{ hashFiles('gh.env') }}
|
||||
restore-keys: env-cache-
|
||||
- name: Set Environment Variables
|
||||
uses: tw3lveparsecs/github-actions-setvars@latest
|
||||
with:
|
||||
envFilePath: gh.env
|
||||
@@ -1,104 +0,0 @@
|
||||
name: Production Build
|
||||
description: "Creates a production build, caches it and restores if necessary"
|
||||
inputs:
|
||||
DATABASE_URL:
|
||||
required: true
|
||||
NEXT_PUBLIC_WEBAPP_URL:
|
||||
required: true
|
||||
NEXT_PUBLIC_WEBSITE_URL:
|
||||
required: true
|
||||
NEXTAUTH_SECRET:
|
||||
required: true
|
||||
GOOGLE_API_CREDENTIALS:
|
||||
required: true
|
||||
GOOGLE_LOGIN_ENABLED:
|
||||
required: true
|
||||
CRON_API_KEY:
|
||||
required: false
|
||||
CALENDSO_ENCRYPTION_KEY:
|
||||
required: true
|
||||
DAILY_API_KEY:
|
||||
required: true
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY:
|
||||
required: true
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE:
|
||||
required: true
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE:
|
||||
required: true
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE:
|
||||
required: true
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE:
|
||||
required: true
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN:
|
||||
required: true
|
||||
STRIPE_PRIVATE_KEY:
|
||||
required: true
|
||||
STRIPE_CLIENT_ID:
|
||||
required: true
|
||||
STRIPE_WEBHOOK_SECRET:
|
||||
required: true
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID:
|
||||
required: true
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID:
|
||||
required: true
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID:
|
||||
required: true
|
||||
PAYMENT_FEE_PERCENTAGE:
|
||||
required: true
|
||||
PAYMENT_FEE_FIXED:
|
||||
required: true
|
||||
SAML_DATABASE_URL:
|
||||
required: true
|
||||
SAML_ADMINS:
|
||||
required: true
|
||||
NEXTAUTH_URL:
|
||||
required: true
|
||||
NEXT_PUBLIC_IS_E2E:
|
||||
required: true
|
||||
TURBO_TOKEN:
|
||||
required: true
|
||||
TURBO_TEAM:
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/cache-build
|
||||
id: prod-build-cache
|
||||
- name: Next.js production build
|
||||
if: steps.prod-build-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
DATABASE_URL: ${{ inputs.DATABASE_URL }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ inputs.NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ inputs.NEXT_PUBLIC_WEBSITE_URL }}
|
||||
NEXTAUTH_SECRET: ${{ inputs.NEXTAUTH_SECRET }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ inputs.GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ inputs.GOOGLE_LOGIN_ENABLED }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ inputs.CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ inputs.DAILY_API_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ inputs.NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ inputs.NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ inputs.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ inputs.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ inputs.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: ${{ inputs.NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN }}
|
||||
STRIPE_PRIVATE_KEY: ${{ inputs.STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ inputs.STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ inputs.STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ inputs.STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ inputs.STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ inputs.STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ inputs.PAYMENT_FEE_PERCENTAGE }}
|
||||
PAYMENT_FEE_FIXED: ${{ inputs.PAYMENT_FEE_FIXED }}
|
||||
SAML_DATABASE_URL: ${{ inputs.SAML_DATABASE_URL }}
|
||||
SAML_ADMINS: ${{ inputs.SAML_ADMINS }}
|
||||
NEXTAUTH_URL: ${{ inputs.NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ inputs.NEXT_PUBLIC_IS_E2E }}
|
||||
TURBO_TOKEN: ${{ inputs.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ inputs.TURBO_TEAM }}
|
||||
run: |
|
||||
yarn db-seed
|
||||
yarn build
|
||||
@@ -1,30 +1,19 @@
|
||||
name: Yarn install
|
||||
description: "Install all NPM dependencies, caches them and restores if necessary"
|
||||
env:
|
||||
node_version: v16.x
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v16.x
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Use Node ${{ env.node_version }}
|
||||
- name: Use Node ${{ inputs.node_version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.node_version }}
|
||||
node-version: ${{ inputs.node_version }}
|
||||
cache: "yarn"
|
||||
- name: node_modules cache
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
cache-name: node-modules-yarn
|
||||
cache-key: ${{ env.node_version }}-${{ hashFiles('yarn.lock', 'packages/prisma/schema.prisma') }}
|
||||
with:
|
||||
path: |
|
||||
**/node_modules/**
|
||||
packages/prisma/zod
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.cache-key }}
|
||||
restore-keys: ${{ runner.os }}-${{ env.cache-name }}-
|
||||
- name: Yarn install
|
||||
shell: bash
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
yarn install --prefer-offline --frozen-lockfile
|
||||
yarn prisma generate
|
||||
|
||||
@@ -7,37 +7,6 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
name: E2E App-Store Apps
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
NEXT_PUBLIC_IS_E2E: 1
|
||||
# EMAIL_FROM: e2e@cal.com
|
||||
# EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
# EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
# EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
# EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||
# MS_GRAPH_CLIENT_ID: xxx
|
||||
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# ZOOM_CLIENT_ID: xxx
|
||||
# ZOOM_CLIENT_SECRET: xxx
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
@@ -53,13 +22,13 @@ jobs:
|
||||
- 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
|
||||
with:
|
||||
DATABASE_URL: ${{ env.DATABASE_URL }}
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn test-e2e:app-store
|
||||
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
|
||||
@@ -6,41 +6,6 @@ jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
NEXT_PUBLIC_IS_E2E: 1
|
||||
# EMAIL_FROM: e2e@cal.com
|
||||
# EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
# EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
# EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
# EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||
# MS_GRAPH_CLIENT_ID: xxx
|
||||
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# ZOOM_CLIENT_ID: xxx
|
||||
# ZOOM_CLIENT_SECRET: xxx
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
@@ -56,22 +21,23 @@ jobs:
|
||||
- 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
|
||||
with:
|
||||
DATABASE_URL: ${{ env.DATABASE_URL }}
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn turbo run embed-tests-update-snapshots:ci --scope=@calcom/embed-react --concurrency=1
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload embed-core results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results-core
|
||||
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-react
|
||||
name: test-results-embed-react
|
||||
path: packages/embeds/embed-react/playwright/results
|
||||
|
||||
@@ -4,46 +4,8 @@ on:
|
||||
jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
NEXT_PUBLIC_IS_E2E: 1
|
||||
# EMAIL_FROM: e2e@cal.com
|
||||
# EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
# EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
# EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
# EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||
# MS_GRAPH_CLIENT_ID: xxx
|
||||
# MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# ZOOM_CLIENT_ID: xxx
|
||||
# ZOOM_CLIENT_SECRET: xxx
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
name: E2E tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
@@ -52,19 +14,20 @@ jobs:
|
||||
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
|
||||
with:
|
||||
DATABASE_URL: ${{ env.DATABASE_URL }}
|
||||
- uses: ./.github/actions/cache-build
|
||||
- run: yarn test-e2e
|
||||
- name: Upload test results
|
||||
- name: Run Tests
|
||||
run: yarn test-e2e
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Create .env file
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
INPUT_ENV_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
INPUT_ENV_NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
INPUT_ENV_NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
INPUT_ENV_NEXTAUTH_SECRET: secret
|
||||
INPUT_ENV_GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
INPUT_ENV_GOOGLE_LOGIN_ENABLED: true
|
||||
# INPUT_ENV_CRON_API_KEY: xxx
|
||||
INPUT_ENV_CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
INPUT_ENV_NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
INPUT_ENV_STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
INPUT_ENV_STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
INPUT_ENV_STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
INPUT_ENV_PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
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_NEXT_PUBLIC_IS_E2E: 1
|
||||
# INPUT_ENV_EMAIL_FROM: e2e@cal.com
|
||||
# INPUT_ENV_EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
# INPUT_ENV_EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
# INPUT_ENV_EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
# INPUT_ENV_EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }}
|
||||
# INPUT_ENV_MS_GRAPH_CLIENT_ID: xxx
|
||||
# INPUT_ENV_MS_GRAPH_CLIENT_SECRET: xxx
|
||||
# INPUT_ENV_ZOOM_CLIENT_ID: xxx
|
||||
# INPUT_ENV_ZOOM_CLIENT_SECRET: xxx
|
||||
INPUT_ENV_TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
INPUT_ENV_TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
create_env_file:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ozaytsev86/create-env-file@v1
|
||||
with:
|
||||
file-name: ${{ github.workspace }}/gh.env
|
||||
- uses: actions/cache@v3
|
||||
id: env-cache
|
||||
with:
|
||||
path: gh.env
|
||||
key: env-cache-${{ hashFiles('gh.env') }}
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -32,38 +35,35 @@ jobs:
|
||||
- 'apps/web/**'
|
||||
- 'packages/embeds/**'
|
||||
|
||||
setup:
|
||||
name: Yarn install & cache
|
||||
uses: ./.github/workflows/yarn-install.yml
|
||||
env:
|
||||
name: Create env file
|
||||
uses: ./.github/workflows/env-create-file.yml
|
||||
secrets: inherit
|
||||
|
||||
type-check:
|
||||
name: Type check
|
||||
needs: setup
|
||||
uses: ./.github/workflows/check-types.yml
|
||||
secrets: inherit
|
||||
|
||||
test:
|
||||
name: Unit tests
|
||||
needs: setup
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
name: Linters
|
||||
needs: setup
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production build
|
||||
needs: setup
|
||||
needs: env
|
||||
uses: ./.github/workflows/production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
name: E2E tests
|
||||
needs: [lint, build]
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
name: E2E embeds tests
|
||||
if: ${{ needs.changes.outputs.embed == 'true' }}
|
||||
needs: [changes, lint, build]
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
|
||||
@@ -3,36 +3,6 @@ name: Production Build
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
NEXT_PUBLIC_IS_E2E: 1
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
@@ -48,38 +18,8 @@ jobs:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-build
|
||||
with:
|
||||
# GitHub Composite actions cannot read secrets, we have to pass them in here
|
||||
DATABASE_URL: ${{ env.DATABASE_URL }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ env.NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ env.NEXT_PUBLIC_WEBSITE_URL }}
|
||||
NEXTAUTH_SECRET: ${{ env.NEXTAUTH_SECRET }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ env.GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ env.GOOGLE_LOGIN_ENABLED }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ env.CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ env.DAILY_API_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ env.NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ env.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: ${{ env.NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN }}
|
||||
STRIPE_PRIVATE_KEY: ${{ env.STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ env.STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ env.STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ env.STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ env.STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ env.STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ env.PAYMENT_FEE_PERCENTAGE }}
|
||||
PAYMENT_FEE_FIXED: ${{ env.PAYMENT_FEE_FIXED }}
|
||||
SAML_DATABASE_URL: ${{ env.SAML_DATABASE_URL }}
|
||||
SAML_ADMINS: ${{ env.SAML_ADMINS }}
|
||||
NEXTAUTH_URL: ${{ env.NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ env.NEXT_PUBLIC_IS_E2E }}
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
- uses: ./.github/actions/env-read-file
|
||||
- uses: ./.github/actions/cache-db
|
||||
with:
|
||||
DATABASE_URL: ${{ env.DATABASE_URL }}
|
||||
- uses: ./.github/actions/cache-build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: TODO to Issue
|
||||
on:
|
||||
pull_request_target: # So we can test on forks
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
@@ -12,17 +12,13 @@ on:
|
||||
description: "By default, the commit entered above is compared to the one directly before it; to go back further, enter an earlier SHA here"
|
||||
required: false
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
todos:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
- uses: actions/checkout@v3
|
||||
- name: "Add TODOs to issues"
|
||||
uses: "alstr/todo-to-issue-action@v4"
|
||||
env:
|
||||
|
||||
@@ -77,9 +77,6 @@ lint-results
|
||||
#Storybook
|
||||
apps/storybook/build-storybook.log
|
||||
|
||||
# Infisical
|
||||
.infisical.json
|
||||
|
||||
# Snaplet
|
||||
.snaplet/snapshots
|
||||
.snaplet/structure.d.ts
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"workspaceId": "63ea8121b6e2b0543ba79616",
|
||||
"defaultEnvironment": "dev",
|
||||
"gitBranchToEnvironmentMapping": {
|
||||
"main": "staging",
|
||||
"production": "prod"
|
||||
}
|
||||
}
|
||||
Vendored
+12
-30
@@ -2,38 +2,20 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: Server",
|
||||
"type": "node-terminal",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Next.js: Client",
|
||||
"type": "pwa-chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: Full Stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"name": "Next.js Node Debug",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/next/dist/bin/next",
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
},
|
||||
"cwd": "${workspaceFolder}/apps/web",
|
||||
"console": "integratedTerminal",
|
||||
"serverReadyAction": {
|
||||
"pattern": "started server on .+, url: (https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
"sourceMapPathOverrides": {
|
||||
"meteor://💻app/*": "${workspaceFolder}/*",
|
||||
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
|
||||
"webpack://?:*/*": "${workspaceFolder}/*"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -128,6 +128,6 @@ If you get errors, be sure to fix them before committing.
|
||||
## Making a Pull Request
|
||||
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. Se more about [Linking a pull request to an issue
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue
|
||||
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
|
||||
@@ -350,8 +350,17 @@ We have a list of [help wanted](https://github.com/calcom/cal.com/issues?q=is:is
|
||||
<img alt="Bounties of cal" src="https://console.algora.io/api/og/cal/bounties.png?p=0&status=open&theme=light">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
<!-- CONTRIBUTORS -->
|
||||
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/calcom/cal.com/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=calcom/cal.com" />
|
||||
</a>
|
||||
|
||||
|
||||
<!-- TRANSLATIONS -->
|
||||
|
||||
### Translations
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iframe-resizer-react": "^1.1.0",
|
||||
"next": "^13.1.1",
|
||||
"next": "^13.2.1",
|
||||
"nextra": "^1.1.0",
|
||||
"nextra-theme-docs": "^1.2.2",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||
|
||||
const withTM = require("next-transpile-modules")([
|
||||
"@calcom/app-store",
|
||||
"@calcom/dayjs",
|
||||
"@calcom/emails",
|
||||
"@calcom/trpc",
|
||||
"@calcom/embed-core",
|
||||
"@calcom/embed-react",
|
||||
"@calcom/features",
|
||||
"@calcom/lib",
|
||||
"@calcom/prisma",
|
||||
"@calcom/ui",
|
||||
]);
|
||||
const glob = require("glob");
|
||||
|
||||
const plugins = [];
|
||||
plugins.push(withTM, withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
@@ -27,7 +13,18 @@ const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
experimental: { images: { allowFutureImage: true } },
|
||||
transpilePackages: [
|
||||
"@calcom/app-store",
|
||||
"@calcom/dayjs",
|
||||
"@calcom/emails",
|
||||
"@calcom/trpc",
|
||||
"@calcom/embed-core",
|
||||
"@calcom/embed-react",
|
||||
"@calcom/features",
|
||||
"@calcom/lib",
|
||||
"@calcom/prisma",
|
||||
"@calcom/ui",
|
||||
],
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@radix-ui/react-slider": "^1.0.0",
|
||||
"@radix-ui/react-switch": "^1.0.0",
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"next": "^13.1.1",
|
||||
"next": "^13.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"storybook-addon-rtl-direction": "^0.0.19"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.6.0",
|
||||
"isarray": "2.0.5",
|
||||
"next": "^13.1.1",
|
||||
"next": "^13.2.1",
|
||||
"openapi-snippet": "^0.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
|
||||
return (
|
||||
<div className={`${highlight ? "bg-yellow-100" : ""}`}>
|
||||
<div className="flex gap-x-3 p-4">
|
||||
<div className="flex gap-x-3 px-5 py-4">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
|
||||
@@ -10,7 +10,7 @@ import NavTabs from "./NavTabs";
|
||||
const tabs = [
|
||||
{
|
||||
name: "profile",
|
||||
href: "/settings/profile",
|
||||
href: "/settings/my-account/profile",
|
||||
icon: FiUser,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/v2/LicenseRequired";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
FiShield,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
/* These app slugs all require Google Cal to be installed */
|
||||
|
||||
const Component = ({
|
||||
name,
|
||||
type,
|
||||
@@ -45,6 +47,7 @@ const Component = ({
|
||||
isProOnly,
|
||||
images,
|
||||
isTemplate,
|
||||
dependencies,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t } = useLocale();
|
||||
const hasImages = images && images.length > 0;
|
||||
@@ -76,6 +79,15 @@ const Component = ({
|
||||
}
|
||||
);
|
||||
|
||||
const dependencyData = trpc.viewer.appsRouter.queryForDependencies.useQuery(dependencies, {
|
||||
enabled: !!dependencies,
|
||||
});
|
||||
|
||||
const disableInstall =
|
||||
dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed);
|
||||
|
||||
// const disableInstall = requiresGCal && !gCalInstalled.data;
|
||||
|
||||
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
|
||||
// Such apps, can only be installed once.
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
|
||||
@@ -137,6 +149,7 @@ const Component = ({
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
disableInstall={disableInstall}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
@@ -176,6 +189,7 @@ const Component = ({
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
disableInstall={disableInstall}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
@@ -203,6 +217,16 @@ const Component = ({
|
||||
) : (
|
||||
<SkeletonButton className="h-10 w-24" />
|
||||
)}
|
||||
|
||||
{dependencies &&
|
||||
(!dependencyData.isLoading ? (
|
||||
<div className="mt-6">
|
||||
<AppDependencyComponent appName={name} dependencyData={dependencyData.data} />
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonButton className="mt-6 h-20 grow" />
|
||||
))}
|
||||
|
||||
{price !== 0 && (
|
||||
<span className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
@@ -332,6 +356,8 @@ export default function App(props: {
|
||||
isProOnly: AppType["isProOnly"];
|
||||
images?: string[];
|
||||
isTemplate?: boolean;
|
||||
disableInstall?: boolean;
|
||||
dependencies?: string[];
|
||||
}) {
|
||||
return (
|
||||
<Shell smallHeading isPublic heading={<ShellHeading />} backPath="/apps" withoutSeo>
|
||||
|
||||
@@ -88,7 +88,7 @@ function CalendarSwitch(props: {
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div className="flex py-1">
|
||||
<div className="flex flex-col py-1 sm:flex-row">
|
||||
<Switch
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
@@ -99,7 +99,7 @@ function CalendarSwitch(props: {
|
||||
}}
|
||||
/>
|
||||
{!!props.destination && (
|
||||
<span className="ml-4 inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm font-normal text-gray-800">
|
||||
<span className="ml-8 inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm font-normal text-gray-800 sm:ml-4">
|
||||
<FiArrowLeft className="h-4 w-4" />
|
||||
{t("adding_events_to")}
|
||||
</span>
|
||||
@@ -184,8 +184,8 @@ function ConnectedCalendarsList(props: Props) {
|
||||
<div className="border-t border-gray-200">
|
||||
{!fromOnboarding && (
|
||||
<>
|
||||
<p className="px-4 pt-4 text-sm text-gray-500">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-2 p-4">
|
||||
<p className="px-5 pt-4 text-sm text-gray-500">{t("toggle_calendars_conflict")}</p>
|
||||
<ul className="space-y-2 px-5 py-4">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
@@ -264,7 +264,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
{!!data.connectedCalendars.length || !!installedCalendars.data?.items.length ? (
|
||||
<>
|
||||
{heading && (
|
||||
<div className="flex flex-col gap-6 rounded-md border border-gray-200 p-7">
|
||||
<div className="flex flex-col gap-6 rounded-md border p-7">
|
||||
<ShellSubHeading
|
||||
title={t("calendar")}
|
||||
subtitle={t("installed_app_calendar_description")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
import { Button, SkeletonText } from "@calcom/ui";
|
||||
import { FiMoreHorizontal } from "@calcom/ui/components/icon";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
@@ -18,17 +19,21 @@ export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between px-2 py-[23px] sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-2 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:mt-0 sm:ml-5 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText className="h-6 w-12" />
|
||||
<li>
|
||||
<div className="flex items-center justify-between py-5 hover:bg-neutral-50 ltr:pl-4 rtl:pr-4 sm:ltr:pl-0 sm:rtl:pr-0">
|
||||
<div className="items-between flex w-full flex-col justify-center hover:bg-neutral-50 sm:px-6">
|
||||
<SkeletonText className="my-1 h-4 w-32" />
|
||||
<SkeletonText className="my-1 h-2 w-24" />
|
||||
<SkeletonText className="my-1 h-2 w-40" />
|
||||
</div>
|
||||
<Button
|
||||
className="mx-5"
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon={FiMoreHorizontal}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -17,10 +19,27 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
||||
// It's possible that the location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
|
||||
const translateAbleKeys = [
|
||||
"attendee_in_person",
|
||||
"in_person",
|
||||
"attendee_phone_number",
|
||||
"link_meeting",
|
||||
"organizer_phone_number",
|
||||
];
|
||||
|
||||
const locationKey = z.string().default("").parse(locationKeyToString(location));
|
||||
|
||||
const translatedLocation = location.type.startsWith("integrations:")
|
||||
? eventLocationType.label
|
||||
: translateAbleKeys.includes(locationKey)
|
||||
? t(locationKey)
|
||||
: locationKey;
|
||||
|
||||
return (
|
||||
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||
<FiLink className="dark:text-darkgray-600 min-h-4 min-w-4 ml-[2px] opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||
) : (
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
@@ -31,14 +50,12 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={t(locationKeyToString(location) ?? "")}>
|
||||
<p className="line-clamp-1">{t(locationKeyToString(location) ?? "")}</p>
|
||||
<Tooltip content={translatedLocation}>
|
||||
<p className="line-clamp-1">{translatedLocation}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge } from "@calcom/ui";
|
||||
import { FiCheckSquare, FiClock, FiInfo } from "@calcom/ui/components/icon";
|
||||
import { FiCheckSquare, FiClock } from "@calcom/ui/components/icon";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
@@ -46,14 +46,16 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
const { profile, eventType, isBookingPage = false, children } = props;
|
||||
const { date: bookingDate } = useRouterQuery("date");
|
||||
const { t } = useLocale();
|
||||
const { duration = eventType.length.toString(), setQuery: setDuration } = useRouterQuery("duration");
|
||||
const { duration, setQuery: setDuration } = useRouterQuery("duration");
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
eventType.metadata?.multipleDuration &&
|
||||
!eventType.metadata?.multipleDuration?.includes(Number(duration))
|
||||
!duration ||
|
||||
isNaN(Number(duration)) ||
|
||||
(eventType.metadata?.multipleDuration &&
|
||||
!eventType.metadata?.multipleDuration.includes(Number(duration)))
|
||||
) {
|
||||
setDuration(eventType.length.toString());
|
||||
setDuration(eventType.length);
|
||||
}
|
||||
}, [duration, setDuration, eventType.length, eventType.metadata?.multipleDuration]);
|
||||
|
||||
@@ -94,15 +96,7 @@ const BookingDescription: FC<Props> = (props) => {
|
||||
"flex",
|
||||
isBookingPage && "dark:text-darkgray-600 text-sm font-medium text-gray-600"
|
||||
)}>
|
||||
<div>
|
||||
<FiInfo
|
||||
className={classNames(
|
||||
"ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]",
|
||||
isBookingPage && "dark:text-darkgray-600 -mt-1 text-gray-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-[calc(100%_-_2rem)] flex-shrink break-words">
|
||||
<div className="scroll-bar scrollbar-track-w-20 max-h-[200px] max-w-full flex-shrink overflow-scroll break-words [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { FiCreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
const BookingDescriptionPayment = (props: { eventType: Parameters<typeof getPaymentAppData>[0] }) => {
|
||||
const paymentAppData = getPaymentAppData(props.eventType);
|
||||
if (!paymentAppData || paymentAppData.price <= 0) return null;
|
||||
|
||||
return (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingDescriptionPayment;
|
||||
@@ -223,6 +223,8 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
const showRecordingsButtons =
|
||||
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||
|
||||
const title = decodeURIComponent(booking.title);
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -355,12 +357,12 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
<div className="cursor-pointer py-4">
|
||||
<div
|
||||
title={booking.title}
|
||||
title={title}
|
||||
className={classNames(
|
||||
"max-w-10/12 sm:max-w-56 text-sm font-medium leading-6 text-gray-900 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{booking.title}
|
||||
{title}
|
||||
<span> </span>
|
||||
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
|
||||
<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]" />
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { Controller, useForm, useWatch, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventLocationType, LocationObject } from "@calcom/app-store/locations";
|
||||
@@ -49,16 +49,19 @@ const LocationInput = (props: {
|
||||
defaultValue?: string;
|
||||
}): JSX.Element | null => {
|
||||
const { eventLocationType, locationFormMethods, ...remainingProps } = props;
|
||||
const { control } = useFormContext() as typeof locationFormMethods;
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
return (
|
||||
<input {...locationFormMethods.register(eventLocationType.variable)} type="text" {...remainingProps} />
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
return (
|
||||
<PhoneInput
|
||||
<Controller
|
||||
name={eventLocationType.variable}
|
||||
control={locationFormMethods.control}
|
||||
{...remainingProps}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return <PhoneInput onChange={onChange} value={value} {...remainingProps} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -285,16 +288,13 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}}>
|
||||
<QueryCell
|
||||
query={locationsQuery}
|
||||
success={({ data: locationOptions }) => {
|
||||
if (!locationOptions.length) return null;
|
||||
success={({ data }) => {
|
||||
if (!data.length) return null;
|
||||
const locationOptions = [...data];
|
||||
if (booking) {
|
||||
locationOptions.forEach((location) => {
|
||||
if (location.label === "phone") {
|
||||
location.options.filter((l) => l.value !== "phone");
|
||||
} else if (location.label === "in person") {
|
||||
location.options.filter((l) => l.value !== "attendeeInPerson");
|
||||
}
|
||||
});
|
||||
locationOptions.map((location) =>
|
||||
location.options.filter((l) => !["phone", "attendeeInPerson"].includes(l.value))
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Controller
|
||||
|
||||
@@ -113,7 +113,7 @@ const EventTypeScheduleDetails = () => {
|
||||
);
|
||||
|
||||
const filterDays = (dayNum: number) =>
|
||||
schedule?.schedule.availability.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||
schedule?.schedule.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded border px-6 pb-4">
|
||||
@@ -156,15 +156,17 @@ const EventTypeScheduleDetails = () => {
|
||||
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<Button
|
||||
href={`/availability/${schedule?.schedule.id}`}
|
||||
disabled={isLoading}
|
||||
color="minimal"
|
||||
EndIcon={FiExternalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
{!!schedule?.id && (
|
||||
<Button
|
||||
href={`/availability/${schedule.id}`}
|
||||
disabled={isLoading}
|
||||
color="minimal"
|
||||
EndIcon={FiExternalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { FC } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogClose, DialogFooter, DialogContent, TextField } from "@calcom/ui";
|
||||
|
||||
interface FormValues {
|
||||
customEventName: string;
|
||||
}
|
||||
|
||||
interface CustomEventTypeModalFormProps {
|
||||
placeHolder: string;
|
||||
close: () => void;
|
||||
setValue: (value: string) => void;
|
||||
event: EventNameObjectType;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const { placeHolder, close, setValue, event } = props;
|
||||
const { register, handleSubmit, watch, getValues } = useFormContext<FormValues>();
|
||||
const onSubmit: SubmitHandler<FormValues> = (data) => {
|
||||
setValue(data.customEventName);
|
||||
close();
|
||||
};
|
||||
|
||||
// const customEventName = watch("customEventName");
|
||||
const previewText = getEventName({ ...event, eventName: watch("customEventName") });
|
||||
const placeHolder_ = watch("customEventName") === "" ? previewText : placeHolder;
|
||||
|
||||
return (
|
||||
<form
|
||||
id="custom-event-name"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const isEmpty = getValues("customEventName") === "";
|
||||
if (isEmpty) {
|
||||
setValue("");
|
||||
}
|
||||
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}>
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
placeholder={placeHolder_}
|
||||
{...register("customEventName", {
|
||||
validate: (value) => validateCustomEventName(value, t("invalid_event_name_variables")),
|
||||
})}
|
||||
className="mb-0"
|
||||
/>
|
||||
<div className="pt-6 text-sm">
|
||||
<div className="mb-6 rounded-md bg-gray-100 p-2">
|
||||
<h1 className="mb-2 ml-1 font-medium text-gray-900">{t("available_variables")}</h1>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Event type title}`}</p>
|
||||
<p className="text-gray-900">{t("event_name_info")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Organiser}`}</p>
|
||||
<p className="text-gray-900">{t("your_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Scheduler}`}</p>
|
||||
<p className="text-gray-900">{t("scheduler_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-1 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Location}`}</p>
|
||||
<p className="text-gray-900">{t("location_info")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||
<div
|
||||
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: "url(/calendar-preview.svg)",
|
||||
}}>
|
||||
<div className="m-auto flex items-center justify-center self-stretch">
|
||||
<div className="mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black bg-gray-100 text-[12px] leading-3">
|
||||
<p className="overflow-hidden text-ellipsis p-1.5 font-medium text-gray-900">{previewText}</p>
|
||||
<p className="ml-1.5 text-[10px] font-normal text-gray-600">8 - 10 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomEventTypeModalProps {
|
||||
placeHolder: string;
|
||||
defaultValue: string;
|
||||
close: () => void;
|
||||
setValue: (value: string) => void;
|
||||
event: EventNameObjectType;
|
||||
}
|
||||
|
||||
const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { defaultValue, placeHolder, close, setValue, event } = props;
|
||||
|
||||
const methods = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
customEventName: defaultValue,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={close}>
|
||||
<DialogContent
|
||||
title={t("custom_event_name")}
|
||||
description={t("custom_event_name_description")}
|
||||
type="creation"
|
||||
enableOverflow>
|
||||
<FormProvider {...methods}>
|
||||
<CustomEventTypeModalForm
|
||||
event={event}
|
||||
close={close}
|
||||
setValue={setValue}
|
||||
placeHolder={placeHolder}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</FormProvider>
|
||||
<DialogFooter>
|
||||
<DialogClose>{t("cancel")}</DialogClose>
|
||||
|
||||
<Button form="custom-event-name" type="submit" color="primary">
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEventTypeModal;
|
||||
@@ -1,214 +0,0 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import type { CustomInputParsed } from "pages/event-types/[type]";
|
||||
import type { FC } from "react";
|
||||
import type { Control, UseFormRegister } from "react-hook-form";
|
||||
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Label, Select, TextField } from "@calcom/ui";
|
||||
import { FiPlus, FiX } from "@calcom/ui/components/icon";
|
||||
|
||||
interface OptionTypeBase {
|
||||
label: string;
|
||||
value: EventTypeCustomInputType;
|
||||
options?: { label: string; type: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (output: CustomInputParsed) => void;
|
||||
onCancel: () => void;
|
||||
selectedCustomInput?: CustomInputParsed;
|
||||
}
|
||||
|
||||
type IFormInput = CustomInputParsed;
|
||||
|
||||
/**
|
||||
* Getting a random ID gives us the option to know WHICH field is changed
|
||||
* when the user edits a custom field.
|
||||
* This UUID is only used to check for changes in the UI and not the ID we use in the DB
|
||||
* There is very very very slim chance that this will cause a collision
|
||||
* */
|
||||
const randomId = () => Math.floor(Math.random() * 1000000 + new Date().getTime());
|
||||
|
||||
const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const inputOptions: OptionTypeBase[] = [
|
||||
{ value: EventTypeCustomInputType.TEXT, label: t("text") },
|
||||
{ value: EventTypeCustomInputType.TEXTLONG, label: t("multiline_text") },
|
||||
{ value: EventTypeCustomInputType.NUMBER, label: t("number") },
|
||||
{ value: EventTypeCustomInputType.BOOL, label: t("checkbox") },
|
||||
{
|
||||
value: EventTypeCustomInputType.RADIO,
|
||||
label: t("radio"),
|
||||
},
|
||||
{ value: EventTypeCustomInputType.PHONE, label: t("phone_number") },
|
||||
];
|
||||
|
||||
const { selectedCustomInput } = props;
|
||||
|
||||
const defaultValues = selectedCustomInput
|
||||
? { ...selectedCustomInput, id: selectedCustomInput?.id || randomId() }
|
||||
: {
|
||||
id: randomId(),
|
||||
type: EventTypeCustomInputType.TEXT,
|
||||
};
|
||||
|
||||
const { register, control, getValues } = useForm<IFormInput>({
|
||||
defaultValues,
|
||||
});
|
||||
const selectedInputType = useWatch({ name: "type", control });
|
||||
const selectedInputOption = inputOptions.find((e) => selectedInputType === e.value);
|
||||
|
||||
const onCancel = () => {
|
||||
props.onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
{t("input_type")}
|
||||
</label>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
id="type"
|
||||
defaultValue={selectedInputOption}
|
||||
options={inputOptions}
|
||||
isSearchable={false}
|
||||
className="mt-1 mb-2 block w-full min-w-0 flex-1 text-sm"
|
||||
onChange={(option) => option && field.onChange(option.value)}
|
||||
value={selectedInputOption}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label={t("label")}
|
||||
type="text"
|
||||
id="label"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.label}
|
||||
{...register("label", { required: true })}
|
||||
/>
|
||||
{(selectedInputType === EventTypeCustomInputType.TEXT ||
|
||||
selectedInputType === EventTypeCustomInputType.TEXTLONG) && (
|
||||
<TextField
|
||||
label={t("placeholder")}
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
)}
|
||||
{selectedInputType === EventTypeCustomInputType.RADIO && (
|
||||
<RadioInputHandler control={control} register={register} />
|
||||
)}
|
||||
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="required"
|
||||
type="checkbox"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300 ltr:mr-2 rtl:ml-2"
|
||||
defaultChecked={selectedCustomInput?.required ?? true}
|
||||
{...register("required")}
|
||||
/>
|
||||
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||
{t("is_required")}
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
id="eventTypeId"
|
||||
value={selectedCustomInput?.eventTypeId || -1}
|
||||
{...register("eventTypeId", { valueAsNumber: true })}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
id="id"
|
||||
value={selectedCustomInput?.id || -1}
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 flex justify-end space-x-2 rtl:space-x-reverse sm:mt-4">
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="ltr:mr-2 rtl:ml-2">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.onSubmit(getValues());
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function RadioInputHandler({
|
||||
register,
|
||||
control,
|
||||
}: {
|
||||
register: UseFormRegister<IFormInput>;
|
||||
control: Control<IFormInput>;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { fields, append, remove } = useFieldArray<IFormInput>({
|
||||
control,
|
||||
name: "options",
|
||||
shouldUnregister: true,
|
||||
});
|
||||
const [animateRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col ">
|
||||
<Label htmlFor="radio_options">{t("options")}</Label>
|
||||
<ul
|
||||
className="flex max-h-80 w-full flex-col space-y-1 overflow-y-scroll rounded-md bg-gray-50 p-4"
|
||||
ref={animateRef}>
|
||||
<>
|
||||
{fields.map((option, index) => (
|
||||
<li key={`${option.id}`}>
|
||||
<TextField
|
||||
id={option.id}
|
||||
placeholder={t("enter_option", { index: index + 1 })}
|
||||
addOnFilled={false}
|
||||
label={t("option", { index: index + 1 })}
|
||||
labelSrOnly
|
||||
{...register(`options.${index}.label` as const, { required: true })}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
StartIcon={FiX}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={FiPlus}
|
||||
className="!text-sm !font-medium"
|
||||
onClick={() => {
|
||||
append({ label: "", type: "text" });
|
||||
}}>
|
||||
{t("add_an_option")}
|
||||
</Button>
|
||||
</>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomInputTypeForm;
|
||||
@@ -1,5 +1,6 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import type { CustomInputParsed, EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import short from "short-uuid";
|
||||
@@ -8,30 +9,17 @@ import { v5 as uuidv5 } from "uuid";
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import CustomInputItem from "@calcom/features/eventtypes/components/CustomInputItem";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Label,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import { FiEdit, FiCopy, FiPlus } from "@calcom/ui/components/icon";
|
||||
|
||||
import CustomInputTypeForm from "@components/eventtype/CustomInputTypeForm";
|
||||
import { Badge, Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip } from "@calcom/ui";
|
||||
import { FiEdit, FiCopy } from "@calcom/ui/components/icon";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal"));
|
||||
|
||||
const generateHashedLink = (id: number) => {
|
||||
const translator = short();
|
||||
const seed = `${id}:${new Date().getTime()}`;
|
||||
@@ -39,22 +27,11 @@ const generateHashedLink = (id: number) => {
|
||||
return uid;
|
||||
};
|
||||
|
||||
const getRandomId = (length = 8) => {
|
||||
return (
|
||||
-1 *
|
||||
parseInt(
|
||||
Math.ceil(Math.random() * Date.now())
|
||||
.toPrecision(length)
|
||||
.toString()
|
||||
.replace(".", "")
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
|
||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||
@@ -66,44 +43,39 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
host: eventType.users[0]?.name || "Nameless",
|
||||
t,
|
||||
};
|
||||
const [previewText, setPreviewText] = useState(getEventName(eventNameObject));
|
||||
const [customInputs, setCustomInputs] = useState<CustomInputParsed[]>(
|
||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
);
|
||||
const [selectedCustomInput, setSelectedCustomInput] = useState<CustomInputParsed | undefined>(undefined);
|
||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
|
||||
const removeCustom = (index: number) => {
|
||||
formMethods.getValues("customInputs").splice(index, 1);
|
||||
customInputs.splice(index, 1);
|
||||
setCustomInputs([...customInputs]);
|
||||
};
|
||||
|
||||
const replaceEventNamePlaceholder = (eventNameObject: EventNameObjectType, previewEventName: string) =>
|
||||
previewEventName
|
||||
.replace("{Event type title}", eventNameObject.eventType)
|
||||
.replace("{Scheduler}", eventNameObject.attendeeName)
|
||||
.replace("{Organiser}", eventNameObject.host);
|
||||
|
||||
const changePreviewText = (eventNameObject: EventNameObjectType, previewEventName: string) => {
|
||||
setPreviewText(replaceEventNamePlaceholder(eventNameObject, previewEventName));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||
}, [eventType.users, hashedUrl, team?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventType.customInputs) {
|
||||
setCustomInputs(eventType.customInputs.sort((a, b) => a.id - b.id));
|
||||
}
|
||||
}, [eventType.customInputs]);
|
||||
const toggleGuests = (enabled: boolean) => {
|
||||
const bookingFields = formMethods.getValues("bookingFields");
|
||||
formMethods.setValue(
|
||||
"bookingFields",
|
||||
bookingFields.map((field) => {
|
||||
if (field.name === "guests") {
|
||||
return {
|
||||
...field,
|
||||
hidden: !enabled,
|
||||
};
|
||||
}
|
||||
return field;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const eventNamePlaceholder = replaceEventNamePlaceholder(eventNameObject, t("meeting_with_user"));
|
||||
const eventNamePlaceholder = getEventName({
|
||||
...eventNameObject,
|
||||
eventName: formMethods.watch("eventName"),
|
||||
});
|
||||
|
||||
const closeEventNameTip = () => setShowEventNameTip(false);
|
||||
|
||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||
return (
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/**
|
||||
@@ -146,14 +118,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
type="text"
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName", {
|
||||
onChange: (e) => {
|
||||
if (!e.target.value || !formMethods.getValues("eventName")) {
|
||||
return setPreviewText(getEventName(eventNameObject));
|
||||
}
|
||||
changePreviewText(eventNameObject, e.target.value);
|
||||
},
|
||||
})}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -162,53 +127,18 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
color="minimal"
|
||||
className="hover:stroke-3 min-w-fit px-0 hover:bg-transparent hover:text-black"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}
|
||||
aria-label="edit custom name"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="">
|
||||
<SettingsToggle
|
||||
title={t("additional_inputs")}
|
||||
description={t("additional_input_description")}
|
||||
checked={customInputs.length > 0}
|
||||
onCheckedChange={(e) => {
|
||||
if (e && customInputs.length === 0) {
|
||||
// Push a placeholders
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
} else if (!e) {
|
||||
formMethods.setValue("customInputs", []);
|
||||
}
|
||||
}}>
|
||||
<ul className="my-4 rounded-md border">
|
||||
{customInputs.map((customInput, idx) => (
|
||||
<CustomInputItem
|
||||
key={idx}
|
||||
question={customInput.label}
|
||||
type={customInput.type}
|
||||
required={customInput.required}
|
||||
editOnClick={() => {
|
||||
setSelectedCustomInput(customInput);
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
}}
|
||||
deleteOnClick={() => removeCustom(idx)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{customInputs.length > 0 && (
|
||||
<Button
|
||||
StartIcon={FiPlus}
|
||||
color="minimal"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCustomInput(undefined);
|
||||
setSelectedCustomInputModalOpen(true);
|
||||
}}>
|
||||
{t("add_input")}
|
||||
</Button>
|
||||
)}
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
/>
|
||||
<hr />
|
||||
<RequiresConfirmationController
|
||||
seatsEnabled={seatsEnabled}
|
||||
@@ -216,22 +146,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="disableGuests"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.disableGuests}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("disable_guests")}
|
||||
description={t("disable_guests_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
disabled={seatsEnabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
@@ -247,22 +161,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="metadata.additionalNotesRequired"
|
||||
control={formMethods.control}
|
||||
defaultValue={!!eventType.metadata.additionalNotesRequired}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex space-x-3 ">
|
||||
<SettingsToggle
|
||||
title={t("require_additional_notes")}
|
||||
description={t("require_additional_notes_description")}
|
||||
checked={!!value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
control={formMethods.control}
|
||||
@@ -363,13 +261,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
onCheckedChange={(e) => {
|
||||
// Enabling seats will disable guests and requiring confirmation until fully supported
|
||||
if (e) {
|
||||
formMethods.setValue("disableGuests", true);
|
||||
toggleGuests(false);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
setRequiresConfirmation(false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
formMethods.setValue("disableGuests", false);
|
||||
toggleGuests(true);
|
||||
}
|
||||
onChange(e);
|
||||
}}>
|
||||
@@ -407,130 +305,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
/>
|
||||
|
||||
{showEventNameTip && (
|
||||
<Dialog open={showEventNameTip} onOpenChange={setShowEventNameTip}>
|
||||
<DialogContent
|
||||
title={t("custom_event_name")}
|
||||
description={t("custom_event_name_description")}
|
||||
type="creation"
|
||||
enableOverflow>
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName", {
|
||||
onChange: (e) => {
|
||||
if (!e.target.value || !formMethods.getValues("eventName")) {
|
||||
return setPreviewText(getEventName(eventNameObject));
|
||||
}
|
||||
changePreviewText(eventNameObject, e.target.value);
|
||||
},
|
||||
})}
|
||||
className="mb-0"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="mb-6 rounded-md bg-gray-100 p-2">
|
||||
<h1 className="mb-2 ml-1 font-medium text-gray-900">{t("available_variables")}</h1>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Event type title}`}</p>
|
||||
<p className="text-gray-900">{t("event_name_info")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Organiser}`}</p>
|
||||
<p className="text-gray-900">{t("your_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Scheduler}`}</p>
|
||||
<p className="text-gray-900">{t("scheduler_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-1 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Location}`}</p>
|
||||
<p className="text-gray-900">{t("location_info")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||
<div
|
||||
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: "url(/calendar-preview.svg)",
|
||||
}}>
|
||||
<div className="m-auto flex items-center justify-center self-stretch">
|
||||
<div className="mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black bg-gray-100 text-[12px] leading-3">
|
||||
<p className="overflow-hidden text-ellipsis p-1.5 font-medium text-gray-900">
|
||||
{previewText}
|
||||
</p>
|
||||
<p className="ml-1.5 text-[10px] font-normal text-gray-600">8 - 10 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CustomEventTypeModal
|
||||
close={closeEventNameTip}
|
||||
setValue={setEventName}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
placeHolder={eventNamePlaceholder}
|
||||
event={eventNameObject}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
name="customInputs"
|
||||
control={formMethods.control}
|
||||
defaultValue={customInputs}
|
||||
render={() => (
|
||||
<Dialog open={selectedCustomInputModalOpen} onOpenChange={setSelectedCustomInputModalOpen}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
Icon={FiPlus}
|
||||
title={t("add_new_custom_input_field")}
|
||||
description={t("this_input_will_shown_booking_this_event")}>
|
||||
<CustomInputTypeForm
|
||||
selectedCustomInput={selectedCustomInput}
|
||||
onSubmit={(values) => {
|
||||
const customInput: CustomInputParsed = {
|
||||
id: getRandomId(),
|
||||
eventTypeId: -1,
|
||||
label: values.label,
|
||||
placeholder: values.placeholder,
|
||||
required: values.required,
|
||||
type: values.type,
|
||||
options: values.options,
|
||||
hasToBeCreated: true,
|
||||
};
|
||||
if (selectedCustomInput) {
|
||||
selectedCustomInput.label = customInput.label;
|
||||
selectedCustomInput.placeholder = customInput.placeholder;
|
||||
selectedCustomInput.required = customInput.required;
|
||||
selectedCustomInput.type = customInput.type;
|
||||
selectedCustomInput.options = customInput.options || undefined;
|
||||
selectedCustomInput.hasToBeCreated = false;
|
||||
// Update by id
|
||||
const inputIndex = customInputs.findIndex((input) => input.id === values.id);
|
||||
customInputs[inputIndex] = selectedCustomInput;
|
||||
setCustomInputs(customInputs);
|
||||
formMethods.setValue("customInputs", customInputs);
|
||||
} else {
|
||||
const concatted = customInputs.concat({
|
||||
...customInput,
|
||||
options: customInput.options,
|
||||
});
|
||||
console.log(concatted);
|
||||
setCustomInputs(concatted);
|
||||
formMethods.setValue("customInputs", concatted);
|
||||
}
|
||||
|
||||
setSelectedCustomInputModalOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setSelectedCustomInputModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,8 +13,10 @@ import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
import { Button, Label, Select, SettingsToggle, Skeleton, TextField } from "@calcom/ui";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { Button, Editor, Label, Select, SettingsToggle, Skeleton, TextField } from "@calcom/ui";
|
||||
import { FiEdit2, FiCheck, FiX, FiPlus } from "@calcom/ui/components/icon";
|
||||
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
@@ -44,11 +46,14 @@ const getDefaultLocationValue = (options: EventTypeSetupProps["locationOptions"]
|
||||
};
|
||||
|
||||
export const EventSetupTab = (
|
||||
props: Pick<EventTypeSetupProps, "eventType" | "locationOptions" | "team" | "teamMembers">
|
||||
props: Pick<
|
||||
EventTypeSetupProps,
|
||||
"eventType" | "locationOptions" | "team" | "teamMembers" | "destinationCalendar"
|
||||
>
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType, locationOptions, team } = props;
|
||||
const { eventType, locationOptions, team, destinationCalendar } = props;
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||
@@ -187,11 +192,9 @@ export const EventSetupTab = (
|
||||
if (!eventLocationType) {
|
||||
return null;
|
||||
}
|
||||
// We dont want to translate the string link - it doesnt exist in common.json and it gets prefixed/suffixed with __ or //
|
||||
|
||||
const eventLabel =
|
||||
eventLocationType.defaultValueVariable === "link"
|
||||
? eventLocationType.label
|
||||
: t(location[eventLocationType.defaultValueVariable] || eventLocationType.label);
|
||||
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -229,7 +232,10 @@ export const EventSetupTab = (
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{validLocations.some((location) => location.type === MeetLocationType) && (
|
||||
{validLocations.some(
|
||||
(location) =>
|
||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
||||
) && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<FiCheck className="mt-0.5 mr-1.5 h-2 w-2.5" />
|
||||
<Trans i18nKey="event_type_requres_google_cal">
|
||||
@@ -241,7 +247,6 @@ export const EventSetupTab = (
|
||||
className="underline">
|
||||
here.
|
||||
</Link>{" "}
|
||||
We will fall back to Cal video if you do not change it.
|
||||
</p>
|
||||
</Trans>
|
||||
</div>
|
||||
@@ -272,12 +277,15 @@ export const EventSetupTab = (
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
<TextField
|
||||
label={t("description")}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
defaultValue={eventType.description ?? ""}
|
||||
{...formMethods.register("description")}
|
||||
/>
|
||||
<div>
|
||||
<Label>{t("description")}</Label>
|
||||
<Editor
|
||||
getText={() => md.render(formMethods.getValues("description") || eventType.description || "")}
|
||||
setText={(value: string) => formMethods.setValue("description", turndown(value))}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
required
|
||||
label={t("URL")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import type { SchedulingType } from "@prisma/client";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { Control } from "react-hook-form";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { Options } from "react-select";
|
||||
|
||||
@@ -35,65 +35,169 @@ const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof
|
||||
return 0;
|
||||
};
|
||||
|
||||
const FixedHosts = ({
|
||||
control,
|
||||
const CheckedHostField = ({
|
||||
labelText,
|
||||
placeholder,
|
||||
options = [],
|
||||
isFixed,
|
||||
value,
|
||||
onChange,
|
||||
...rest
|
||||
}: {
|
||||
control: Control<FormValues>;
|
||||
labelText: string;
|
||||
placeholder: string;
|
||||
isFixed: boolean;
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
} & Partial<ComponentProps<typeof CheckedTeamSelect>>) => {
|
||||
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-5 bg-gray-50 p-4">
|
||||
<div>
|
||||
<Label>{labelText}</Label>
|
||||
<Controller
|
||||
name="hostsFixed"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<CheckedTeamSelect
|
||||
isDisabled={false}
|
||||
onChange={(options) => {
|
||||
onChange(
|
||||
options.map((option) => ({
|
||||
isFixed: true,
|
||||
userId: parseInt(option.value, 10),
|
||||
}))
|
||||
);
|
||||
}}
|
||||
value={value
|
||||
.map(
|
||||
(host) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.find((member) => member.value === host.userId.toString())!
|
||||
)
|
||||
.filter(Boolean)}
|
||||
controlShouldRenderValue={false}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
<CheckedTeamSelect
|
||||
isOptionDisabled={(option) => !!value.find((host) => host.userId.toString() === option.value)}
|
||||
onChange={(options) => {
|
||||
onChange &&
|
||||
onChange(
|
||||
options.map((option) => ({
|
||||
isFixed,
|
||||
userId: parseInt(option.value, 10),
|
||||
}))
|
||||
);
|
||||
}}
|
||||
value={(value || [])
|
||||
.filter(({ isFixed: _isFixed }) => isFixed === _isFixed)
|
||||
.map(
|
||||
(host) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.find((member) => member.value === host.userId.toString())!
|
||||
)
|
||||
.filter(Boolean)}
|
||||
controlShouldRenderValue={false}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "teamMembers" | "team">) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const RoundRobinHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange: (hosts: { isFixed: boolean; userId: number }[]) => void;
|
||||
teamMembers: {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}[];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
isFixed={true}
|
||||
onChange={(changeValue) => {
|
||||
onChange([...value.filter(({ isFixed }) => !isFixed), ...changeValue]);
|
||||
}}
|
||||
value={value}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
/>
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
onChange={(changeValue) => onChange([...value.filter(({ isFixed }) => isFixed), ...changeValue])}
|
||||
value={value}
|
||||
isFixed={false}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("round_robin_hosts")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Hosts = ({
|
||||
teamMembers,
|
||||
}: {
|
||||
teamMembers: {
|
||||
value: string;
|
||||
label: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
}[];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
control,
|
||||
resetField,
|
||||
getValues,
|
||||
formState: { submitCount },
|
||||
} = useFormContext<FormValues>();
|
||||
const schedulingType = useWatch({
|
||||
control: formMethods.control,
|
||||
control,
|
||||
name: "schedulingType",
|
||||
});
|
||||
const initialValue = useRef<{
|
||||
hosts: FormValues["hosts"];
|
||||
schedulingType: SchedulingType | null;
|
||||
submitCount: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Handles init & out of date initial value after submission.
|
||||
if (!initialValue.current || initialValue.current?.submitCount !== submitCount) {
|
||||
initialValue.current = { hosts: getValues("hosts"), schedulingType, submitCount };
|
||||
return;
|
||||
}
|
||||
resetField("hosts", {
|
||||
defaultValue: initialValue.current.schedulingType === schedulingType ? initialValue.current.hosts : [],
|
||||
});
|
||||
}, [schedulingType, resetField, getValues, submitCount]);
|
||||
|
||||
return (
|
||||
<Controller<FormValues>
|
||||
name="hosts"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const schedulingTypeRender = {
|
||||
COLLECTIVE: (
|
||||
<CheckedHostField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isFixed={true}
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("team")}
|
||||
/>
|
||||
),
|
||||
ROUND_ROBIN: (
|
||||
<>
|
||||
<RoundRobinHosts teamMembers={teamMembers} onChange={onChange} value={value} />
|
||||
{/*<TextField
|
||||
required
|
||||
type="number"
|
||||
label={t("minimum_round_robin_hosts_count")}
|
||||
defaultValue={1}
|
||||
{...formMethods.register("minimumHostCount")}
|
||||
addOnSuffix={<>{t("hosts")}</>}
|
||||
/>*/}
|
||||
</>
|
||||
),
|
||||
};
|
||||
return !!schedulingType ? schedulingTypeRender[schedulingType] : <></>;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "teamMembers" | "team">) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const schedulingTypeOptions: {
|
||||
value: SchedulingType;
|
||||
@@ -101,17 +205,16 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||
// description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: SchedulingType.COLLECTIVE,
|
||||
value: "COLLECTIVE",
|
||||
label: t("collective"),
|
||||
// description: t("collective_description"),
|
||||
},
|
||||
{
|
||||
value: SchedulingType.ROUND_ROBIN,
|
||||
value: "ROUND_ROBIN",
|
||||
label: t("round_robin"),
|
||||
// description: t("round_robin_description"),
|
||||
},
|
||||
];
|
||||
|
||||
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
||||
return (
|
||||
<div>
|
||||
@@ -119,9 +222,8 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<Label>{t("scheduling_type")}</Label>
|
||||
<Controller
|
||||
<Controller<FormValues>
|
||||
name="schedulingType"
|
||||
control={formMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
options={schedulingTypeOptions}
|
||||
@@ -134,75 +236,7 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{schedulingType === SchedulingType.COLLECTIVE && (
|
||||
<FixedHosts
|
||||
options={teamMembersOptions.sort(sortByLabel)}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("team")}
|
||||
control={formMethods.control}
|
||||
/>
|
||||
)}
|
||||
{schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||
<>
|
||||
<FixedHosts
|
||||
options={teamMembersOptions.sort(sortByLabel)}
|
||||
isOptionDisabled={(option) =>
|
||||
!!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value)
|
||||
}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
control={formMethods.control}
|
||||
/>
|
||||
<div className="flex flex-col space-y-5 bg-gray-50 p-4">
|
||||
<div>
|
||||
<Label>{t("round_robin_hosts")}</Label>
|
||||
<Controller
|
||||
name="hosts"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CheckedTeamSelect
|
||||
isDisabled={false}
|
||||
onChange={(options) =>
|
||||
onChange(
|
||||
options.map((option) => ({
|
||||
isFixed: false,
|
||||
userId: parseInt(option.value, 10),
|
||||
}))
|
||||
)
|
||||
}
|
||||
value={value
|
||||
.map(
|
||||
(host) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
teamMembers
|
||||
.map(mapUserToValue)
|
||||
.find((member) => member.value === host.userId.toString())!
|
||||
)
|
||||
.filter(Boolean)}
|
||||
controlShouldRenderValue={false}
|
||||
options={teamMembersOptions.sort(sortByLabel)}
|
||||
isOptionDisabled={(option) =>
|
||||
!!formMethods
|
||||
.getValues("hostsFixed")
|
||||
.find((host) => host.userId.toString() === option.value)
|
||||
}
|
||||
placeholder={t("add_attendees")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/*<TextField
|
||||
required
|
||||
type="number"
|
||||
label={t("minimum_round_robin_hosts_count")}
|
||||
defaultValue={1}
|
||||
{...formMethods.register("minimumHostCount")}
|
||||
addOnSuffix={<>{t("hosts")}</>}
|
||||
/>*/}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Hosts teamMembers={teamMembersOptions} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -220,6 +220,7 @@ function EventTypeSingleLayout({
|
||||
<Tooltip content={t("preview")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid="preview-button"
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
href={permalink}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type z from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Alert, Input, Label, SettingsToggle } from "@calcom/ui";
|
||||
import { Input, Label, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
type RequiresConfirmationControllerProps = {
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
@@ -39,118 +39,116 @@ export default function RequiresConfirmationController({
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className={!seatsEnabled ? "w-full" : ""}>
|
||||
{seatsEnabled ? (
|
||||
<Alert severity="warning" title="Seats option doesn't support confirmation requirement" />
|
||||
) : (
|
||||
<Controller
|
||||
name="requiresConfirmation"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("requires_confirmation")}
|
||||
description={t("requires_confirmation_description")}
|
||||
checked={requiresConfirmation}
|
||||
onCheckedChange={(val) => {
|
||||
formMethods.setValue("requiresConfirmation", val);
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="requiresConfirmation"
|
||||
control={formMethods.control}
|
||||
render={() => (
|
||||
<SettingsToggle
|
||||
title={t("requires_confirmation")}
|
||||
disabled={seatsEnabled}
|
||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||
description={t("requires_confirmation_description")}
|
||||
checked={requiresConfirmation}
|
||||
onCheckedChange={(val) => {
|
||||
formMethods.setValue("requiresConfirmation", val);
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={
|
||||
requiresConfirmation
|
||||
? requiresConfirmationSetup === undefined
|
||||
? "always"
|
||||
: "notice"
|
||||
: undefined
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||
);
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "always") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
|
||||
setRequiresConfirmationSetup(undefined);
|
||||
} else if (val === "notice") {
|
||||
formMethods.setValue("requiresConfirmation", true);
|
||||
onRequiresConfirmation(true);
|
||||
formMethods.setValue(
|
||||
"metadata.requiresConfirmationThreshold",
|
||||
requiresConfirmationSetup || defaultRequiresConfirmationSetup
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<div className="-ml-2 flex flex-col flex-wrap justify-start gap-y-2 space-y-2">
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
id="always"
|
||||
value="always"
|
||||
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor="always" className="!m-0 flex items-center">
|
||||
{t("always_requires_confirmation")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
id="notice"
|
||||
value="notice"
|
||||
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor="notice" className="!m-0 flex items-center">
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold.time", val);
|
||||
}}
|
||||
className="!m-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<select
|
||||
onChange={(evt) => {
|
||||
const val = evt.target.value as UnitTypeLongPlural;
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: val,
|
||||
});
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold.unit", val);
|
||||
}}
|
||||
className="ml-2 block h-9 rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
|
||||
defaultValue={
|
||||
metadata?.requiresConfirmationThreshold?.unit ||
|
||||
defaultRequiresConfirmationSetup.unit
|
||||
}>
|
||||
<option value="minutes">{t("minute_timeUnit")}</option>
|
||||
<option value="hours">{t("hour_timeUnit")}</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
}}>
|
||||
<div className="-ml-2 flex flex-col flex-wrap justify-start gap-y-2 space-y-2">
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
id="always"
|
||||
value="always"
|
||||
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor="always" className="!m-0 flex items-center">
|
||||
{t("always_requires_confirmation")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<RadioGroup.Item
|
||||
id="notice"
|
||||
value="notice"
|
||||
className="min-w-4 flex h-4 w-4 cursor-pointer items-center rounded-full border border-black bg-white focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor="notice" className="!m-0 flex items-center">
|
||||
<Trans
|
||||
i18nKey="when_booked_with_less_than_notice"
|
||||
defaults="When booked with less than <time></time> notice"
|
||||
components={{
|
||||
time: (
|
||||
<div className="mx-2 flex">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(evt) => {
|
||||
const val = Number(evt.target?.value);
|
||||
setRequiresConfirmationSetup({
|
||||
unit:
|
||||
requiresConfirmationSetup?.unit ??
|
||||
defaultRequiresConfirmationSetup.unit,
|
||||
time: val,
|
||||
});
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold.time", val);
|
||||
}}
|
||||
className="!m-0 block w-16 rounded-md border-gray-300 text-sm [appearance:textfield]"
|
||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||
/>
|
||||
<select
|
||||
onChange={(evt) => {
|
||||
const val = evt.target.value as UnitTypeLongPlural;
|
||||
setRequiresConfirmationSetup({
|
||||
time:
|
||||
requiresConfirmationSetup?.time ??
|
||||
defaultRequiresConfirmationSetup.time,
|
||||
unit: val,
|
||||
});
|
||||
formMethods.setValue("metadata.requiresConfirmationThreshold.unit", val);
|
||||
}}
|
||||
className="ml-2 block h-9 rounded-md border-gray-300 py-2 pl-3 pr-10 text-sm focus:outline-none"
|
||||
defaultValue={
|
||||
metadata?.requiresConfirmationThreshold?.unit ||
|
||||
defaultRequiresConfirmationSetup.unit
|
||||
}>
|
||||
<option value="minutes">{t("minute_timeUnit")}</option>
|
||||
<option value="hours">{t("hour_timeUnit")}</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/solid";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { useRouter } from "next/router";
|
||||
import type { FormEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -14,8 +14,6 @@ import { Avatar } from "@calcom/ui";
|
||||
|
||||
import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]";
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true });
|
||||
|
||||
type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ShieldCheckIcon } from "@calcom/ui/components/icon";
|
||||
import { FiShield } from "@calcom/ui/components/icon";
|
||||
|
||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ShieldCheckIcon className="h-6 w-6 text-white" />
|
||||
<FiShield className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="font-cal text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
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 { Avatar } from "@calcom/ui";
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
type TeamType = TeamPageProps["team"];
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number];
|
||||
@@ -19,7 +17,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
|
||||
|
||||
return (
|
||||
<Link key={member.id} href={`/${member.username}`}>
|
||||
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
|
||||
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={member.name || ""}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { StarIcon as StarIconSolid } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import { debounce, noop } from "lodash";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -14,7 +15,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogHeader, Input, Label } from "@calcom/ui";
|
||||
import { FiCheck, FiEdit2, FiExternalLink, StarIconSolid } from "@calcom/ui/components/icon";
|
||||
import { FiCheck, FiEdit2, FiExternalLink } from "@calcom/ui/components/icon";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
UPGRADE = "UPGRADE",
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
User,
|
||||
VerificationToken,
|
||||
} from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
|
||||
import { identityProviderNameMap } from "@lib/auth";
|
||||
|
||||
|
||||
@@ -1,7 +1,56 @@
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import type { z } from "zod";
|
||||
|
||||
async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
import { getBookingResponsesPartialSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
|
||||
type BookingSelect = {
|
||||
description: true;
|
||||
customInputs: true;
|
||||
attendees: {
|
||||
select: {
|
||||
email: true;
|
||||
name: true;
|
||||
};
|
||||
};
|
||||
location: true;
|
||||
smsReminderNumber: true;
|
||||
};
|
||||
|
||||
// Backward Compatibility for booking created before we had managed booking questions
|
||||
function getResponsesFromOldBooking(
|
||||
rawBooking: Prisma.BookingGetPayload<{
|
||||
select: BookingSelect;
|
||||
}>
|
||||
) {
|
||||
const customInputs = rawBooking.customInputs || {};
|
||||
const responses = Object.keys(customInputs).reduce((acc, label) => {
|
||||
acc[slugify(label) as keyof typeof acc] = customInputs[label as keyof typeof customInputs];
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
// It is possible to have no attendees in a booking when the booking is cancelled.
|
||||
name: rawBooking.attendees[0]?.name || "Nameless",
|
||||
email: rawBooking.attendees[0]?.email || "",
|
||||
guests: rawBooking.attendees.slice(1).map((attendee) => {
|
||||
return attendee.email;
|
||||
}),
|
||||
notes: rawBooking.description || "",
|
||||
location: {
|
||||
value: rawBooking.location || "",
|
||||
optionValue: rawBooking.location || "",
|
||||
},
|
||||
...responses,
|
||||
};
|
||||
}
|
||||
|
||||
async function getBooking(
|
||||
prisma: PrismaClient,
|
||||
uid: string,
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">
|
||||
) {
|
||||
const rawBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
@@ -9,6 +58,7 @@ async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
startTime: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
responses: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
attendees: {
|
||||
@@ -20,6 +70,14 @@ async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!rawBooking) {
|
||||
return rawBooking;
|
||||
}
|
||||
|
||||
const booking = getBookingWithResponses(rawBooking, {
|
||||
bookingFields,
|
||||
});
|
||||
|
||||
if (booking) {
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
@@ -31,4 +89,23 @@ async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
|
||||
export type GetBookingType = Prisma.PromiseReturnType<typeof getBooking>;
|
||||
|
||||
export const getBookingWithResponses = <
|
||||
T extends Prisma.BookingGetPayload<{
|
||||
select: BookingSelect & {
|
||||
responses: true;
|
||||
};
|
||||
}>
|
||||
>(
|
||||
booking: T,
|
||||
eventType: {
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">;
|
||||
}
|
||||
) => {
|
||||
return {
|
||||
...booking,
|
||||
responses: getBookingResponsesPartialSchema({
|
||||
bookingFields: eventType.bookingFields,
|
||||
}).parse(booking.responses || getResponsesFromOldBooking(booking)),
|
||||
};
|
||||
};
|
||||
export default getBooking;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import type { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
||||
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const createBooking = async (data: BookingCreateBody) => {
|
||||
const response = await fetch.post<BookingCreateBody, BookingResponse>("/api/book/event", data);
|
||||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import type { BookingResponse } from "@lib/types/booking";
|
||||
|
||||
type BookingCreateBodyForMutation = Omit<BookingCreateBody, "location">;
|
||||
const createBooking = async (data: BookingCreateBodyForMutation) => {
|
||||
const response = await fetch.post<BookingCreateBodyForMutation, BookingResponse>("/api/book/event", data);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { BookingCreateBody } from "@calcom/prisma/zod-utils";
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import type { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
||||
import type { BookingResponse } from "@lib/types/booking";
|
||||
|
||||
type ExtendedBookingCreateBody = BookingCreateBody & {
|
||||
noEmail?: boolean;
|
||||
|
||||
@@ -2,36 +2,6 @@ import type { Attendee, Booking } from "@prisma/client";
|
||||
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
export type BookingCreateBody = {
|
||||
email: string;
|
||||
end: string;
|
||||
web3Details?: {
|
||||
userWallet: string;
|
||||
userSignature: unknown;
|
||||
};
|
||||
eventTypeId: number;
|
||||
eventTypeSlug: string;
|
||||
guests?: string[];
|
||||
location: string;
|
||||
name: string;
|
||||
notes?: string;
|
||||
rescheduleUid?: string;
|
||||
recurringEventId?: string;
|
||||
start: string;
|
||||
timeZone: string;
|
||||
user?: string | string[];
|
||||
language: string;
|
||||
bookingUid?: string;
|
||||
customInputs: { label: string; value: string | boolean }[];
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
hasHashedBookingLink: boolean;
|
||||
hashedLink?: string | null;
|
||||
smsReminderNumber?: string;
|
||||
ethSignature?: string;
|
||||
};
|
||||
|
||||
export type BookingResponse = Booking & {
|
||||
paymentUid?: string;
|
||||
attendees: Attendee[];
|
||||
|
||||
+25
-21
@@ -2,20 +2,6 @@ require("dotenv").config({ path: "../../.env" });
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
const os = require("os");
|
||||
const withTM = require("next-transpile-modules")([
|
||||
"@calcom/app-store",
|
||||
"@calcom/core",
|
||||
"@calcom/dayjs",
|
||||
"@calcom/emails",
|
||||
"@calcom/embed-core",
|
||||
"@calcom/embed-react",
|
||||
"@calcom/embed-snippet",
|
||||
"@calcom/features",
|
||||
"@calcom/lib",
|
||||
"@calcom/prisma",
|
||||
"@calcom/trpc",
|
||||
"@calcom/ui",
|
||||
]);
|
||||
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { i18n } = require("./next-i18next.config");
|
||||
@@ -80,7 +66,6 @@ if (process.env.ANALYZE === "true") {
|
||||
plugins.push(withBundleAnalyzer);
|
||||
}
|
||||
|
||||
plugins.push(withTM);
|
||||
plugins.push(withAxiom);
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
@@ -94,12 +79,31 @@ const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: !!process.env.CI,
|
||||
},
|
||||
// TODO: We need to have all components in `@calcom/ui/components` in order to use this
|
||||
// modularizeImports: {
|
||||
// "@calcom/ui": {
|
||||
// transform: "@calcom/ui/components/{{member}}",
|
||||
// },
|
||||
// },
|
||||
transpilePackages: [
|
||||
"@calcom/app-store",
|
||||
"@calcom/core",
|
||||
"@calcom/dayjs",
|
||||
"@calcom/emails",
|
||||
"@calcom/embed-core",
|
||||
"@calcom/embed-react",
|
||||
"@calcom/embed-snippet",
|
||||
"@calcom/features",
|
||||
"@calcom/lib",
|
||||
"@calcom/prisma",
|
||||
"@calcom/trpc",
|
||||
"@calcom/ui",
|
||||
],
|
||||
modularizeImports: {
|
||||
"@calcom/ui/components/icon": {
|
||||
transform: "@react-icons/all-files/fi/{{member}}",
|
||||
skipDefaultConversion: true,
|
||||
preventFullImport: true,
|
||||
},
|
||||
// TODO: We need to have all components in `@calcom/ui/components` in order to use this
|
||||
// "@calcom/ui": {
|
||||
// transform: "@calcom/ui/components/{{member}}",
|
||||
// },
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.6.5",
|
||||
"version": "2.6.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -13,6 +13,7 @@
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start:bg": "next start &",
|
||||
"lint": "eslint . --ignore-path .gitignore",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/web.json",
|
||||
@@ -45,7 +46,6 @@
|
||||
"@hookform/resolvers": "^2.9.7",
|
||||
"@next-auth/prisma-adapter": "^1.0.4",
|
||||
"@next/bundle-analyzer": "^13.1.6",
|
||||
"@next/font": "^13.1.1",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
@@ -70,12 +70,13 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"classnames": "^2.3.1",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"eslint-config-next": "^13.1.6",
|
||||
"eslint-config-next": "^13.2.1",
|
||||
"googleapis": "^84.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"handlebars": "^4.7.7",
|
||||
"ical.js": "^1.4.0",
|
||||
"ics": "^2.37.0",
|
||||
"jose": "^4.13.1",
|
||||
"kbar": "^0.1.0-beta.36",
|
||||
"libphonenumber-js": "^1.10.12",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -85,14 +86,13 @@
|
||||
"memory-cache": "^0.2.0",
|
||||
"micro": "^10.0.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"next": "^13.1.1",
|
||||
"next": "^13.2.1",
|
||||
"next-auth": "^4.18.8",
|
||||
"next-axiom": "^0.16.0",
|
||||
"next-collect": "^0.2.1",
|
||||
"next-i18next": "^11.3.0",
|
||||
"next-seo": "^4.26.0",
|
||||
"next-themes": "^0.2.0",
|
||||
"next-transpile-modules": "^10.0.0",
|
||||
"nodemailer": "^6.7.8",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.1",
|
||||
@@ -104,7 +104,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-easy-crop": "^3.5.2",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-hook-form": "^7.34.2",
|
||||
"react-hook-form": "^7.43.3",
|
||||
"react-hot-toast": "^2.3.0",
|
||||
"react-intl": "^5.25.1",
|
||||
"react-live-chat-loader": "^2.7.3",
|
||||
@@ -116,9 +116,11 @@
|
||||
"react-use-intercom": "1.5.1",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.7",
|
||||
"remark": "^14.0.2",
|
||||
"rrule": "^2.7.1",
|
||||
"schema-dts": "^1.1.0",
|
||||
"short-uuid": "^4.2.0",
|
||||
"strip-markdown": "^5.0.0",
|
||||
"stripe": "^9.16.0",
|
||||
"superjson": "1.9.1",
|
||||
"tailwindcss-radix": "^2.6.0",
|
||||
|
||||
+35
-29
@@ -1,5 +1,5 @@
|
||||
import { BadgeCheckIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import defaultEvents, {
|
||||
getDynamicEventDescription,
|
||||
getGroupName,
|
||||
@@ -23,20 +24,19 @@ import defaultEvents, {
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { HeadSeo, AvatarGroup, Avatar } from "@calcom/ui";
|
||||
import { BadgeCheckIcon, FiArrowRight } from "@calcom/ui/components/icon";
|
||||
import { Avatar, AvatarGroup, HeadSeo } from "@calcom/ui";
|
||||
import { FiArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
export default function User(props: inferSSRProps<typeof getServerSideProps> & EmbedProps) {
|
||||
const { users, profile, eventTypes, isDynamicGroup, dynamicNames, dynamicUsernames, isSingleUser } = props;
|
||||
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||
@@ -77,7 +77,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
size="sm"
|
||||
items={props.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
image: user.avatar || "",
|
||||
image: user.avatar,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
{!isBioEmpty && (
|
||||
<>
|
||||
<div
|
||||
className="dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
className=" dark:text-darkgray-600 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
dangerouslySetInnerHTML={{ __html: md.render(user.bio || "") }}
|
||||
/>
|
||||
</>
|
||||
@@ -178,26 +178,28 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
className="dark:bg-darkgray-100 dark:hover:bg-darkgray-200 dark:border-darkgray-300 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50">
|
||||
<FiArrowRight className="absolute right-4 top-4 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={{
|
||||
pathname: `/${user.username}/${type.slug}`,
|
||||
query,
|
||||
}}
|
||||
onClick={async () => {
|
||||
sdkActionManager?.fire("eventTypeSelected", {
|
||||
eventType: type,
|
||||
});
|
||||
}}
|
||||
className="block w-full p-5"
|
||||
data-testid="event-type-link">
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h2 className="dark:text-darkgray-700 pr-2 text-sm font-semibold text-gray-700">
|
||||
{type.title}
|
||||
</h2>
|
||||
</div>
|
||||
<EventTypeDescription eventType={type} />
|
||||
</Link>
|
||||
<div className="block w-full p-5">
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={{
|
||||
pathname: `/${user.username}/${type.slug}`,
|
||||
query,
|
||||
}}
|
||||
passHref
|
||||
onClick={async () => {
|
||||
sdkActionManager?.fire("eventTypeSelected", {
|
||||
eventType: type,
|
||||
});
|
||||
}}
|
||||
data-testid="event-type-link">
|
||||
<div className="flex flex-wrap items-center">
|
||||
<h2 className="dark:text-darkgray-700 pr-2 text-sm font-semibold text-gray-700">
|
||||
{type.title}
|
||||
</h2>
|
||||
</div>
|
||||
<EventTypeDescription eventType={type} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -260,7 +262,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const dataFetchStart = Date.now();
|
||||
const users = await prisma.user.findMany({
|
||||
const usersWithoutAvatar = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: usernameList,
|
||||
@@ -274,7 +276,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
bio: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
avatar: true,
|
||||
theme: true,
|
||||
away: true,
|
||||
verified: true,
|
||||
@@ -282,6 +283,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
},
|
||||
});
|
||||
|
||||
const users = usersWithoutAvatar.map((user) => ({
|
||||
...user,
|
||||
avatar: `${WEBAPP_URL}/${user.username}/avatar.png`,
|
||||
}));
|
||||
|
||||
if (!users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { addListFormatting } from "@calcom/lib/markdownIt";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
@@ -70,7 +71,8 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
/** TODO: We should standarize this */
|
||||
username: username.toLowerCase().replace(/( |%20)/g, "+"),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -123,13 +125,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
},
|
||||
});
|
||||
|
||||
const md = new MarkdownIt("zero").enable([
|
||||
//
|
||||
"emphasis",
|
||||
"list",
|
||||
"newline",
|
||||
"strikethrough",
|
||||
]);
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
if (!user || !user.eventTypes.length) return { notFound: true };
|
||||
|
||||
@@ -157,7 +153,7 @@ async function getUserPageProps(context: GetStaticPropsContext) {
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: privacyFilteredLocations(locations),
|
||||
descriptionAsSafeHTML: eventType.description ? md.render(eventType.description) : null,
|
||||
descriptionAsSafeHTML: eventType.description ? addListFormatting(md.render(eventType.description)) : null,
|
||||
});
|
||||
// Check if the user you are logging into has any active teams or premium user name
|
||||
const hasActiveTeam =
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import {
|
||||
getDefaultEvent,
|
||||
@@ -118,10 +119,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
});
|
||||
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventTypeRaw),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
};
|
||||
|
||||
@@ -183,7 +184,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
prisma,
|
||||
context.query.rescheduleUid
|
||||
? (context.query.rescheduleUid as string)
|
||||
: (context.query.bookingUid as string)
|
||||
: (context.query.bookingUid as string),
|
||||
eventTypeObject.bookingFields
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inter } from "@next/font/google";
|
||||
import localFont from "@next/font/local";
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UserPermissionRole } from "@prisma/client";
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import { readFileSync } from "fs";
|
||||
import Handlebars from "handlebars";
|
||||
import { SignJWT } from "jose";
|
||||
import type { Session } from "next-auth";
|
||||
import NextAuth from "next-auth";
|
||||
import { encode } from "next-auth/jwt";
|
||||
@@ -16,9 +17,10 @@ import path from "path";
|
||||
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
|
||||
import { hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { hostedCal, isSAMLLoginEnabled, clientSecretVerifier } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { ErrorCode, isPasswordValid, verifyPassword } from "@calcom/lib/auth";
|
||||
import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { APP_NAME, IS_TEAM_BILLING_ENABLED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultCookies } from "@calcom/lib/default-cookies";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
@@ -38,6 +40,21 @@ const transporter = nodemailer.createTransport<TransportOptions>({
|
||||
|
||||
const usernameSlug = (username: string) => slugify(username) + "-" + randomString(6).toLowerCase();
|
||||
|
||||
const signJwt = async (payload: { email: string }) => {
|
||||
const secret = new TextEncoder().encode(process.env.CALENDSO_ENCRYPTION_KEY);
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(payload.email)
|
||||
.setIssuedAt()
|
||||
.setIssuer(WEBSITE_URL)
|
||||
.setAudience(`${WEBSITE_URL}/auth/login`)
|
||||
.setExpirationTime("2m")
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
const loginWithTotp = async (user: { email: string }) =>
|
||||
`/auth/login?totp=${await signJwt({ email: user.email })}`;
|
||||
|
||||
const providers: Provider[] = [
|
||||
CredentialsProvider({
|
||||
id: "credentials",
|
||||
@@ -82,17 +99,19 @@ const providers: Provider[] = [
|
||||
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||
}
|
||||
|
||||
if (user.identityProvider !== IdentityProvider.CAL) {
|
||||
if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
|
||||
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
if (!user.password && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
|
||||
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||
if (user.password) {
|
||||
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.IncorrectUsernamePassword);
|
||||
}
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
@@ -130,7 +149,7 @@ const providers: Provider[] = [
|
||||
await limiter.check(10, user.email); // 10 requests per minute
|
||||
// Check if the user you are logging into has any active teams
|
||||
const hasActiveTeams =
|
||||
user.teams.filter((m) => {
|
||||
user.teams.filter((m: { team: { metadata: unknown } }) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) return true;
|
||||
const metadata = teamMetadataSchema.safeParse(m.team.metadata);
|
||||
if (metadata.success && metadata.data?.subscriptionId) return true;
|
||||
@@ -204,10 +223,65 @@ if (isSAMLLoginEnabled) {
|
||||
},
|
||||
options: {
|
||||
clientId: "dummy",
|
||||
clientSecret: "dummy",
|
||||
clientSecret: clientSecretVerifier,
|
||||
},
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
});
|
||||
|
||||
// Idp initiated login
|
||||
providers.push(
|
||||
CredentialsProvider({
|
||||
id: "saml-idp",
|
||||
name: "IdP Login",
|
||||
credentials: {
|
||||
code: {},
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { code } = credentials;
|
||||
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { oauthController } = await jackson();
|
||||
|
||||
// Fetch access token
|
||||
const { access_token } = await oauthController.token({
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: `${process.env.NEXTAUTH_URL}`,
|
||||
client_id: "dummy",
|
||||
client_secret: clientSecretVerifier,
|
||||
});
|
||||
|
||||
if (!access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch user info
|
||||
const userInfo = await oauthController.userInfo(access_token);
|
||||
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, firstName, lastName, email } = userInfo;
|
||||
|
||||
return {
|
||||
id: id as unknown as number,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
name: `${firstName} ${lastName}`.trim(),
|
||||
email_verified: true,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (true) {
|
||||
@@ -268,7 +342,7 @@ export default NextAuth({
|
||||
if (user) {
|
||||
const metadata = userMetadata.parse(user.metadata);
|
||||
if (metadata?.sessionTimeout) {
|
||||
maxAge = metadata.sessionTimeout / 60;
|
||||
maxAge = metadata.sessionTimeout * 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,12 +382,18 @@ export default NextAuth({
|
||||
...token,
|
||||
};
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return await autoMergeIdentities();
|
||||
}
|
||||
|
||||
if (account && account.type === "credentials") {
|
||||
if (!account) {
|
||||
return token;
|
||||
}
|
||||
if (account.type === "credentials") {
|
||||
// return token if credentials,saml-idp
|
||||
if (account.provider === "saml-idp") {
|
||||
return token;
|
||||
}
|
||||
// any other credentials, add user info
|
||||
return {
|
||||
...token,
|
||||
id: user.id,
|
||||
@@ -328,11 +408,12 @@ export default NextAuth({
|
||||
|
||||
// The arguments above are from the provider so we need to look up the
|
||||
// user based on those values in order to construct a JWT.
|
||||
if (account && account.type === "oauth" && account.provider && account.providerAccountId) {
|
||||
let idP: IdentityProvider = IdentityProvider.GOOGLE;
|
||||
if (account.provider === "saml") {
|
||||
idP = IdentityProvider.SAML;
|
||||
if (account.type === "oauth") {
|
||||
if (!account.provider || !account.providerAccountId) {
|
||||
return token;
|
||||
}
|
||||
const idP = account.provider === "saml" ? IdentityProvider.SAML : IdentityProvider.GOOGLE;
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
@@ -387,14 +468,18 @@ export default NextAuth({
|
||||
if (account?.provider === "email") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In this case we've already verified the credentials in the authorize
|
||||
// callback so we can sign the user in.
|
||||
if (account?.type === "credentials") {
|
||||
return true;
|
||||
}
|
||||
// Only if provider is not saml-idp
|
||||
if (account?.provider !== "saml-idp") {
|
||||
if (account?.type === "credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (account?.type !== "oauth") {
|
||||
return false;
|
||||
if (account?.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
@@ -407,7 +492,7 @@ export default NextAuth({
|
||||
|
||||
if (account?.provider) {
|
||||
let idP: IdentityProvider = IdentityProvider.GOOGLE;
|
||||
if (account.provider === "saml") {
|
||||
if (account.provider === "saml" || account.provider === "saml-idp") {
|
||||
idP = IdentityProvider.SAML;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -417,19 +502,18 @@ export default NextAuth({
|
||||
if (!user.email_verified) {
|
||||
return "/auth/error?error=unverified-email";
|
||||
}
|
||||
// Only google oauth on this path
|
||||
const provider = account.provider.toUpperCase() as IdentityProvider;
|
||||
|
||||
// Only google oauth on this path
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
include: {
|
||||
accounts: {
|
||||
where: {
|
||||
provider: account.provider,
|
||||
provider: idP,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
identityProvider: provider,
|
||||
identityProvider: idP as IdentityProvider,
|
||||
identityProviderId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
@@ -449,7 +533,11 @@ export default NextAuth({
|
||||
console.error("Error while linking account of already existing user");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUser);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the email address doesn't match, check if an account already exists
|
||||
@@ -461,7 +549,11 @@ export default NextAuth({
|
||||
|
||||
if (!userWithNewEmail) {
|
||||
await prisma.user.update({ where: { id: existingUser.id }, data: { email: user.email } });
|
||||
return true;
|
||||
if (existingUser.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUser);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return "/auth/error?error=new-email-conflict";
|
||||
}
|
||||
@@ -477,7 +569,11 @@ export default NextAuth({
|
||||
if (existingUserWithEmail) {
|
||||
// if self-hosted then we can allow auto-merge of identity providers if email is verified
|
||||
if (!hostedCal && existingUserWithEmail.emailVerified) {
|
||||
return true;
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if user was invited
|
||||
@@ -499,7 +595,11 @@ export default NextAuth({
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// User signs up with email/password and then tries to login with Google/SAML using the same email
|
||||
@@ -511,7 +611,11 @@ export default NextAuth({
|
||||
where: { email: existingUserWithEmail.email },
|
||||
data: { password: null },
|
||||
});
|
||||
return true;
|
||||
if (existingUserWithEmail.twoFactorEnabled) {
|
||||
return loginWithTotp(existingUserWithEmail);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
|
||||
return "/auth/error?error=use-password-login";
|
||||
}
|
||||
@@ -531,10 +635,15 @@ export default NextAuth({
|
||||
identityProviderId: String(user.id),
|
||||
},
|
||||
});
|
||||
|
||||
const linkAccountNewUserData = { ...account, userId: newUser.id };
|
||||
await calcomAdapter.linkAccount(linkAccountNewUserData);
|
||||
|
||||
return true;
|
||||
if (account.twoFactorEnabled) {
|
||||
return loginWithTotp(newUser);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -5,8 +5,12 @@ import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { oauthController } = await jackson();
|
||||
|
||||
const { redirect_url } = await oauthController.samlResponse(req.body);
|
||||
if (redirect_url) return res.redirect(302, redirect_url);
|
||||
|
||||
if (redirect_url) {
|
||||
res.redirect(302, redirect_url);
|
||||
}
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
|
||||
@@ -83,7 +83,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
// get user's credentials + their connected integrations
|
||||
const calendarCredentials = getCalendarCredentials(user.credentials);
|
||||
// get all the connected integrations' calendars (from third party)
|
||||
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
||||
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
||||
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
|
||||
const selectableCalendars = calendars.map((cal) => {
|
||||
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal };
|
||||
|
||||
@@ -8,7 +8,9 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const session = await getSession({ req });
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
const booking = await handleNewBooking(req);
|
||||
const booking = await handleNewBooking(req, {
|
||||
isNotAnApiCall: true,
|
||||
});
|
||||
return booking;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetcher } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
const getAccessLinkSchema = z.object({
|
||||
download_link: z.string().url(),
|
||||
// expires (timestamp), s3_bucket, s3_region, s3_key
|
||||
});
|
||||
|
||||
const requestQuery = z.object({
|
||||
recordingId: z.string(),
|
||||
});
|
||||
|
||||
const isDownloadAllowed = async (req: NextApiRequest) => {
|
||||
if (IS_SELF_HOSTED) return true;
|
||||
const session = await getSession({ req });
|
||||
// Check if user belong to active team
|
||||
return !session?.user?.belongsToActiveTeam;
|
||||
};
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<z.infer<typeof getAccessLinkSchema> | void>
|
||||
) {
|
||||
const { recordingId } = requestQuery.parse(req.query);
|
||||
|
||||
if (!(await isDownloadAllowed(req))) return res.status(403);
|
||||
|
||||
const response = await fetcher(`/recordings/${recordingId}/access-link`).then(getAccessLinkSchema.parse);
|
||||
return res.status(200).json(response);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
@@ -18,7 +20,7 @@ const interFontMedium = fetch(new URL("../../../../public/fonts/Inter-Medium.ttf
|
||||
);
|
||||
|
||||
export const config = {
|
||||
runtime: "experimental-edge",
|
||||
runtime: "edge",
|
||||
};
|
||||
|
||||
const meetingSchema = z.object({
|
||||
@@ -106,8 +108,8 @@ export default async function handler(req: NextApiRequest) {
|
||||
description: searchParams.get("description"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig) as {
|
||||
const description_ = await (await remark().use(strip).process(description)).toString();
|
||||
const img = new ImageResponse(<Generic title={title} description={description_} />, ogConfig) as {
|
||||
body: Buffer;
|
||||
};
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export default trpcNext.createNextApiHandler({
|
||||
"viewer.public.i18n": `no-cache`,
|
||||
// Revalidation time here should be 1 second, per https://github.com/calcom/cal.com/pull/6823#issuecomment-1423215321
|
||||
"viewer.public.slots.getSchedule": `no-cache`, // FIXME
|
||||
"viewer.public.cityTimezones": `max-age=${ONE_DAY_IN_SECONDS}, stale-while-revalidate`,
|
||||
} as const;
|
||||
|
||||
// Find which element above is an exact match for this group of paths
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { GetStaticPaths, GetStaticPropsContext } from "next";
|
||||
import path from "path";
|
||||
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import ExisitingGoogleCal from "@calcom/app-store/googlevideo/components/ExistingGoogleCal";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -36,11 +35,11 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
|
||||
isProOnly={data.isProOnly}
|
||||
images={source.data?.items as string[] | undefined}
|
||||
isTemplate={data.isTemplate}
|
||||
dependencies={data.dependencies}
|
||||
// tos="https://zoom.us/terms"
|
||||
// privacy="https://zoom.us/privacy"
|
||||
body={
|
||||
<>
|
||||
{data.slug === "google-meet" && <ExisitingGoogleCal />}
|
||||
<div dangerouslySetInnerHTML={{ __html: md.render(source.content) }} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useReducer, useState } from "react";
|
||||
import { useCallback, useReducer, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||
@@ -7,8 +7,9 @@ import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
||||
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||
import { AppSetDefaultLinkDailog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -113,11 +114,16 @@ interface IntegrationsListProps {
|
||||
const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListProps) => {
|
||||
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const onSuccessCallback = useCallback(() => {
|
||||
setBulkUpdateModal(true);
|
||||
showToast("Default app updated successfully", "success");
|
||||
}, []);
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Default app updated successfully", "success");
|
||||
@@ -172,6 +178,7 @@ const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListP
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug,
|
||||
});
|
||||
setBulkUpdateModal(true);
|
||||
}
|
||||
}}>
|
||||
{t("change_default_conferencing_app")}
|
||||
@@ -196,11 +203,16 @@ const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListP
|
||||
})}
|
||||
</List>
|
||||
{locationType && (
|
||||
<AppSetDefaultLinkDailog
|
||||
<AppSetDefaultLinkDialog
|
||||
locationType={locationType}
|
||||
setLocationType={() => setLocationType(undefined)}
|
||||
onSuccess={onSuccessCallback}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bulkUpdateModal && (
|
||||
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ResetPasswordRequest } from "@prisma/client";
|
||||
import debounce from "lodash/debounce";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken } from "next-auth/react";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import Link from "next/link";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
@@ -185,6 +186,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
id,
|
||||
csrfToken: await getCsrfToken({ req: context.req }),
|
||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
||||
},
|
||||
};
|
||||
} catch (reason) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken } from "next-auth/react";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import type { SyntheticEvent } from "react";
|
||||
@@ -144,5 +145,6 @@ ForgotPassword.getInitialProps = async (context: GetServerSidePropsContext) => {
|
||||
|
||||
return {
|
||||
csrfToken: await getCsrfToken(context),
|
||||
...(await serverSideTranslations(context.locale || "en", ["common"])),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
import { jwtVerify } from "jose";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken, signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
@@ -42,14 +43,14 @@ export default function Login({
|
||||
isSAMLLoginEnabled,
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
totpEmail,
|
||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const methods = useForm<LoginValues>();
|
||||
|
||||
const { register, formState } = methods;
|
||||
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
@@ -94,6 +95,16 @@ export default function Login({
|
||||
</Button>
|
||||
);
|
||||
|
||||
const ExternalTotpFooter = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.replace("/");
|
||||
}}
|
||||
color="minimal">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const onSubmit = async (values: LoginValues) => {
|
||||
setErrorMessage(null);
|
||||
telemetry.event(telemetryEventTypes.login, collectPageParameters());
|
||||
@@ -120,7 +131,9 @@ export default function Login({
|
||||
heading={twoFactorRequired ? t("2fa_code") : t("welcome_back")}
|
||||
footerText={
|
||||
twoFactorRequired
|
||||
? TwoFactorFooter
|
||||
? !totpEmail
|
||||
? TwoFactorFooter
|
||||
: ExternalTotpFooter
|
||||
: process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true"
|
||||
? LoginFooter
|
||||
: null
|
||||
@@ -135,7 +148,7 @@ export default function Login({
|
||||
<EmailField
|
||||
id="email"
|
||||
label={t("email_address")}
|
||||
defaultValue={router.query.email as string}
|
||||
defaultValue={totpEmail || (router.query.email as string)}
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
{...register("email")}
|
||||
@@ -152,7 +165,7 @@ export default function Login({
|
||||
<PasswordField
|
||||
id="password"
|
||||
autoComplete="off"
|
||||
required
|
||||
required={!totpEmail}
|
||||
className="mb-0"
|
||||
{...register("password")}
|
||||
/>
|
||||
@@ -211,6 +224,40 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
|
||||
const session = await getSession({ req });
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
const verifyJwt = (jwt: string) => {
|
||||
const secret = new TextEncoder().encode(process.env.CALENDSO_ENCRYPTION_KEY);
|
||||
|
||||
return jwtVerify(jwt, secret, {
|
||||
issuer: WEBSITE_URL,
|
||||
audience: `${WEBSITE_URL}/auth/login`,
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
};
|
||||
|
||||
let totpEmail = null;
|
||||
if (context.query.totp) {
|
||||
try {
|
||||
const decryptedJwt = await verifyJwt(context.query.totp as string);
|
||||
if (decryptedJwt.payload) {
|
||||
totpEmail = decryptedJwt.payload.email as string;
|
||||
} else {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/auth/error?error=JWT%20Invalid%20Payload",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/auth/error?error=Invalid%20JWT%3A%20Please%20try%20again",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
redirect: {
|
||||
@@ -238,6 +285,7 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer
|
||||
isSAMLLoginEnabled,
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
totpEmail,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// To handle the IdP initiated login flow callback
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { code } = router.query;
|
||||
|
||||
signIn("saml-idp", {
|
||||
callbackUrl: "/",
|
||||
code,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
// This page is used to initiate the SAML authentication flow by redirecting to the SAML provider.
|
||||
// Accessible only on self-hosted Cal.com instances.
|
||||
export default function Page({ samlTenantID, samlProductID }: inferSSRProps<typeof getServerSideProps>) {
|
||||
const router = useRouter();
|
||||
|
||||
if (HOSTED_CAL_FEATURES) {
|
||||
router.push("/auth/login");
|
||||
return;
|
||||
}
|
||||
|
||||
// Initiate SAML authentication flow
|
||||
signIn(
|
||||
"saml",
|
||||
{
|
||||
callbackUrl: "/",
|
||||
},
|
||||
{ tenant: samlTenantID, product: samlProductID }
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getServerSideProps() {
|
||||
return {
|
||||
props: {
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -8,7 +8,7 @@ import Shell from "@calcom/features/shell/Shell";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { yyyymmdd } from "@calcom/lib/date-fns";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { Schedule as ScheduleType, TimeRange, WorkingHours } from "@calcom/types/schedule";
|
||||
@@ -22,19 +22,20 @@ import {
|
||||
Switch,
|
||||
TimezoneSelect,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
ConfirmationDialogContent,
|
||||
VerticalDivider,
|
||||
} from "@calcom/ui";
|
||||
import { FiInfo, FiPlus } from "@calcom/ui/components/icon";
|
||||
import { FiInfo, FiPlus, FiTrash } from "@calcom/ui/components/icon";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
import EditableHeading from "@components/ui/EditableHeading";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
const querySchema = z.object({
|
||||
schedule: stringOrNumber,
|
||||
schedule: z.coerce.number().positive().optional(),
|
||||
});
|
||||
|
||||
type AvailabilityFormValues = {
|
||||
@@ -84,15 +85,29 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function Availability({ schedule }: { schedule: number }) {
|
||||
export default function Availability() {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const me = useMeQuery();
|
||||
const {
|
||||
data: { schedule: scheduleId },
|
||||
} = useTypedQuery(querySchema);
|
||||
|
||||
const { timeFormat } = me.data || { timeFormat: null };
|
||||
const { data, isLoading } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId: schedule });
|
||||
const { data: defaultValues } = trpc.viewer.availability.defaultValues.useQuery({ scheduleId: schedule });
|
||||
const form = useForm<AvailabilityFormValues>({ defaultValues });
|
||||
const { control } = form;
|
||||
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId },
|
||||
{
|
||||
enabled: !!scheduleId,
|
||||
}
|
||||
);
|
||||
|
||||
const form = useForm<AvailabilityFormValues>({
|
||||
values: schedule && {
|
||||
...schedule,
|
||||
schedule: schedule?.availability || [],
|
||||
},
|
||||
});
|
||||
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
|
||||
onSuccess: async ({ prevDefaultId, currentDefaultId, ...data }) => {
|
||||
if (prevDefaultId && currentDefaultId) {
|
||||
@@ -120,10 +135,26 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.availability.list.invalidate();
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast(t("schedule_deleted_successfully"), "success");
|
||||
router.push("/availability");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Shell
|
||||
backPath="/availability"
|
||||
title={data?.schedule.name ? data.schedule.name + " | " + t("availability") : t("availability")}
|
||||
title={schedule?.name ? schedule.name + " | " + t("availability") : t("availability")}
|
||||
heading={
|
||||
<Controller
|
||||
control={form.control}
|
||||
@@ -134,8 +165,8 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
data ? (
|
||||
data.schedule.availability
|
||||
schedule ? (
|
||||
schedule.schedule
|
||||
.filter((availability) => !!availability.days.length)
|
||||
.map((availability) => (
|
||||
<span key={availability.id}>
|
||||
@@ -158,7 +189,7 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
</Skeleton>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
disabled={isLoading || data?.isDefault}
|
||||
disabled={isLoading || schedule?.isDefault}
|
||||
checked={form.watch("isDefault")}
|
||||
onCheckedChange={(e) => {
|
||||
form.setValue("isDefault", e);
|
||||
@@ -166,6 +197,24 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VerticalDivider />
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button StartIcon={FiTrash} variant="icon" color="destructive" aria-label={t("delete")} />
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_schedule")}
|
||||
confirmBtnText={t("delete")}
|
||||
loadingText={t("delete")}
|
||||
onConfirm={() => {
|
||||
scheduleId && deleteMutation.mutate({ scheduleId });
|
||||
}}>
|
||||
{t("delete_schedule_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
|
||||
<VerticalDivider />
|
||||
|
||||
<div className="border-l-2 border-gray-300" />
|
||||
@@ -179,11 +228,12 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
form={form}
|
||||
id="availability-form"
|
||||
handleSubmit={async ({ dateOverrides, ...values }) => {
|
||||
updateMutation.mutate({
|
||||
scheduleId: schedule,
|
||||
dateOverrides: dateOverrides.flatMap((override) => override.ranges),
|
||||
...values,
|
||||
});
|
||||
scheduleId &&
|
||||
updateMutation.mutate({
|
||||
scheduleId,
|
||||
dateOverrides: dateOverrides.flatMap((override) => override.ranges),
|
||||
...values,
|
||||
});
|
||||
}}
|
||||
className="flex flex-col sm:mx-0 xl:flex-row xl:space-x-6">
|
||||
<div className="flex-1 flex-row xl:mr-0">
|
||||
@@ -191,7 +241,7 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
<div>
|
||||
{typeof me.data?.weekStart === "string" && (
|
||||
<Schedule
|
||||
control={control}
|
||||
control={form.control}
|
||||
name="schedule"
|
||||
weekStart={
|
||||
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
||||
@@ -203,7 +253,7 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-6 rounded-md border">
|
||||
{data?.workingHours && <DateOverride workingHours={data.workingHours} />}
|
||||
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||
@@ -243,20 +293,3 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const params = querySchema.safeParse(ctx.params);
|
||||
const ssr = await ssrInit(ctx);
|
||||
|
||||
if (!params.success) return { notFound: true };
|
||||
|
||||
const scheduleId = params.data.schedule;
|
||||
await ssr.viewer.availability.schedule.get.fetch({ scheduleId });
|
||||
await ssr.viewer.availability.defaultValues.fetch({ scheduleId });
|
||||
return {
|
||||
props: {
|
||||
schedule: scheduleId,
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingStatus, WorkflowActions } from "@prisma/client";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { createEvent } from "ics";
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import {
|
||||
SystemField,
|
||||
getBookingFieldsWithSystemFields,
|
||||
} from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
@@ -42,10 +46,11 @@ 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 { Button, EmailInput, HeadSeo } from "@calcom/ui";
|
||||
import { Badge, Button, EmailInput, HeadSeo } from "@calcom/ui";
|
||||
import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { getBookingWithResponses } from "@lib/getBooking";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import CancelBooking from "@components/booking/CancelBooking";
|
||||
@@ -194,11 +199,12 @@ export default function Success(props: SuccessProps) {
|
||||
if ((isCancellationMode || changes) && typeof window !== "undefined") {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
const tz =
|
||||
(isSuccessBookingPage
|
||||
? props.bookingInfo.attendees.find((attendee) => attendee.email === email)?.timeZone
|
||||
: props.bookingInfo.eventType?.timeZone || props.bookingInfo.user?.timeZone) || timeZone();
|
||||
|
||||
const location: ReturnType<typeof getEventLocationValue> = props.bookingInfo.location
|
||||
? props.bookingInfo.location
|
||||
: // If there is no location set then we default to Cal Video
|
||||
"integrations:daily";
|
||||
const location = props.bookingInfo.location as ReturnType<typeof getEventLocationValue>;
|
||||
|
||||
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||
props?.bookingInfo?.metadata || {}
|
||||
@@ -335,12 +341,12 @@ 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" : ""}`
|
||||
);
|
||||
const customInputs = bookingInfo?.customInputs;
|
||||
|
||||
const locationToDisplay = getSuccessPageLocationMessage(
|
||||
locationVideoCallUrl ? locationVideoCallUrl : location,
|
||||
@@ -348,10 +354,6 @@ export default function Success(props: SuccessProps) {
|
||||
bookingInfo.status
|
||||
);
|
||||
|
||||
const hasSMSAttendeeAction =
|
||||
eventType.workflows.find((workflowEventType) =>
|
||||
workflowEventType.workflow.steps.find((step) => step.action === WorkflowActions.SMS_ATTENDEE)
|
||||
) !== undefined;
|
||||
const providerName = guessEventLocationType(location)?.label;
|
||||
|
||||
return (
|
||||
@@ -479,6 +481,7 @@ export default function Success(props: SuccessProps) {
|
||||
date={dayjs(formerTime)}
|
||||
is24h={is24h}
|
||||
isCancelled={isCancelled}
|
||||
tz={tz}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
@@ -490,6 +493,7 @@ export default function Success(props: SuccessProps) {
|
||||
date={date}
|
||||
is24h={is24h}
|
||||
isCancelled={isCancelled}
|
||||
tz={tz}
|
||||
/>
|
||||
</div>
|
||||
{(bookingInfo?.user || bookingInfo?.attendees) && (
|
||||
@@ -499,7 +503,12 @@ export default function Success(props: SuccessProps) {
|
||||
<>
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<p>{bookingInfo.user.name}</p>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -541,63 +550,33 @@ export default function Success(props: SuccessProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{customInputs &&
|
||||
Object.keys(customInputs).map((key) => {
|
||||
// This breaks if you have two label that are the same.
|
||||
// TODO: Fix this in another PR
|
||||
const customInput = customInputs[key as keyof typeof customInputs];
|
||||
const eventTypeCustomFound = eventType.customInputs?.find((ci) => ci.label === key);
|
||||
return (
|
||||
<>
|
||||
{eventTypeCustomFound?.type === "RADIO" && (
|
||||
<>
|
||||
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
{eventTypeCustomFound.label}
|
||||
</div>
|
||||
<div className="col-span-3 mt-1 mb-2">
|
||||
{eventTypeCustomFound.options &&
|
||||
eventTypeCustomFound.options.map((option) => {
|
||||
const selected = option.label == customInput;
|
||||
return (
|
||||
<div
|
||||
key={option.label}
|
||||
className={classNames(
|
||||
"flex space-x-1",
|
||||
!selected && "text-gray-500"
|
||||
)}>
|
||||
<p>{option.label}</p>
|
||||
<span>{option.label === customInput && "✅"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
|
||||
<>
|
||||
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
{key}
|
||||
</div>
|
||||
<div className="col-span-3 mt-2 mb-2">
|
||||
{typeof customInput === "boolean" ? (
|
||||
<p>{customInput ? "true" : "false"}</p>
|
||||
) : (
|
||||
<p>{customInput}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
{bookingInfo?.smsReminderNumber && hasSMSAttendeeAction && (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("number_sms_notifications")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p>{bookingInfo.smsReminderNumber}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{Object.entries(bookingInfo.responses).map(([name, response]) => {
|
||||
const field = eventType.bookingFields.find((field) => field.name === name);
|
||||
// We show location in the "where" section
|
||||
// We show Booker Name, Emails and guests in Who section
|
||||
// We show notes in additional notes section
|
||||
// We show rescheduleReason at the top
|
||||
if (!field) return null;
|
||||
const isSystemField = SystemField.safeParse(field.name);
|
||||
if (isSystemField.success) return null;
|
||||
|
||||
const label = field.label || t(field.defaultLabel || "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-9 font-medium">{label}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p
|
||||
className="break-words"
|
||||
data-testid="field-response"
|
||||
data-fob-field={field.name}>
|
||||
{response.toString()}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{(!needsConfirmation || !userIsOwner) &&
|
||||
@@ -812,6 +791,7 @@ type RecurringBookingsProps = {
|
||||
is24h: boolean;
|
||||
allRemainingBookings: boolean;
|
||||
isCancelled: boolean;
|
||||
tz: string;
|
||||
};
|
||||
|
||||
export function RecurringBookings({
|
||||
@@ -822,13 +802,13 @@ export function RecurringBookings({
|
||||
allRemainingBookings,
|
||||
is24h,
|
||||
isCancelled,
|
||||
tz,
|
||||
}: RecurringBookingsProps) {
|
||||
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
|
||||
const recurringBookingsSorted = recurringBookings
|
||||
? recurringBookings.sort((a: ConfigType, b: ConfigType) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1))
|
||||
: null;
|
||||
@@ -850,12 +830,12 @@ export function RecurringBookings({
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookingsSorted.slice(0, 4).map((dateStr: string, idx: number) => (
|
||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(dateStr, timeZone()), language, "full")}
|
||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h)}{" "}
|
||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language)})
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -870,12 +850,12 @@ export function RecurringBookings({
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookingsSorted.slice(4).map((dateStr: string, idx: number) => (
|
||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(date, timeZone()), language, "full")}
|
||||
{formatToLocalizedDate(dayjs.tz(date, tz), language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(date, language, undefined, !is24h)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h)}{" "}
|
||||
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language)})
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -888,11 +868,11 @@ export function RecurringBookings({
|
||||
|
||||
return (
|
||||
<div className={classNames(isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(date, timeZone()), language, "full")}
|
||||
{formatToLocalizedDate(date, language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(date, language, undefined, !is24h)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h)}{" "}
|
||||
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language)})</span>
|
||||
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
||||
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -927,6 +907,9 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
locations: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
bookingFields: true,
|
||||
disableGuests: true,
|
||||
timeZone: true,
|
||||
owner: {
|
||||
select: userSelect,
|
||||
},
|
||||
@@ -951,6 +934,7 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
@@ -973,6 +957,7 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
return {
|
||||
isDynamic: false,
|
||||
...eventType,
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
@@ -1017,7 +1002,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!parsedQuery.success) return { notFound: true };
|
||||
const { uid, email, eventTypeSlug, cancel } = parsedQuery.data;
|
||||
|
||||
const bookingInfo = await prisma.booking.findFirst({
|
||||
const bookingInfoRaw = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
@@ -1035,6 +1020,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
status: true,
|
||||
metadata: true,
|
||||
cancellationReason: true,
|
||||
responses: true,
|
||||
rejectionReason: true,
|
||||
user: {
|
||||
select: {
|
||||
@@ -1042,12 +1028,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
name: true,
|
||||
email: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
eventTypeId: true,
|
||||
@@ -1055,31 +1043,33 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
select: {
|
||||
eventName: true,
|
||||
slug: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!bookingInfo) {
|
||||
if (!bookingInfoRaw) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
// @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;
|
||||
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
|
||||
|
||||
const eventTypeRaw = !bookingInfo.eventTypeId
|
||||
const eventTypeRaw = !bookingInfoRaw.eventTypeId
|
||||
? getDefaultEvent(eventTypeSlug || "")
|
||||
: await getEventTypesFromDB(bookingInfo.eventTypeId);
|
||||
: await getEventTypesFromDB(bookingInfoRaw.eventTypeId);
|
||||
if (!eventTypeRaw) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const bookingInfo = getBookingWithResponses(bookingInfoRaw, eventTypeRaw);
|
||||
|
||||
// @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;
|
||||
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
|
||||
|
||||
eventTypeRaw.users = !!eventTypeRaw.hosts?.length
|
||||
? eventTypeRaw.hosts.map((host) => host.user)
|
||||
: eventTypeRaw.users;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { bookEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -71,6 +71,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
...eventTypeRaw,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
bookingFields: eventTypeBookingFields.parse(eventTypeRaw.bookingFields || []),
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { PeriodType } from "@prisma/client";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import type { SchedulingType } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import type { EventLocationType } from "@calcom/core/location";
|
||||
import { validateBookingLimitOrder } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -16,6 +17,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -84,8 +86,8 @@ export type FormValues = {
|
||||
};
|
||||
successRedirectUrl: string;
|
||||
bookingLimits?: BookingLimit;
|
||||
hosts: { userId: number }[];
|
||||
hostsFixed: { userId: number }[];
|
||||
hosts: { userId: number; isFixed: boolean }[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
};
|
||||
|
||||
export type CustomInputParsed = typeof customInputSchema._output;
|
||||
@@ -120,7 +122,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
extendsFeature: "EventType",
|
||||
});
|
||||
|
||||
const { eventType, locationOptions, team, teamMembers, currentUserMembership } = props;
|
||||
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
@@ -178,48 +180,56 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
delete metadata.config?.useHostSchedulesForTeamEvent;
|
||||
}
|
||||
|
||||
const formMethods = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
title: eventType.title,
|
||||
locations: eventType.locations || [],
|
||||
recurringEvent: eventType.recurringEvent || null,
|
||||
description: eventType.description ?? undefined,
|
||||
schedule: eventType.schedule || undefined,
|
||||
bookingLimits: eventType.bookingLimits || undefined,
|
||||
length: eventType.length,
|
||||
hidden: eventType.hidden,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
endDate: periodDates.endDate,
|
||||
},
|
||||
periodType: eventType.periodType,
|
||||
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
||||
schedulingType: eventType.schedulingType,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
metadata,
|
||||
hosts: !!eventType.hosts?.length
|
||||
? eventType.hosts.filter((host) => !host.isFixed)
|
||||
: eventType.users
|
||||
.filter(() => eventType.schedulingType === SchedulingType.ROUND_ROBIN)
|
||||
.map((user) => ({ userId: user.id })),
|
||||
hostsFixed: !!eventType.hosts?.length
|
||||
? eventType.hosts.filter((host) => host.isFixed)
|
||||
: eventType.users
|
||||
.filter(() => eventType.schedulingType === SchedulingType.COLLECTIVE)
|
||||
.map((user) => ({ userId: user.id })),
|
||||
const defaultValues = {
|
||||
title: eventType.title,
|
||||
locations: eventType.locations || [],
|
||||
recurringEvent: eventType.recurringEvent || null,
|
||||
description: eventType.description ?? undefined,
|
||||
schedule: eventType.schedule || undefined,
|
||||
bookingLimits: eventType.bookingLimits || undefined,
|
||||
length: eventType.length,
|
||||
hidden: eventType.hidden,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
endDate: periodDates.endDate,
|
||||
},
|
||||
bookingFields: eventType.bookingFields,
|
||||
periodType: eventType.periodType,
|
||||
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
||||
schedulingType: eventType.schedulingType,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
metadata,
|
||||
hosts: eventType.hosts,
|
||||
} as const;
|
||||
|
||||
const formMethods = useForm<FormValues>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
// Length if string, is converted to a number or it can be a number
|
||||
// Make it optional because it's not submitted from all tabs of the page
|
||||
eventName: z
|
||||
.string()
|
||||
.refine((val) => validateCustomEventName(val, t("invalid_event_name_variables")) === true, {
|
||||
message: t("invalid_event_name_variables"),
|
||||
})
|
||||
.optional(),
|
||||
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
bookingFields: eventTypeBookingFields,
|
||||
})
|
||||
// TODO: Add schema for other fields later.
|
||||
.passthrough()
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!formMethods.formState.isDirty) {
|
||||
//TODO: What's the best way to sync the form with backend
|
||||
formMethods.setValue("bookingFields", defaultValues.bookingFields);
|
||||
}
|
||||
}, [defaultValues]);
|
||||
|
||||
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
||||
const numberOfInstalledApps = eventTypeApps?.filter((app) => app.isInstalled).length || 0;
|
||||
let numberOfActiveApps = 0;
|
||||
@@ -241,6 +251,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
locationOptions={locationOptions}
|
||||
team={team}
|
||||
teamMembers={teamMembers}
|
||||
destinationCalendar={destinationCalendar}
|
||||
/>
|
||||
),
|
||||
availability: <AvailabilityTab isTeamEvent={!!team} />,
|
||||
@@ -285,8 +296,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
locations,
|
||||
metadata,
|
||||
customInputs,
|
||||
hosts: hostsInput,
|
||||
hostsFixed,
|
||||
// We don't need to send send these values to the backend
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
seatsPerTimeSlotEnabled,
|
||||
@@ -295,11 +304,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
...input
|
||||
} = values;
|
||||
|
||||
const hosts: ((typeof hostsInput)[number] & { isFixed?: boolean })[] = [];
|
||||
if (hostsInput || hostsFixed) {
|
||||
hosts.push(...hostsInput.concat(hostsFixed.map((host) => ({ isFixed: true, ...host }))));
|
||||
}
|
||||
|
||||
if (bookingLimits) {
|
||||
const isValid = validateBookingLimitOrder(bookingLimits);
|
||||
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
|
||||
@@ -317,7 +321,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
|
||||
updateMutation.mutate({
|
||||
...input,
|
||||
hosts,
|
||||
locations,
|
||||
recurringEvent,
|
||||
periodStartDate: periodDates.startDate,
|
||||
@@ -342,13 +345,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
};
|
||||
|
||||
const EventTypePageWrapper = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const { data, isLoading } = trpc.viewer.eventTypes.get.useQuery(
|
||||
{ id: props.type },
|
||||
{
|
||||
initialData: props.initialData,
|
||||
}
|
||||
);
|
||||
|
||||
const { data, isLoading } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
|
||||
if (isLoading || !data) return null;
|
||||
return <EventTypePage {...data} />;
|
||||
};
|
||||
@@ -391,9 +388,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ import type { FC } from "react";
|
||||
import { useEffect, useState, memo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CreateEventTypeButton,
|
||||
EventTypeDescriptionLazy as EventTypeDescription,
|
||||
} from "@calcom/features/eventtypes/components";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
showToast,
|
||||
Switch,
|
||||
Tooltip,
|
||||
CreateButton,
|
||||
HorizontalTabs,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
@@ -119,7 +119,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
<Link
|
||||
href={`/event-types/${type.id}?tabName=setup`}
|
||||
className="flex-1 overflow-hidden pr-4 text-sm"
|
||||
title={`${type.title} ${type.description ? `– ${type.description}` : ""}`}>
|
||||
title={type.title}>
|
||||
<div>
|
||||
<span
|
||||
className="font-semibold text-gray-700 ltr:mr-1 rtl:ml-1"
|
||||
@@ -134,14 +134,15 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
</small>
|
||||
) : null}
|
||||
{readOnly && (
|
||||
<span className="items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2 ltr:mr-2 rtl:ml-2">
|
||||
<Badge variant="gray" className="ml-2">
|
||||
{t("readonly")}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<EventTypeDescription
|
||||
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
|
||||
eventType={type}
|
||||
shortenDescription
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
@@ -240,7 +241,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "duplicate-event-type",
|
||||
dialog: "duplicate",
|
||||
title: eventType.title,
|
||||
description: eventType.description,
|
||||
slug: eventType.slug,
|
||||
@@ -337,7 +338,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
{type.users?.length > 1 && (
|
||||
{type.team && (
|
||||
<AvatarGroup
|
||||
className="relative top-1 right-3"
|
||||
size="sm"
|
||||
@@ -627,17 +628,37 @@ const CreateFirstEventTypeView = () => {
|
||||
};
|
||||
|
||||
const CTA = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const query = trpc.viewer.eventTypes.getByViewer.useQuery();
|
||||
|
||||
if (!query.data) return null;
|
||||
|
||||
return <CreateEventTypeButton canAddEvents={true} options={query.data.profiles} />;
|
||||
const profileOptions = query.data.profiles
|
||||
.filter((profile) => !profile.readOnly)
|
||||
.map((profile) => {
|
||||
return {
|
||||
teamId: profile.teamId,
|
||||
label: profile.name || profile.slug,
|
||||
image: profile.image,
|
||||
slug: profile.slug,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<CreateButton
|
||||
subtitle={t("create_event_on").toUpperCase()}
|
||||
options={profileOptions}
|
||||
createDialog={CreateEventTypeDialog}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer);
|
||||
|
||||
const EventTypesPage = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
|
||||
@@ -683,6 +704,7 @@ const EventTypesPage = () => {
|
||||
)}
|
||||
|
||||
<EmbedDialog />
|
||||
{router.query.dialog === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
||||
import { AppSetDefaultLinkDailog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import { BulkEditDefaultConferencingModal } from "@calcom/features/eventtypes/components/BulkEditDefaultConferencingModal";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -62,10 +63,15 @@ const ConferencingLayout = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const onSuccessCallback = useCallback(() => {
|
||||
setBulkUpdateModal(true);
|
||||
showToast("Default app updated successfully", "success");
|
||||
}, []);
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Default app updated successfully", "success");
|
||||
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||
onSuccessCallback();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Error: ${error.message}`, "error");
|
||||
@@ -73,6 +79,7 @@ const ConferencingLayout = () => {
|
||||
});
|
||||
|
||||
const [deleteAppModal, setDeleteAppModal] = useState(false);
|
||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||
undefined
|
||||
);
|
||||
@@ -167,7 +174,14 @@ const ConferencingLayout = () => {
|
||||
</Dialog>
|
||||
|
||||
{locationType && (
|
||||
<AppSetDefaultLinkDailog locationType={locationType} setLocationType={setLocationType} />
|
||||
<AppSetDefaultLinkDialog
|
||||
locationType={locationType}
|
||||
setLocationType={setLocationType}
|
||||
onSuccess={onSuccessCallback}
|
||||
/>
|
||||
)}
|
||||
{bulkUpdateModal && (
|
||||
<BulkEditDefaultConferencingModal open={bulkUpdateModal} setOpen={setBulkUpdateModal} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { signOut } from "next-auth/react";
|
||||
import type { BaseSyntheticEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
@@ -10,6 +9,7 @@ import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -41,8 +41,6 @@ import { FiAlertTriangle, FiTrash2 } from "@calcom/ui/components/icon";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true });
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
@@ -371,6 +369,7 @@ const ProfileForm = ({
|
||||
formMethods.setValue("bio", turndown(value), { shouldDirty: true });
|
||||
}}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={isDisabled} color="primary" className="mt-8" type="submit">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import classNames from "classnames";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -11,6 +10,7 @@ import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
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";
|
||||
@@ -23,8 +23,6 @@ import Team from "@components/team/screens/Team";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
export type TeamPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
function TeamPage({ team }: TeamPageProps) {
|
||||
useTheme(team.theme);
|
||||
@@ -50,29 +48,31 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
"dark:bg-darkgray-100 dark:border-darkgray-300 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50",
|
||||
!isEmbed && "bg-white"
|
||||
)}>
|
||||
<Link
|
||||
href={`/team/${team.slug}/${type.slug}`}
|
||||
className="flex justify-between px-6 py-4"
|
||||
data-testid="event-type-link">
|
||||
<div className="flex-shrink">
|
||||
<div className="flex flex-wrap items-center space-x-2 rtl:space-x-reverse">
|
||||
<h2 className="dark:text-darkgray-700 text-sm font-semibold text-gray-700">{type.title}</h2>
|
||||
<div className="px-6 py-4 ">
|
||||
<Link
|
||||
href={`/team/${team.slug}/${type.slug}`}
|
||||
data-testid="event-type-link"
|
||||
className="flex justify-between">
|
||||
<div className="flex-shrink">
|
||||
<div className="flex flex-wrap items-center space-x-2 rtl:space-x-reverse">
|
||||
<h2 className="dark:text-darkgray-700 text-sm font-semibold text-gray-700">{type.title}</h2>
|
||||
</div>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
<div className="mt-1 self-center">
|
||||
<AvatarGroup
|
||||
truncateAfter={4}
|
||||
className="flex flex-shrink-0"
|
||||
size="sm"
|
||||
items={type.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
title: user.name || "",
|
||||
image: CAL_URL + "/" + user.username + "/avatar.png" || "",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="mt-1 self-center">
|
||||
<AvatarGroup
|
||||
truncateAfter={4}
|
||||
className="flex flex-shrink-0"
|
||||
size="sm"
|
||||
items={type.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
title: user.name || "",
|
||||
image: CAL_URL + "/" + user.username + "/avatar.png" || "",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -78,6 +79,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
availability: true,
|
||||
description: true,
|
||||
length: true,
|
||||
disableGuests: true,
|
||||
schedulingType: true,
|
||||
periodType: true,
|
||||
periodStartDate: true,
|
||||
@@ -96,12 +98,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
slotInterval: true,
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
bookingFields: true,
|
||||
customInputs: true,
|
||||
schedule: {
|
||||
select: {
|
||||
timeZone: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
members: {
|
||||
@@ -163,7 +177,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBooking(prisma, rescheduleUid);
|
||||
booking = await getBooking(prisma, rescheduleUid, getBookingFieldsWithSystemFields(eventTypeObject));
|
||||
}
|
||||
|
||||
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, eventTypeBookingFields, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { GetBookingType } from "@lib/getBooking";
|
||||
@@ -13,6 +14,8 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import BookingPage from "@components/booking/pages/BookingPage";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||
@@ -22,6 +25,7 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||
TeamBookingPage.isThemeSupported = true;
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
||||
@@ -55,6 +59,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
metadata: true,
|
||||
seatsPerTimeSlot: true,
|
||||
schedulingType: true,
|
||||
bookingFields: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
@@ -92,12 +97,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
//TODO: Use zodSchema to verify it instead of using Type Assertion
|
||||
locations: privacyFilteredLocations((eventTypeRaw.locations || []) as LocationObject[]),
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
bookingFields: eventTypeBookingFields.parse(eventTypeRaw.bookingFields || []),
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
...e,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
metadata: EventTypeMetaDataSchema.parse(e.metadata || {}),
|
||||
bookingFields: getBookingFieldsWithSystemFields(eventType),
|
||||
periodStartDate: e.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: e.periodEndDate?.toString() ?? null,
|
||||
customInputs: customInputSchema.array().parse(e.customInputs || []),
|
||||
@@ -114,7 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (context.query.rescheduleUid) {
|
||||
booking = await getBooking(prisma, context.query.rescheduleUid as string);
|
||||
booking = await getBooking(prisma, context.query.rescheduleUid as string, eventTypeObject.bookingFields);
|
||||
}
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
@@ -128,6 +134,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
profile: {
|
||||
...eventTypeObject.team,
|
||||
// FIXME: This slug is used as username on success page which is wrong. This is correctly set as username for user booking.
|
||||
|
||||
@@ -4,6 +4,8 @@ import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("App Store - Authed", () => {
|
||||
test("Browse apple-calendar and try to install", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
@@ -12,7 +14,6 @@ test.describe("App Store - Authed", () => {
|
||||
await page.click('[data-testid="app-store-app-card-apple-calendar"]');
|
||||
await page.click('[data-testid="install-app-button"]');
|
||||
await expect(page.locator(`text=Connect to Apple Server`)).toBeVisible();
|
||||
await pro.delete();
|
||||
});
|
||||
|
||||
test("Installed Apps - Navigation", async ({ page, users }) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test("Can delete user account", async ({ page, users }) => {
|
||||
const user = await users.create({
|
||||
username: "delete-me",
|
||||
@@ -10,7 +12,7 @@ test("Can delete user account", async ({ page, users }) => {
|
||||
|
||||
await page.waitForSelector("[data-testid=dashboard-shell]");
|
||||
|
||||
await page.goto(`/settings/profile`);
|
||||
await page.goto(`/settings/my-account/profile`);
|
||||
await page.click("[data-testid=delete-account]");
|
||||
if (!user.username) throw Error(`Test user doesn't have a username`);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test("Can reset forgotten password", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_IS_E2E !== "1", "It shouldn't if we can't skip email");
|
||||
const user = await users.create();
|
||||
@@ -47,6 +49,4 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
||||
await page.waitForSelector("[data-testid=dashboard-shell]");
|
||||
|
||||
await expect(page.locator("[data-testid=dashboard-shell]")).toBeVisible();
|
||||
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
@@ -10,15 +10,13 @@ import {
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(async ({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("free user", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const free = await users.create();
|
||||
await page.goto(`/${free.username}`);
|
||||
});
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test("cannot book same slot multiple times", async ({ page }) => {
|
||||
// Click first event type
|
||||
@@ -58,9 +56,6 @@ test.describe("pro user", () => {
|
||||
const pro = await users.create();
|
||||
await page.goto(`/${pro.username}`);
|
||||
});
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test("pro user's page has at least 2 visible events", async ({ page }) => {
|
||||
// await page.pause();
|
||||
|
||||
@@ -24,6 +24,6 @@ test.describe("Change Password Test", () => {
|
||||
|
||||
const toast = await page.waitForSelector("div[class*='data-testid-toast-success']");
|
||||
|
||||
await expect(toast).toBeTruthy();
|
||||
expect(toast).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
selectSecondAvailableTimeSlotNextMonth,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test("dynamic booking", async ({ page, users }) => {
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
// Due to some reason for Dynamic booking cancellation, daily video api_key is not set which causes cancellation to fail.
|
||||
// This test is skipped until the issue is resolved in GH actions.
|
||||
test.skip("dynamic booking", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.login();
|
||||
|
||||
const free = await users.create({ username: "free" });
|
||||
await page.goto(`/${pro.username}+${free.username}`);
|
||||
|
||||
@@ -55,8 +60,6 @@ test("dynamic booking", async ({ page, users }) => {
|
||||
|
||||
const cancelledHeadline = await page.locator('[data-testid="cancelled-headline"]').innerText();
|
||||
|
||||
await expect(cancelledHeadline).toBe("This event is cancelled");
|
||||
expect(cancelledHeadline).toBe("This event is cancelled");
|
||||
});
|
||||
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
@@ -88,6 +88,8 @@ async function expectToContainValidPreviewIframe(
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Embed Code Generator Tests", () => {
|
||||
test.beforeEach(async ({ users }) => {
|
||||
const pro = await users.create();
|
||||
|
||||
@@ -139,6 +139,8 @@ test.describe("Event Types tests", () => {
|
||||
const fillLocation = async (inputText: string) => {
|
||||
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.locator('input[name="locationAddress"]').fill(inputText);
|
||||
await page.locator("[data-testid=display-location]").check();
|
||||
await page.locator("[data-testid=update-location]").click();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page, WorkerInfo } from "@playwright/test";
|
||||
import type Prisma from "@prisma/client";
|
||||
import { MembershipRole, Prisma as PrismaType } from "@prisma/client";
|
||||
import { hash } from "bcryptjs";
|
||||
import { hashSync as hash } from "bcryptjs";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
@@ -10,8 +10,8 @@ import { prisma } from "@calcom/prisma";
|
||||
import type { TimeZoneEnum } from "./types";
|
||||
|
||||
// Don't import hashPassword from app as that ends up importing next-auth and initializing it before NEXTAUTH_URL can be updated during tests.
|
||||
export async function hashPassword(password: string) {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
export function hashPassword(password: string) {
|
||||
const hashedPassword = hash(password, 12);
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
@@ -44,9 +44,6 @@ const createTeamAndAddUser = async (
|
||||
slug: `team-${workerInfo.workerIndex}-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
if (!team) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { role = MembershipRole.OWNER, id: userId } = user;
|
||||
await prisma.membership.create({
|
||||
@@ -54,8 +51,10 @@ const createTeamAndAddUser = async (
|
||||
teamId: team.id,
|
||||
userId,
|
||||
role: role,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
return team;
|
||||
};
|
||||
|
||||
// creates a user fixture instance and stores the collection
|
||||
@@ -74,11 +73,33 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
});
|
||||
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,
|
||||
@@ -92,6 +113,11 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
title: "Opt in",
|
||||
slug: "opt-in",
|
||||
requiresConfirmation: true,
|
||||
@@ -219,7 +245,29 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
include: userIncludes,
|
||||
});
|
||||
if (scenario.hasTeam) {
|
||||
await createTeamAndAddUser({ user: { id: user.id, role: "OWNER" } }, workerInfo);
|
||||
const team = await createTeamAndAddUser({ user: { id: user.id, role: "OWNER" } }, workerInfo);
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
team: {
|
||||
connect: {
|
||||
id: team.id,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
title: "Team Event - 30min",
|
||||
slug: "team-event-30min",
|
||||
length: 30,
|
||||
},
|
||||
});
|
||||
}
|
||||
const userFixture = createUserFixture(user, store.page!);
|
||||
store.users.push(userFixture);
|
||||
@@ -270,17 +318,14 @@ type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "loc
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & { timeZone?: TimeZoneEnum };
|
||||
|
||||
// creates the actual user in the db.
|
||||
const createUser = async (
|
||||
workerInfo: WorkerInfo,
|
||||
opts?: CustomUserOpts | null
|
||||
): Promise<PrismaType.UserCreateInput> => {
|
||||
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
|
||||
// build a unique name for our user
|
||||
const uname = `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`;
|
||||
return {
|
||||
username: uname,
|
||||
name: opts?.name,
|
||||
email: `${uname}@example.com`,
|
||||
password: await hashPassword(uname),
|
||||
password: hashPassword(uname),
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
timeZone: opts?.timeZone ?? dayjs.tz.guess(),
|
||||
@@ -298,13 +343,6 @@ const createUser = async (
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
eventTypes: {
|
||||
create: {
|
||||
title: "30 min",
|
||||
slug: "30-min",
|
||||
length: 30,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, todo } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
const IS_STRIPE_ENABLED = !!(
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
@@ -32,9 +33,6 @@ test.describe("Stripe integration", () => {
|
||||
await expect(
|
||||
page.locator(`li:has-text("Stripe") >> [data-testid="stripe_payment-integration-disconnect-button"]`)
|
||||
).toContainText("");
|
||||
|
||||
// Cleanup
|
||||
await users.deleteAll();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,9 +68,6 @@ test.describe("Stripe integration", () => {
|
||||
|
||||
// Make sure we're navigated to the success page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
todo("Pending payment booking should not be confirmed by default");
|
||||
|
||||
@@ -159,6 +159,7 @@ test.afterEach(() => requestInterceptor.resetHandlers());
|
||||
|
||||
// Disable API mocking after the tests are done.
|
||||
test.afterAll(() => requestInterceptor.close());
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
// TODO: Fix MSW mocking
|
||||
test.fixme("Integrations", () => {
|
||||
@@ -210,17 +211,6 @@ test.fixme("Integrations", () => {
|
||||
});
|
||||
};
|
||||
test.describe("Zoom App", () => {
|
||||
test.afterEach(async () => {
|
||||
await prisma?.credential.deleteMany({
|
||||
where: {
|
||||
user: {
|
||||
email: "pro@example.com",
|
||||
},
|
||||
type: "zoom_video",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Can add integration", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","seatsShowAttendees":false,"uid":"[redacted/dynamic]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}
|
||||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","seatsShowAttendees":true,"uid":"[redacted/dynamic]","videoCallData":"[redacted/dynamic]","appsStatus":"[redacted/dynamic]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import { createServer } from "http";
|
||||
import noop from "lodash/noop";
|
||||
|
||||
import { test } from "./fixtures";
|
||||
|
||||
export function todo(title: string) {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(title, noop);
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { Page, PlaywrightTestArgs } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { WebhookTriggerEvents } from "@prisma/client";
|
||||
import type { createUsersFixture } from "playwright/fixtures/users";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe("Manage Booking Questions", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("For User 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 EventType Page ", async () => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types] > li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
|
||||
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 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");
|
||||
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!",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
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!",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSystemFieldsToBeThere(page: Page) {
|
||||
const allFieldsLocator = page.locator("[data-fob-field-name]:not(.hidden)");
|
||||
const nameLocator = allFieldsLocator.nth(0);
|
||||
const emailLocator = allFieldsLocator.nth(1);
|
||||
// Location isn't rendered unless explicitly set which isn't the case here
|
||||
// const locationLocator = allFieldsLocator.nth(2);
|
||||
const additionalNotes = allFieldsLocator.nth(3);
|
||||
const guestsLocator = allFieldsLocator.nth(4);
|
||||
|
||||
await expect(nameLocator.locator('[name="name"]')).toBeVisible();
|
||||
await expect(emailLocator.locator('[name="email"]')).toBeVisible();
|
||||
|
||||
await expect(additionalNotes.locator('[name="notes"]')).toBeVisible();
|
||||
await expect(guestsLocator.locator("button")).toBeVisible();
|
||||
return allFieldsLocator;
|
||||
}
|
||||
|
||||
//TODO: Add one question for each type and see they are rendering labels and only once and are showing appropriate native component
|
||||
// Verify webhook is sent with the correct data, DB is correct (including metadata)
|
||||
|
||||
//TODO: Verify that prefill works
|
||||
async function bookTimeSlot({ page, name, email }: { page: Page; name: string; email: string }) {
|
||||
// --- fill form
|
||||
await page.fill('[name="name"]', name);
|
||||
await page.fill('[name="email"]', email);
|
||||
await page.press('[name="email"]', "Enter");
|
||||
}
|
||||
/**
|
||||
* 'option' starts from 1
|
||||
*/
|
||||
async function selectOption({
|
||||
page,
|
||||
selector,
|
||||
optionText,
|
||||
}: {
|
||||
page: Page;
|
||||
selector: { selector: string; nth: number };
|
||||
optionText: string;
|
||||
}) {
|
||||
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
|
||||
await locatorForSelect.click();
|
||||
await locatorForSelect.locator(`text="${optionText}"`).click();
|
||||
}
|
||||
|
||||
async function addQuestionAndSave({
|
||||
page,
|
||||
question,
|
||||
}: {
|
||||
page: Page;
|
||||
question: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
}) {
|
||||
await page.click('[href$="tabName=advanced"]');
|
||||
await page.click('[data-testid="add-field"]');
|
||||
|
||||
if (question.type !== undefined) {
|
||||
await selectOption({
|
||||
page,
|
||||
selector: {
|
||||
selector: "[id=test-field-type]",
|
||||
nth: 0,
|
||||
},
|
||||
optionText: question.type,
|
||||
});
|
||||
}
|
||||
|
||||
if (question.name !== undefined) {
|
||||
await page.fill('[name="name"]', question.name);
|
||||
}
|
||||
|
||||
if (question.label !== undefined) {
|
||||
await page.fill('[name="label"]', question.label);
|
||||
}
|
||||
|
||||
if (question.placeholder !== undefined) {
|
||||
await page.fill('[name="placeholder"]', question.placeholder);
|
||||
}
|
||||
|
||||
if (question.required !== undefined) {
|
||||
// await page.fill('[name="name"]', question.required);
|
||||
}
|
||||
|
||||
await page.click('[data-testid="field-add-save"]');
|
||||
await saveEventType(page);
|
||||
}
|
||||
|
||||
async function expectErrorToBeThereFor({ page, name }: { page: Page; name: string }) {
|
||||
await expect(page.locator(`[data-testid=error-message-${name}]`)).toHaveCount(1);
|
||||
// TODO: We should either verify the error message or error code in the test so we know that the correct error is shown
|
||||
// Checking for the error message isn't well maintainable as translation can change and we might want to verify in non english language as well.
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a fresh preview window and runs the callback on it giving it the preview tab's `page`
|
||||
*/
|
||||
async function doOnFreshPreview(
|
||||
page: Page,
|
||||
context: PlaywrightTestArgs["context"],
|
||||
callback: (page: Page) => Promise<void>
|
||||
) {
|
||||
const previewTabPage = await openBookingFormInPreviewTab(context, page);
|
||||
await callback(previewTabPage);
|
||||
await previewTabPage.close();
|
||||
}
|
||||
|
||||
async function toggleQuestionAndSave({ name, page }: { name: string; page: Page }) {
|
||||
await page.locator(`[data-testid="field-${name}"]`).locator('[data-testid="toggle-field"]').click();
|
||||
await saveEventType(page);
|
||||
}
|
||||
|
||||
async function createAndLoginUserWithEventTypes({ users }: { users: ReturnType<typeof createUsersFixture> }) {
|
||||
const user = await users.create(null, {
|
||||
hasTeam: true,
|
||||
});
|
||||
await user.login();
|
||||
return user;
|
||||
}
|
||||
|
||||
async function openBookingFormInPreviewTab(context: PlaywrightTestArgs["context"], page: Page) {
|
||||
const previewTabPromise = context.waitForEvent("page");
|
||||
await page.locator('[data-testid="preview-button"]').click();
|
||||
const previewTabPage = await previewTabPromise;
|
||||
await previewTabPage.waitForLoadState();
|
||||
await selectFirstAvailableTimeSlotNextMonth(previewTabPage);
|
||||
await previewTabPage.waitForNavigation({
|
||||
url: (url) => url.pathname.endsWith("/book"),
|
||||
});
|
||||
return previewTabPage;
|
||||
}
|
||||
|
||||
async function saveEventType(page: Page) {
|
||||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
}
|
||||
|
||||
async function addWebhook(user: Awaited<ReturnType<typeof createAndLoginUserWithEventTypes>>) {
|
||||
const webhookReceiver = createHttpServer();
|
||||
await prisma.webhook.create({
|
||||
data: {
|
||||
id: uuid(),
|
||||
userId: user.id,
|
||||
subscriberUrl: webhookReceiver.url,
|
||||
eventTriggers: [
|
||||
WebhookTriggerEvents.BOOKING_CREATED,
|
||||
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
],
|
||||
},
|
||||
});
|
||||
return webhookReceiver;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Onboarding", () => {
|
||||
test.describe("Onboarding v2", () => {
|
||||
test("Onboarding Flow", async ({ page, users }) => {
|
||||
|
||||
@@ -14,10 +14,9 @@ const IS_STRIPE_ENABLED = !!(
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Reschedule Tests", async () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Teams", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Teams", () => {
|
||||
test("Can create teams via Wizard", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
@@ -44,6 +44,8 @@ test.describe("Teams", () => {
|
||||
await removeMemberButton.click();
|
||||
await removeMemberButton.waitFor({ state: "hidden" });
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
// Cleanup here since this user is created without our fixtures.
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can publish team", async () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { expect } from "@playwright/test";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth, waitFor } from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test("add webhook & test that creating an event triggers a webhook call", async ({
|
||||
page,
|
||||
users,
|
||||
@@ -70,11 +72,62 @@ test("add webhook & test that creating an event triggers a webhook call", async
|
||||
body.payload.appsStatus = dynamic;
|
||||
body.payload.metadata.videoCallUrl = dynamic;
|
||||
|
||||
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
||||
// console.log("BODY", body);
|
||||
// Text files shouldn't have platform specific suffixes
|
||||
testInfo.snapshotSuffix = "";
|
||||
expect(JSON.stringify(body)).toMatchSnapshot(`webhookResponse.txt`);
|
||||
expect(body).toMatchObject({
|
||||
triggerEvent: "BOOKING_CREATED",
|
||||
createdAt: "[redacted/dynamic]",
|
||||
payload: {
|
||||
type: "30 min",
|
||||
title: "30 min between Nameless and Test Testson",
|
||||
description: "",
|
||||
additionalNotes: "",
|
||||
customInputs: {},
|
||||
startTime: "[redacted/dynamic]",
|
||||
endTime: "[redacted/dynamic]",
|
||||
organizer: {
|
||||
id: "[redacted/dynamic]",
|
||||
name: "Nameless",
|
||||
email: "[redacted/dynamic]",
|
||||
timeZone: "[redacted/dynamic]",
|
||||
language: "[redacted/dynamic]",
|
||||
},
|
||||
responses: {
|
||||
email: {
|
||||
value: "test@example.com",
|
||||
label: "email_address",
|
||||
},
|
||||
name: {
|
||||
value: "Test Testson",
|
||||
label: "your_name",
|
||||
},
|
||||
},
|
||||
userFieldsResponses: {},
|
||||
attendees: [
|
||||
{
|
||||
email: "test@example.com",
|
||||
name: "Test Testson",
|
||||
timeZone: "[redacted/dynamic]",
|
||||
language: "[redacted/dynamic]",
|
||||
},
|
||||
],
|
||||
location: "[redacted/dynamic]",
|
||||
destinationCalendar: null,
|
||||
hideCalendarNotes: false,
|
||||
requiresConfirmation: "[redacted/dynamic]",
|
||||
eventTypeId: "[redacted/dynamic]",
|
||||
seatsShowAttendees: true,
|
||||
seatsPerTimeSlot: null,
|
||||
uid: "[redacted/dynamic]",
|
||||
eventTitle: "30 min",
|
||||
eventDescription: null,
|
||||
price: 0,
|
||||
currency: "usd",
|
||||
length: 30,
|
||||
bookingId: "[redacted/dynamic]",
|
||||
metadata: { videoCallUrl: "[redacted/dynamic]" },
|
||||
status: "ACCEPTED",
|
||||
additionalInformation: "[redacted/dynamic]",
|
||||
},
|
||||
});
|
||||
|
||||
webhookReceiver.close();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user