Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633d85e81b | ||
|
|
49a61207d5 | ||
|
|
f3eb490e3b | ||
|
|
2de5f57437 | ||
|
|
8bdd835a68 | ||
|
|
9c7d0ea604 | ||
|
|
a3aa53fe6e | ||
|
|
57384eb921 | ||
|
|
a153f9627d | ||
|
|
a54539d211 | ||
|
|
8d736cfb67 | ||
|
|
3c3564f884 | ||
|
|
cc75899b01 | ||
|
|
1a818ab0c3 | ||
|
|
7dafaf1381 | ||
|
|
74f354abd2 | ||
|
|
f281131bf6 | ||
|
|
22b4bcdeeb | ||
|
|
5f59ef2acf | ||
|
|
d6d4e246f9 | ||
|
|
a49c34e733 | ||
|
|
37ce8860b5 | ||
|
|
4dfbc46247 | ||
|
|
4c38ea3329 | ||
|
|
24e60498f4 | ||
|
|
fa9a8cca89 | ||
|
|
38bb01a9f0 | ||
|
|
3cf6fef159 | ||
|
|
26dac453e9 | ||
|
|
52db023f76 | ||
|
|
df9e02d138 | ||
|
|
d7be40f67b | ||
|
|
68d2a97e92 | ||
|
|
cde9b24a9b | ||
|
|
cf89598279 | ||
|
|
fcd892bfa0 | ||
|
|
bee5011ec1 | ||
|
|
e907ac8a9a | ||
|
|
c22dfdd54e | ||
|
|
2aa0188418 | ||
|
|
59ea4d624b | ||
|
|
975420181d | ||
|
|
4d9a7c1b69 | ||
|
|
382cbe0a86 | ||
|
|
0d1ab6ade9 | ||
|
|
428e330301 | ||
|
|
f5350b3a2b | ||
|
|
4cd8935375 | ||
|
|
05020dfa9d | ||
|
|
e5f15cb1df | ||
|
|
6b536030fa | ||
|
|
46de1a8518 | ||
|
|
4b78e91384 | ||
|
|
b9cec94880 | ||
|
|
7b375ce814 | ||
|
|
29d4348e7c | ||
|
|
826a8856e6 | ||
|
|
2f23b5de9b | ||
|
|
df674532a4 | ||
|
|
4d9047974e | ||
|
|
ffa3a454bb | ||
|
|
0d62311b41 | ||
|
|
d36002d24c | ||
|
|
7b837d48f1 | ||
|
|
8dc9f1c929 | ||
|
|
b006f7f1df | ||
|
|
9a08fbb22d | ||
|
|
359779cabb | ||
|
|
d22d8095e1 | ||
|
|
123ecf3700 | ||
|
|
710bfc0d7e | ||
|
|
c7d8d435f1 | ||
|
|
506ef2078f | ||
|
|
e2cb1773ff | ||
|
|
89808cb4f6 | ||
|
|
bf45dcf139 | ||
|
|
5491821baf | ||
|
|
669065cebd | ||
|
|
91af873db9 | ||
|
|
2de4abba65 | ||
|
|
1b6d1b3a5a | ||
|
|
a56b19e618 | ||
|
|
ea7853789c | ||
|
|
871e17a865 | ||
|
|
95dee6dd37 | ||
|
|
23f97bdd3c | ||
|
|
bf050b498c | ||
|
|
83678dfea2 | ||
|
|
02c5aa02df | ||
|
|
b7851e6e53 | ||
|
|
4ca9138e01 | ||
|
|
e44a07b67c | ||
|
|
86690a4c32 | ||
|
|
c8beb6fba0 | ||
|
|
a97e145cdb | ||
|
|
617e665004 | ||
|
|
a9344115d2 | ||
|
|
81c11f61dc | ||
|
|
7c64b6a6e2 | ||
|
|
17855251ff | ||
|
|
a0fc045852 | ||
|
|
1b0e4b0127 | ||
|
|
60a559940c | ||
|
|
b893afb8b8 | ||
|
|
6fa54aba46 | ||
|
|
88f542cb0d | ||
|
|
acc4909981 | ||
|
|
4a6dc50909 | ||
|
|
4435451e9b | ||
|
|
12abca65b8 | ||
|
|
e93f1e2329 | ||
|
|
5a430df5d9 | ||
|
|
fcd8de43d6 | ||
|
|
9498ad4916 | ||
|
|
d21135e076 | ||
|
|
65384d7b1f | ||
|
|
e68ace87e2 | ||
|
|
8f9f096723 | ||
|
|
0f84102f1e | ||
|
|
e6ae69a20f | ||
|
|
878f074cb6 | ||
|
|
2c2fd14050 | ||
|
|
5a919acd22 | ||
|
|
063c7f6244 | ||
|
|
344b9e9b2b | ||
|
|
e8339030c2 | ||
|
|
4bc4ff8193 | ||
|
|
6cff76cc3b | ||
|
|
d47e8b48a6 | ||
|
|
96fb3a357e | ||
|
|
1d5b383529 | ||
|
|
766a64c46e | ||
|
|
65600b76f2 | ||
|
|
0c37f8a712 | ||
|
|
aa21217cfb | ||
|
|
32068a646e | ||
|
|
697e80966b | ||
|
|
35d55b2960 | ||
|
|
c1a562af97 | ||
|
|
e81985c80d | ||
|
|
a1e6e8ff32 | ||
|
|
54cf49cfd6 | ||
|
|
e4a075b5e2 | ||
|
|
660d28b286 | ||
|
|
2b1c600d99 | ||
|
|
48b79b618f | ||
|
|
98a156311d | ||
|
|
8cdddfa1d0 | ||
|
|
4f488be654 | ||
|
|
cdb78b686b | ||
|
|
f1acedff77 |
@@ -1,17 +0,0 @@
|
||||
name: "Set environment variables"
|
||||
description: "Configures environment variables for a workflow"
|
||||
runs:
|
||||
using: "composite"
|
||||
|
||||
steps:
|
||||
- name: Read env file
|
||||
uses: buildjet/cache@v3
|
||||
id: env-cache
|
||||
with:
|
||||
path: gh.env-${{ github.sha }}
|
||||
key: env-cache
|
||||
restore-keys: env-cache-
|
||||
- name: Set Environment Variables
|
||||
uses: tw3lveparsecs/github-actions-setvars@latest
|
||||
with:
|
||||
envFilePath: gh.env-${{ github.sha }}
|
||||
@@ -15,6 +15,11 @@ jobs:
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
mailhog:
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
ports:
|
||||
- 8025:8025
|
||||
- 1025:1025
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -27,16 +32,37 @@ 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
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn e2e:app-store --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: app-store-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
path: app-store-results
|
||||
path: test-results
|
||||
|
||||
@@ -27,7 +27,6 @@ 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
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
@@ -35,10 +34,32 @@ jobs:
|
||||
yarn e2e:embed-react --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
yarn workspace @calcom/embed-react packaged:tests
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: embed-react-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
path: embed-react-results
|
||||
path: test-results
|
||||
|
||||
@@ -15,6 +15,11 @@ jobs:
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
mailhog:
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
ports:
|
||||
- 8025:8025
|
||||
- 1025:1025
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -27,16 +32,37 @@ 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
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn e2e:embed --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: embed-core-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
||||
path: embed-core-results
|
||||
path: test-results
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: E2E test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
@@ -14,6 +16,11 @@ jobs:
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
mailhog:
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
ports:
|
||||
- 8025:8025
|
||||
- 1025:1025
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -24,13 +31,38 @@ 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
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
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}}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Create .env file
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
ENV_FILE_NAME: gh.env-${{ github.sha }}
|
||||
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_CRON_ENABLE_APP_SYNC: true|false
|
||||
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
|
||||
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_SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
INPUT_ENV_SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
INPUT_ENV_TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
INPUT_ENV_TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
create_env_file:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ozaytsev86/create-env-file@v1
|
||||
|
||||
with:
|
||||
file-name: ${{ github.workspace }}/${{ env.ENV_FILE_NAME }}
|
||||
- uses: buildjet/cache@v3
|
||||
id: env-cache
|
||||
with:
|
||||
path: ${{ env.ENV_FILE_NAME }}
|
||||
key: env-cache-${{ hashFiles(env.ENV_FILE_NAME) }}
|
||||
@@ -7,13 +7,8 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
env:
|
||||
name: Create env file
|
||||
uses: ./.github/workflows/env-create-file.yml
|
||||
secrets: inherit
|
||||
build:
|
||||
name: Production build
|
||||
needs: env
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: ./.github/workflows/production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -44,11 +44,6 @@ jobs:
|
||||
- 'packages/embeds/**'
|
||||
- 'playwright.config.ts'
|
||||
|
||||
env:
|
||||
name: Create env file
|
||||
uses: ./.github/workflows/env-create-file.yml
|
||||
secrets: inherit
|
||||
|
||||
type-check:
|
||||
name: Type check
|
||||
uses: ./.github/workflows/check-types.yml
|
||||
@@ -66,13 +61,11 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Production build
|
||||
needs: env
|
||||
uses: ./.github/workflows/production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-without-database:
|
||||
name: Production build (without database)
|
||||
needs: env
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
|
||||
@@ -3,6 +3,30 @@ name: Production Build (without database)
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
@@ -12,5 +36,4 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/env-read-file
|
||||
- uses: ./.github/actions/cache-build
|
||||
|
||||
@@ -3,6 +3,30 @@ name: Production Build
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
@@ -20,6 +44,5 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/env-read-file
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
name: "Welcome new contributors"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: opened
|
||||
pull_request:
|
||||
types: opened
|
||||
issues:
|
||||
types: opened
|
||||
pull_request:
|
||||
types: opened
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
welcome-message:
|
||||
name: Welcoming New Users
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: semantics
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-message: |-
|
||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
||||
Feel free to join the conversation at [discord](https://go.cal.com/discord)
|
||||
issue-message: |
|
||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
||||
welcome-message:
|
||||
name: Welcoming New Users
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-message: |-
|
||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
||||
Feel free to join the conversation at [discord](https://go.cal.com/discord)
|
||||
issue-message: |
|
||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
# Scheduling infrastructure for absolutely everyone
|
||||
|
||||
The open source Calendly alternative. You are in charge
|
||||
of your own data, workflow and appearance.
|
||||
of your own data, workflow, and appearance.
|
||||
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes and even calls with our families. However, most tools are very limited in terms of control and customizations.
|
||||
Calendly and other scheduling tools are awesome. It made our lives massively easier. We're using it for business meetings, seminars, yoga classes, and even calls with our families. However, most tools are very limited in terms of control and customization.
|
||||
|
||||
That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data.
|
||||
|
||||
@@ -98,7 +98,7 @@ Meet our sales team for any commercial inquiries.
|
||||
|
||||
## Stay Up-to-Date
|
||||
|
||||
Cal.com officially launched as v.1.0 on 15th of September 2021 and we've come a long way so far. Watch **releases** of this repository to be notified of future updates:
|
||||
Cal.com officially launched as v.1.0 on the 15th of September 2021 and we've come a long way so far. Watch **releases** of this repository to be notified of future updates:
|
||||
|
||||

|
||||
|
||||
@@ -128,7 +128,7 @@ Here is what you need to be able to run Cal.com.
|
||||
git clone https://github.com/calcom/cal.com.git
|
||||
```
|
||||
|
||||
> If you are on windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
||||
> If you are on Windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
||||
> See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
|
||||
|
||||
1. Go to the project folder
|
||||
@@ -254,7 +254,7 @@ Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If
|
||||
# In a terminal just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
# To open the last HTML report run:
|
||||
yarn playwright show-report test-results/reports/playwright-html-report
|
||||
```
|
||||
|
||||
@@ -545,6 +545,4 @@ Special thanks to these amazing projects which help power Cal.com:
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Prisma](https://prisma.io/)
|
||||
|
||||
<a href="https://jitsu.com/?utm_source=cal.com-gihub"><img height="40px" src="https://jitsu.com/img/powered-by-jitsu.png?gh=true" alt="Jitsu.com"></a>
|
||||
|
||||
Cal.com is an [open startup](https://cal.com/open) and [Jitsu](https://github.com/jitsucom/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
|
||||
export const captureErrors: NextMiddleware = async (_req, res, next) => {
|
||||
try {
|
||||
// Catch any errors that are thrown in remaining
|
||||
@@ -8,7 +10,11 @@ export const captureErrors: NextMiddleware = async (_req, res, next) => {
|
||||
await next();
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.log(error);
|
||||
const redactedError = redactError(error);
|
||||
if (redactedError instanceof Error) {
|
||||
res.status(400).json({ message: redactedError.message, error: redactedError });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ message: "Something went wrong", error });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||
|
||||
@@ -17,7 +18,7 @@ export const dateNotInPast = function (date: Date) {
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const { prisma, isCustomPrisma, isAdmin } = req;
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
if (!hasValidLicense && process.env.NODE_ENV !== "development")
|
||||
if (!hasValidLicense && IS_PRODUCTION)
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
// If the user is an admin and using a license key (from customPrisma), skip the apiKey check.
|
||||
if (isCustomPrisma && isAdmin) {
|
||||
|
||||
@@ -28,10 +28,17 @@ const withMiddleware = label(
|
||||
customPrismaClient,
|
||||
extendRequest,
|
||||
pagination: withPagination,
|
||||
sentry: captureErrors,
|
||||
captureErrors,
|
||||
},
|
||||
// The order here, determines the order of execution, put customPrismaClient before verifyApiKey always.
|
||||
["extendRequest", "sentry", "customPrismaClient", "verifyApiKey", "addRequestId"] // <-- Provide a list of middleware to call automatically
|
||||
// The order here, determines the order of execution
|
||||
[
|
||||
"extendRequest",
|
||||
"captureErrors",
|
||||
// - Put customPrismaClient before verifyApiKey always.
|
||||
"customPrismaClient",
|
||||
"verifyApiKey",
|
||||
"addRequestId",
|
||||
] // <-- Provide a list of middleware to call automatically
|
||||
);
|
||||
|
||||
export { withMiddleware };
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { availabilityUserSelect } from "@calcom/prisma";
|
||||
@@ -120,10 +119,8 @@ const availabilitySchema = z
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { prisma, isAdmin, userId: reqUserId } = req;
|
||||
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
if (!teamId)
|
||||
return getUserAvailability({
|
||||
orgSlug: isValidOrgDomain ? currentOrgDomain || undefined : undefined,
|
||||
username,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
|
||||
@@ -56,6 +56,7 @@ async function getHandler(req: NextApiRequest) {
|
||||
owner: { select: { username: true, id: true } },
|
||||
},
|
||||
});
|
||||
if (data.length === 0) new HttpError({ statusCode: 404, message: "No event types were found" });
|
||||
return {
|
||||
event_types: data.map((eventType) => {
|
||||
const link = getCalLink(eventType);
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
// If user is not ADMIN, return only his data.
|
||||
if (!isAdmin) where.id = userId;
|
||||
const [total, data] = await prisma.$transaction([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where }),
|
||||
prisma.user.findMany({ where, take, skip }),
|
||||
]);
|
||||
const users = schemaUsersReadPublic.parse(data);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Meta } from '@storybook/addon-docs';
|
||||
|
||||
<div className="text-center flex flex-col items-center">
|
||||
<h1 style={{marginBottom: '24px', marginTop: '36px'}}>Welcome to Cal.com UI</h1>
|
||||
<p>This is the beginning of our storybook impovments to match Figma as close as possible. Like our Figma library, we will be adding more components as we go along.</p>
|
||||
<p>This is the beginning of our storybook improvements to match Figma as close as possible. Like our Figma library, we will be adding more components as we go along.</p>
|
||||
<p>Our <a href="https://www.figma.com/file/9MOufQNLtdkpnDucmNX10R/%E2%9D%96-Cal-DS" target="_blank">Figma</a> library is avalible for anyone to view and use. If you have any questions or concerns, please reach out to the design team.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { Badge, ListItemText, Avatar } from "@calcom/ui";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
|
||||
type ShouldHighlight = { slug: string; shouldHighlight: true } | { shouldHighlight?: never; slug?: never };
|
||||
type ShouldHighlight =
|
||||
| {
|
||||
slug: string;
|
||||
shouldHighlight: true;
|
||||
}
|
||||
| {
|
||||
shouldHighlight?: never;
|
||||
slug?: never;
|
||||
};
|
||||
|
||||
type AppListCardProps = {
|
||||
logo?: string;
|
||||
@@ -46,14 +55,16 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
const router = useRouter();
|
||||
const [highlight, setHighlight] = useState(shouldHighlight && hl === slug);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldHighlight && highlight) {
|
||||
const timer = setTimeout(() => {
|
||||
setHighlight(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("hl");
|
||||
router.replace(url.pathname, undefined, { shallow: true });
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
_searchParams.delete("hl");
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
}, 3000);
|
||||
timeoutRef.current = timer;
|
||||
}
|
||||
@@ -96,7 +107,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
className="mr-2"
|
||||
alt={credentialOwner.name || "Nameless"}
|
||||
size="xs"
|
||||
imageSrc={credentialOwner.avatar}
|
||||
imageSrc={getPlaceholderAvatar(credentialOwner.avatar, credentialOwner?.name as string)}
|
||||
/>
|
||||
{credentialOwner.name}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import React from "react";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: "app_store",
|
||||
href: "/apps",
|
||||
},
|
||||
{
|
||||
name: "installed_apps",
|
||||
href: "/apps/installed",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AppsShell({ children }: { children: React.ReactNode }) {
|
||||
const { status } = useSession();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-12 block lg:hidden">{status === "authenticated" && <NavTabs tabs={tabs} />}</div>
|
||||
<main className="pb-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BookingsShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<NavTabs tabs={tabs} />
|
||||
<main>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { noop } from "lodash";
|
||||
import type { LinkProps } from "next/link";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import type { FC, MouseEventHandler } from "react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { PermissionContainer } from "@calcom/features/auth/PermissionContainer";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import type { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
export interface NavTabProps {
|
||||
tabs: {
|
||||
name: string;
|
||||
/** If you want to change the path as per current tab */
|
||||
href?: string;
|
||||
/** If you want to change query param tabName as per current tab */
|
||||
tabName?: string;
|
||||
icon?: SVGComponent;
|
||||
adminRequired?: boolean;
|
||||
className?: string;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
}
|
||||
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="no-scrollbar -mb-px flex space-x-5 overflow-x-scroll rtl:space-x-reverse sm:rtl:space-x-reverse"
|
||||
aria-label="Tabs"
|
||||
{...props}>
|
||||
{tabs.map((tab) => {
|
||||
if ((tab.tabName && tab.href) || (!tab.tabName && !tab.href)) {
|
||||
throw new Error("Use either tabName or href");
|
||||
}
|
||||
let href = "";
|
||||
let isCurrent;
|
||||
if (tab.href) {
|
||||
href = tab.href;
|
||||
isCurrent = router.asPath === tab.href;
|
||||
} else if (tab.tabName) {
|
||||
href = "";
|
||||
isCurrent = router.query.tabName === tab.tabName;
|
||||
}
|
||||
|
||||
const onClick: MouseEventHandler = tab.tabName
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
tabName: tab.tabName,
|
||||
},
|
||||
});
|
||||
}
|
||||
: noop;
|
||||
|
||||
const Component = tab.adminRequired ? PermissionContainer : Fragment;
|
||||
const className = tab.className || "";
|
||||
return (
|
||||
<Component key={tab.name}>
|
||||
<Link key={tab.name} href={href} {...linkProps} legacyBehavior>
|
||||
<a
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "text-emphasis border-gray-900"
|
||||
: "hover:border-default hover:text-default text-subtle border-transparent",
|
||||
"group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
aria-current={isCurrent ? "page" : undefined}>
|
||||
{tab.icon && (
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
isCurrent ? "text-emphasis" : "group-hover:text-subtle text-muted",
|
||||
"-ml-0.5 hidden h-4 w-4 ltr:mr-2 rtl:ml-2 sm:inline-block"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{t(tab.name)}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</Component>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<hr className="border-subtle" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavTabs;
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ErrorBoundary } from "@calcom/ui";
|
||||
import { CreditCard, Key, Lock, Terminal, User, Users } from "@calcom/ui/components/icon";
|
||||
|
||||
import NavTabs from "./NavTabs";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: "profile",
|
||||
href: "/settings/my-account/profile",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
name: "teams",
|
||||
href: "/settings/teams",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: "security",
|
||||
href: "/settings/security",
|
||||
icon: Key,
|
||||
},
|
||||
{
|
||||
name: "developer",
|
||||
href: "/settings/developer",
|
||||
icon: Terminal,
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "admin",
|
||||
href: "/settings/admin",
|
||||
icon: Lock,
|
||||
adminRequired: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsShell({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
return (
|
||||
<Shell {...rest} hideHeadingOnMobile>
|
||||
<div className="sm:mx-auto">
|
||||
<NavTabs tabs={tabs} />
|
||||
</div>
|
||||
<main className="max-w-4xl">
|
||||
<>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</>
|
||||
</main>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -1,376 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App as AppType } from "@calcom/types/App";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import type { AppFrontendPayload as App } from "@calcom/types/App";
|
||||
import { Button, showToast, SkeletonButton, SkeletonText, HeadSeo, Badge } from "@calcom/ui";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuLabel,
|
||||
DropdownItem,
|
||||
Avatar,
|
||||
} from "@calcom/ui";
|
||||
import { BookOpen, Check, ExternalLink, File, Flag, Mail, Plus, Shield } from "@calcom/ui/components/icon";
|
||||
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
|
||||
/* These app slugs all require Google Cal to be installed */
|
||||
|
||||
const Component = ({
|
||||
name,
|
||||
type,
|
||||
logo,
|
||||
slug,
|
||||
variant,
|
||||
body,
|
||||
categories,
|
||||
author,
|
||||
price = 0,
|
||||
commission,
|
||||
isGlobal = false,
|
||||
feeType,
|
||||
docs,
|
||||
website,
|
||||
email,
|
||||
tos,
|
||||
privacy,
|
||||
teamsPlanRequired,
|
||||
descriptionItems,
|
||||
isTemplate,
|
||||
dependencies,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price);
|
||||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
onSettled(data) {
|
||||
const credentialsCount = data?.credentials.length || 0
|
||||
setShowDisconnectIntegration(data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0);
|
||||
setExistingCredentials(data?.credentials.map(credential => credential.id) || []);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasDescriptionItems && (
|
||||
<div className="align-center bg-subtle -ml-4 -mr-4 mb-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap p-4 md:-ml-8 md:-mr-8 md:mb-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||
{descriptionItems ? (
|
||||
descriptionItems.map((descriptionItem, index) =>
|
||||
typeof descriptionItem === "object" ? (
|
||||
<div
|
||||
key={`iframe-${index}`}
|
||||
className="mr-4 max-h-full min-h-[315px] min-w-[90%] max-w-full snap-center last:mb-0 lg:mb-4 lg:mr-0 [&_iframe]:h-full [&_iframe]:min-h-[315px] [&_iframe]:w-full">
|
||||
<iframe allowFullScreen {...descriptionItem.iframe} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={descriptionItem}
|
||||
src={descriptionItem}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<SkeletonText />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
|
||||
hasDescriptionItems && "lg:ml-8"
|
||||
)}>
|
||||
<div className="mb-8 flex pt-4">
|
||||
<header>
|
||||
<div className="mb-4 flex items-center">
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "min-h-16 min-w-16 h-16 w-16")}
|
||||
src={logo}
|
||||
alt={name}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis ml-4 text-3xl">{name}</h1>
|
||||
</div>
|
||||
<h2 className="text-default text-sm font-medium">
|
||||
<Link
|
||||
href={`categories/${categories[0]}`}
|
||||
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
|
||||
{categories[0]}
|
||||
</Link>{" "}
|
||||
•{" "}
|
||||
<a target="_blank" rel="noreferrer" href={website}>
|
||||
{t("published_by", { author })}
|
||||
</a>
|
||||
</h2>
|
||||
{isTemplate && (
|
||||
<Badge variant="red" className="mt-4">
|
||||
Template - Available in Dev Environment only for testing
|
||||
</Badge>
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
{!appDbQuery.isLoading ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={Check} color="secondary" disabled>
|
||||
{existingCredentials.length > 0
|
||||
? t("active_install", { count: existingCredentials.length })
|
||||
: t("default")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
disableInstall={disableInstall}
|
||||
teamsPlanRequired={teamsPlanRequired}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{ type, variant, slug }} multiInstall {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : showDisconnectIntegration ? (
|
||||
<DisconnectIntegration
|
||||
buttonProps={{ color: "secondary" }}
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
appDbQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
disableInstall={disableInstall}
|
||||
teamsPlanRequired={teamsPlanRequired}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild appCategories={categories} userAdminTeams={appDbQuery.data?.userAdminTeams} addAppMutationInput={{ type, variant, slug }} credentials={appDbQuery.data?.credentials} {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<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}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
|
||||
{body}
|
||||
</div>
|
||||
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
|
||||
<span className="text-default">
|
||||
{teamsPlanRequired ? (
|
||||
t("teams_plan_required")
|
||||
) : price === 0 ? (
|
||||
t("free_to_use_apps")
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis text-sm font-normal no-underline hover:underline"
|
||||
href={docs}>
|
||||
<BookOpen className="text-subtle -mt-1 mr-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={website}>
|
||||
<ExternalLink className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<Mail className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={tos}>
|
||||
<File className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<Shield className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<span className="leading-1 text-subtle block text-xs">
|
||||
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
|
||||
</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
|
||||
<Flag className="inline h-3 w-3" /> {t("report_app")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import type { AppPageProps } from "./AppPage";
|
||||
import { AppPage } from "./AppPage";
|
||||
|
||||
const ShellHeading = () => {
|
||||
const { t } = useLocale();
|
||||
return <span className="block py-2">{t("app_store")}</span>;
|
||||
};
|
||||
|
||||
export default function App(props: {
|
||||
name: string;
|
||||
description: AppType["description"];
|
||||
type: AppType["type"];
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
slug: string;
|
||||
variant: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
author: string;
|
||||
pro?: boolean;
|
||||
price?: number;
|
||||
commission?: number;
|
||||
feeType?: AppType["feeType"];
|
||||
docs?: string;
|
||||
website?: string;
|
||||
email: string; // required
|
||||
tos?: string;
|
||||
privacy?: string;
|
||||
licenseRequired: AppType["licenseRequired"];
|
||||
teamsPlanRequired: AppType["teamsPlanRequired"];
|
||||
descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
|
||||
isTemplate?: boolean;
|
||||
disableInstall?: boolean;
|
||||
dependencies?: string[];
|
||||
}) {
|
||||
export default function WrappedApp(props: AppPageProps) {
|
||||
return (
|
||||
<Shell smallHeading isPublic hideHeadingOnMobile heading={<ShellHeading />} backPath="/apps" withoutSeo>
|
||||
<HeadSeo
|
||||
@@ -380,116 +21,11 @@ export default function App(props: {
|
||||
/>
|
||||
{props.licenseRequired ? (
|
||||
<LicenseRequired>
|
||||
<Component {...props} />
|
||||
<AppPage {...props} />
|
||||
</LicenseRequired>
|
||||
) : (
|
||||
<Component {...props} />
|
||||
<AppPage {...props} />
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const InstallAppButtonChild = ({
|
||||
userAdminTeams,
|
||||
addAppMutationInput,
|
||||
appCategories,
|
||||
multiInstall,
|
||||
credentials,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
addAppMutationInput: { type: App["type"]; variant: string; slug: string };
|
||||
appCategories: string[];
|
||||
multiInstall?: boolean;
|
||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
if (!userAdminTeams?.length || appCategories.some((category) => ["calendar", "conferencing"].includes(category))) {
|
||||
return <Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
onInteractOutside={(event) => {
|
||||
if (mutation.isLoading) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{mutation.isLoading && (
|
||||
<div className="z-1 fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
|
||||
{userAdminTeams.map((team) => {
|
||||
|
||||
const isInstalled = credentials &&
|
||||
credentials.some((credential) =>
|
||||
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
||||
key={team.id}
|
||||
disabled={
|
||||
isInstalled
|
||||
}
|
||||
StartIcon={(props) => (
|
||||
<Avatar
|
||||
alt={team.logo || ""}
|
||||
imageSrc={team.logo || `${CAL_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
mutation.mutate(
|
||||
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
|
||||
);
|
||||
}}>
|
||||
<p>{team.name}{" "}
|
||||
{isInstalled &&
|
||||
`(${t("installed")})`}</p>
|
||||
</DropdownItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import Link from "next/link";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { App as AppType } from "@calcom/types/App";
|
||||
import { Badge, Button, showToast, SkeletonButton, SkeletonText } from "@calcom/ui";
|
||||
import { BookOpen, Check, ExternalLink, File, Flag, Mail, Shield } from "@calcom/ui/components/icon";
|
||||
|
||||
import { InstallAppButtonChild } from "./InstallAppButtonChild";
|
||||
|
||||
export type AppPageProps = {
|
||||
name: string;
|
||||
description: AppType["description"];
|
||||
type: AppType["type"];
|
||||
isGlobal?: AppType["isGlobal"];
|
||||
logo: string;
|
||||
slug: string;
|
||||
variant: string;
|
||||
body: React.ReactNode;
|
||||
categories: string[];
|
||||
author: string;
|
||||
pro?: boolean;
|
||||
price?: number;
|
||||
commission?: number;
|
||||
feeType?: AppType["feeType"];
|
||||
docs?: string;
|
||||
website?: string;
|
||||
email: string; // required
|
||||
tos?: string;
|
||||
privacy?: string;
|
||||
licenseRequired: AppType["licenseRequired"];
|
||||
teamsPlanRequired: AppType["teamsPlanRequired"];
|
||||
descriptionItems?: Array<string | { iframe: IframeHTMLAttributes<HTMLIFrameElement> }>;
|
||||
isTemplate?: boolean;
|
||||
disableInstall?: boolean;
|
||||
dependencies?: string[];
|
||||
concurrentMeetings: AppType["concurrentMeetings"];
|
||||
};
|
||||
|
||||
export const AppPage = ({
|
||||
name,
|
||||
type,
|
||||
logo,
|
||||
slug,
|
||||
variant,
|
||||
body,
|
||||
categories,
|
||||
author,
|
||||
price = 0,
|
||||
commission,
|
||||
isGlobal = false,
|
||||
feeType,
|
||||
docs,
|
||||
website,
|
||||
email,
|
||||
tos,
|
||||
privacy,
|
||||
teamsPlanRequired,
|
||||
descriptionItems,
|
||||
isTemplate,
|
||||
dependencies,
|
||||
concurrentMeetings,
|
||||
}: AppPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const priceInDollar = Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price);
|
||||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
onSettled(data) {
|
||||
const credentialsCount = data?.credentials.length || 0;
|
||||
setShowDisconnectIntegration(
|
||||
data?.userAdminTeams.length ? credentialsCount >= data?.userAdminTeams.length : credentialsCount > 0
|
||||
);
|
||||
setExistingCredentials(data?.credentials.map((credential) => credential.id) || []);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasDescriptionItems && (
|
||||
<div className="align-center bg-subtle -ml-4 -mr-4 mb-4 flex min-h-[450px] w-auto basis-3/5 snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap p-4 md:-ml-8 md:-mr-8 md:mb-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:justify-center lg:rounded-md">
|
||||
{descriptionItems ? (
|
||||
descriptionItems.map((descriptionItem, index) =>
|
||||
typeof descriptionItem === "object" ? (
|
||||
<div
|
||||
key={`iframe-${index}`}
|
||||
className="mr-4 max-h-full min-h-[315px] min-w-[90%] max-w-full snap-center last:mb-0 lg:mb-4 lg:mr-0 [&_iframe]:h-full [&_iframe]:min-h-[315px] [&_iframe]:w-full">
|
||||
<iframe allowFullScreen {...descriptionItem.iframe} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={descriptionItem}
|
||||
src={descriptionItem}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<SkeletonText />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"sticky top-0 -mt-4 max-w-xl basis-2/5 pb-12 text-sm lg:pb-0",
|
||||
hasDescriptionItems && "lg:ml-8"
|
||||
)}>
|
||||
<div className="mb-8 flex pt-4">
|
||||
<header>
|
||||
<div className="mb-4 flex items-center">
|
||||
<img
|
||||
className={classNames(logo.includes("-dark") && "dark:invert", "min-h-16 min-w-16 h-16 w-16")}
|
||||
src={logo}
|
||||
alt={name}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis ml-4 text-3xl">{name}</h1>
|
||||
</div>
|
||||
<h2 className="text-default text-sm font-medium">
|
||||
<Link
|
||||
href={`categories/${categories[0]}`}
|
||||
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
|
||||
{categories[0]}
|
||||
</Link>{" "}
|
||||
•{" "}
|
||||
<a target="_blank" rel="noreferrer" href={website}>
|
||||
{t("published_by", { author })}
|
||||
</a>
|
||||
</h2>
|
||||
{isTemplate && (
|
||||
<Badge variant="red" className="mt-4">
|
||||
Template - Available in Dev Environment only for testing
|
||||
</Badge>
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
{!appDbQuery.isLoading ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={Check} color="secondary" disabled>
|
||||
{existingCredentials.length > 0
|
||||
? t("active_install", { count: existingCredentials.length })
|
||||
: t("default")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
disableInstall={disableInstall}
|
||||
teamsPlanRequired={teamsPlanRequired}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
appCategories={categories}
|
||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
multiInstall
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : showDisconnectIntegration ? (
|
||||
<DisconnectIntegration
|
||||
buttonProps={{ color: "secondary" }}
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
appDbQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
disableInstall={disableInstall}
|
||||
teamsPlanRequired={teamsPlanRequired}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
...props,
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
appCategories={categories}
|
||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
credentials={appDbQuery.data?.credentials}
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<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}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
|
||||
{body}
|
||||
</div>
|
||||
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
|
||||
<span className="text-default">
|
||||
{teamsPlanRequired ? (
|
||||
t("teams_plan_required")
|
||||
) : price === 0 ? (
|
||||
t("free_to_use_apps")
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis text-sm font-normal no-underline hover:underline"
|
||||
href={docs}>
|
||||
<BookOpen className="text-subtle -mt-1 mr-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={website}>
|
||||
<ExternalLink className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<Mail className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={tos}>
|
||||
<File className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-emphasis font-normal no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<Shield className="text-subtle -mt-px mr-1 inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="border-subtle my-8 border" />
|
||||
<span className="leading-1 text-subtle block text-xs">
|
||||
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
|
||||
</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
|
||||
<Flag className="inline h-3 w-3" /> {t("report_app")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
|
||||
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { AppFrontendPayload } from "@calcom/types/App";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
export const InstallAppButtonChild = ({
|
||||
userAdminTeams,
|
||||
addAppMutationInput,
|
||||
appCategories,
|
||||
multiInstall,
|
||||
credentials,
|
||||
concurrentMeetings,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
addAppMutationInput: { type: AppFrontendPayload["type"]; variant: string; slug: string };
|
||||
appCategories: string[];
|
||||
multiInstall?: boolean;
|
||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||
concurrentMeetings?: boolean;
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const mutation = useAddAppMutation(null, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.setupPending) return;
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
onInteractOutside={(event) => {
|
||||
if (mutation.isLoading) event.preventDefault();
|
||||
}}>
|
||||
{mutation.isLoading && (
|
||||
<div className="z-1 fixed inset-0 flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
|
||||
{userAdminTeams.map((team) => {
|
||||
const isInstalled =
|
||||
credentials &&
|
||||
credentials.some((credential) =>
|
||||
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownItem
|
||||
type="button"
|
||||
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
|
||||
key={team.id}
|
||||
disabled={isInstalled}
|
||||
StartIcon={(props) => (
|
||||
<Avatar
|
||||
alt={team.logo || ""}
|
||||
imageSrc={team.logo || `${CAL_URL}/${team.logo}/avatar.png`} // if no image, use default avatar
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
mutation.mutate(
|
||||
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
|
||||
);
|
||||
}}>
|
||||
<p>
|
||||
{team.name} {isInstalled && `(${t("installed")})`}
|
||||
</p>
|
||||
</DropdownItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
@@ -12,7 +12,7 @@ type AppsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
actions?: (className?: string) => JSX.Element;
|
||||
emptyStore?: boolean;
|
||||
} & Omit<ComponentProps<typeof Shell>, "actions">;
|
||||
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
|
||||
|
||||
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||
const { t } = useLocale();
|
||||
@@ -22,7 +22,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
return (
|
||||
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
<main className="w-full">
|
||||
{emptyStore ? (
|
||||
@@ -38,7 +38,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
export const getLayout = (page: React.ReactElement) => <AppsLayout>{page}</AppsLayout>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
@@ -10,9 +10,9 @@ import { ErrorBoundary } from "@calcom/ui";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
|
||||
...rest
|
||||
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
|
||||
const pathname = usePathname();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function AdminLayout({
|
||||
}
|
||||
}, [session, router]);
|
||||
|
||||
const isAppsPage = router.asPath.startsWith("/settings/admin/apps");
|
||||
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
|
||||
return (
|
||||
<SettingsLayout {...rest}>
|
||||
<div className="divide-subtle mx-auto flex max-w-4xl flex-row divide-y">
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
import { SkeletonContainer, SkeletonText, ToggleGroup } from "@calcom/ui";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
|
||||
type AvailableTimesProps = {
|
||||
timeFormat: TimeFormat;
|
||||
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||
eventTypeId: number;
|
||||
recurringCount: number | undefined;
|
||||
eventTypeSlug: string;
|
||||
date?: Dayjs;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
bookingAttendees?: number | null;
|
||||
slots?: Slot[];
|
||||
isLoading: boolean;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
slots = [],
|
||||
isLoading,
|
||||
date,
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
recurringCount,
|
||||
timeFormat,
|
||||
onTimeFormatChange,
|
||||
seatsPerTimeSlot,
|
||||
bookingAttendees,
|
||||
duration,
|
||||
}) => {
|
||||
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
|
||||
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
|
||||
const [brand, setBrand] = useState("#292929");
|
||||
|
||||
useEffect(() => {
|
||||
setBrand(getComputedStyle(document.documentElement).getPropertyValue("--brand-color").trim());
|
||||
}, []);
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const ref = useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
if (isMobile) {
|
||||
node?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
},
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const reserveSlot = (slot: Slot) => {
|
||||
// Prevent double clicking
|
||||
if (reserveSlotMutation.isLoading || reserveSlotMutation.isSuccess) {
|
||||
return;
|
||||
}
|
||||
reserveSlotMutation.mutate({
|
||||
slotUtcStartDate: slot.time,
|
||||
eventTypeId,
|
||||
slotUtcEndDate: dayjs(slot.time).utc().add(duration, "minutes").format(),
|
||||
bookingUid: slot.bookingUid,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={slotPickerRef}>
|
||||
{!!date ? (
|
||||
<div className="mt-8 flex h-full w-full flex-col rounded-md px-4 text-center sm:mt-0 sm:p-4 md:-mb-5 md:min-w-[200px] md:p-4 lg:min-w-[300px]">
|
||||
<div className="mb-4 flex items-center text-left text-base">
|
||||
<div className="mr-4">
|
||||
<span className="text-emphasis font-semibold">
|
||||
{nameOfDay(i18n.language, Number(date.format("d")), "short")}
|
||||
</span>
|
||||
<span className="text-subtle">
|
||||
, {date.toDate().toLocaleString(i18n.language, { month: "short" })} {date.format(" D ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<ToggleGroup
|
||||
onValueChange={(timeFormat) => onTimeFormatChange(timeFormat === "24")}
|
||||
defaultValue={timeFormat === TimeFormat.TWELVE_HOUR ? "12" : "24"}
|
||||
options={[
|
||||
{ value: "12", label: t("12_hour_short") },
|
||||
{ value: "24", label: t("24_hour_short") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className="scroll-bar scrollbar-track-w-20 relative -mb-4 flex-grow overflow-y-auto sm:block md:h-[364px]">
|
||||
{slots.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
type BookingURL = {
|
||||
pathname: string;
|
||||
query: Record<string, string | number | string[] | undefined | TimeFormat>;
|
||||
};
|
||||
const bookingUrl: BookingURL = {
|
||||
pathname: router.pathname.endsWith("/embed") ? "../book" : "book",
|
||||
query: {
|
||||
...router.query,
|
||||
date: dayjs.utc(slot.time).tz(timeZone()).format(),
|
||||
type: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
timeFormat,
|
||||
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (rescheduleUid) {
|
||||
bookingUrl.query.rescheduleUid = rescheduleUid as string;
|
||||
}
|
||||
|
||||
// If event already has an attendee add booking id
|
||||
if (slot.bookingUid) {
|
||||
bookingUrl.query.bookingUid = slot.bookingUid;
|
||||
}
|
||||
|
||||
let slotFull, notEnoughSeats;
|
||||
|
||||
if (slot.attendees && seatsPerTimeSlot) slotFull = slot.attendees >= seatsPerTimeSlot;
|
||||
if (slot.attendees && bookingAttendees && seatsPerTimeSlot) {
|
||||
notEnoughSeats = slot.attendees + bookingAttendees > seatsPerTimeSlot;
|
||||
}
|
||||
|
||||
const isHalfFull =
|
||||
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
|
||||
const isNearlyFull =
|
||||
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
|
||||
|
||||
const colorClass = isNearlyFull
|
||||
? "text-rose-600"
|
||||
: isHalfFull
|
||||
? "text-yellow-500"
|
||||
: "text-emerald-400";
|
||||
|
||||
return (
|
||||
<div data-slot-owner={(slot.userIds || []).join(",")} key={`${dayjs(slot.time).format()}`}>
|
||||
{/* ^ data-slot-owner is helpful in debugging and used to identify the owners of the slot. Owners are the users which have the timeslot in their schedule. It doesn't consider if a user has that timeslot booked */}
|
||||
{/* Current there is no way to disable Next.js Links */}
|
||||
{seatsPerTimeSlot && slot.attendees && (slotFull || notEnoughSeats) ? (
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default bg-default border-subtle mb-2 block rounded-sm border py-2 font-medium opacity-25",
|
||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}
|
||||
data-testid="time"
|
||||
data-disabled="true">
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{notEnoughSeats ? (
|
||||
<p className="text-sm">{t("not_enough_seats")}</p>
|
||||
) : slots ? (
|
||||
<p className="text-sm">{t("booking_full")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={bookingUrl}
|
||||
prefetch={false}
|
||||
className={classNames(
|
||||
" bg-default dark:bg-muted border-default hover:bg-subtle hover:border-brand-default text-emphasis mb-2 block rounded-md border py-2 text-sm font-medium",
|
||||
brand === "#fff" || brand === "#ffffff" ? "" : ""
|
||||
)}
|
||||
onClick={() => reserveSlot(slot)}
|
||||
data-testid="time"
|
||||
data-disabled="false">
|
||||
{dayjs(slot.time).tz(timeZone()).format(timeFormat)}
|
||||
{!!seatsPerTimeSlot && (
|
||||
<p className={`${colorClass} text-sm`}>
|
||||
{slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot} /{" "}
|
||||
{seatsPerTimeSlot}{" "}
|
||||
{t("seats_available", {
|
||||
count: slot.attendees ? seatsPerTimeSlot - slot.attendees : seatsPerTimeSlot,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isLoading && !slots.length && (
|
||||
<div className="-mt-4 flex h-full w-full flex-col content-center items-center justify-center">
|
||||
<h1 className="text-emphasis my-6 text-xl">{t("all_booked_today")}</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !slots.length && (
|
||||
<>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
<SkeletonContainer className="mb-2">
|
||||
<SkeletonText className="h-5 w-full" />
|
||||
</SkeletonContainer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AvailableTimes.displayName = "AvailableTimes";
|
||||
|
||||
export default AvailableTimes;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { i18n } from "next-i18next";
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { CreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
const BookingDescriptionPayment = (props: {
|
||||
eventType: Parameters<typeof getPaymentAppData>[0];
|
||||
t: TFunction;
|
||||
i18n: typeof i18n;
|
||||
}) => {
|
||||
const paymentAppData = getPaymentAppData(props.eventType);
|
||||
if (!paymentAppData || paymentAppData.price <= 0) return null;
|
||||
|
||||
const params = {
|
||||
amount: paymentAppData.price / 100.0,
|
||||
formatParams: { amount: { currency: paymentAppData.currency } },
|
||||
};
|
||||
|
||||
return (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<CreditCard className="-mt-1 ml-[2px] inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{paymentAppData.paymentOption === "HOLD" ? (
|
||||
<>{props.t("no_show_fee_amount", params)}</>
|
||||
) : (
|
||||
<>
|
||||
{/* If undefined this will default to the browser locale */}
|
||||
{new Intl.NumberFormat(i18n?.language, {
|
||||
style: "currency",
|
||||
currency: paymentAppData.currency,
|
||||
}).format(paymentAppData.price / 100)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingDescriptionPayment;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
@@ -158,7 +158,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
id: "cancel",
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
|
||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}
|
||||
@@ -239,13 +239,18 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const saveLocation = (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => {
|
||||
const saveLocation = (
|
||||
newLocationType: EventLocationType["type"],
|
||||
details: {
|
||||
[key: string]: string;
|
||||
}
|
||||
) => {
|
||||
let newLocation = newLocationType as string;
|
||||
const eventLocationType = getEventLocationType(newLocationType);
|
||||
if (eventLocationType?.organizerInputType) {
|
||||
newLocation = details[Object.keys(details)[0]];
|
||||
}
|
||||
setLocationMutation.mutate({ bookingId: booking.id, newLocation });
|
||||
setLocationMutation.mutate({ bookingId: booking.id, newLocation, details });
|
||||
};
|
||||
|
||||
// Getting accepted recurring dates to show
|
||||
@@ -255,13 +260,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
||||
|
||||
const onClickTableData = () => {
|
||||
router.push({
|
||||
pathname: `/booking/${booking.uid}`,
|
||||
query: {
|
||||
allRemainingBookings: isTabRecurring,
|
||||
email: booking.attendees[0] ? booking.attendees[0].email : undefined,
|
||||
},
|
||||
const urlSearchParams = new URLSearchParams({
|
||||
allRemainingBookings: isTabRecurring.toString(),
|
||||
});
|
||||
if (booking.attendees[0]) urlSearchParams.set("email", booking.attendees[0].email);
|
||||
router.push(`/booking/${booking.uid}?${urlSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const title = booking.title;
|
||||
@@ -283,6 +286,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -26,6 +26,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const asPath = `${pathname}?${searchParams.toString()}`;
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -97,7 +100,7 @@ export default function CancelBooking(props: Props) {
|
||||
});
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
await router.replace(router.asPath);
|
||||
router.replace(asPath);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import type { EventType } from "@prisma/client";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import DatePicker from "@calcom/features/calendars/DatePicker";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
const AvailableTimes = dynamic(() => import("@components/booking/AvailableTimes"));
|
||||
|
||||
const getRefetchInterval = (refetchCount: number): number => {
|
||||
const intervals = [3000, 3000, 5000, 10000, 20000, 30000] as const;
|
||||
return intervals[refetchCount] || intervals[intervals.length - 1];
|
||||
};
|
||||
|
||||
const useSlots = ({
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
startTime,
|
||||
endTime,
|
||||
usernameList,
|
||||
timeZone,
|
||||
duration,
|
||||
enabled = true,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
eventTypeSlug: string;
|
||||
startTime?: Dayjs;
|
||||
endTime?: Dayjs;
|
||||
usernameList: string[];
|
||||
timeZone?: string;
|
||||
duration?: string;
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
const [refetchCount, setRefetchCount] = useState(0);
|
||||
const refetchInterval = getRefetchInterval(refetchCount);
|
||||
const { data, isLoading, isPaused, fetchStatus } = trpc.viewer.public.slots.getSchedule.useQuery(
|
||||
{
|
||||
eventTypeId,
|
||||
eventTypeSlug,
|
||||
usernameList,
|
||||
startTime: startTime?.toISOString() || "",
|
||||
endTime: endTime?.toISOString() || "",
|
||||
timeZone,
|
||||
duration,
|
||||
},
|
||||
{
|
||||
enabled: !!startTime && !!endTime && enabled,
|
||||
refetchInterval,
|
||||
trpc: { context: { skipBatch: true } },
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!!data && fetchStatus === "idle") {
|
||||
setRefetchCount(refetchCount + 1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchStatus, data]);
|
||||
|
||||
// The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state.
|
||||
return { slots: data?.slots || {}, isLoading: isLoading || isPaused };
|
||||
};
|
||||
|
||||
export const SlotPicker = ({
|
||||
eventType,
|
||||
timeFormat,
|
||||
onTimeFormatChange,
|
||||
timeZone,
|
||||
recurringEventCount,
|
||||
users,
|
||||
seatsPerTimeSlot,
|
||||
bookingAttendees,
|
||||
weekStart = 0,
|
||||
}: {
|
||||
eventType: Pick<
|
||||
EventType & { metadata: z.infer<typeof EventTypeMetaDataSchema> },
|
||||
"id" | "schedulingType" | "slug" | "length" | "metadata"
|
||||
>;
|
||||
timeFormat: TimeFormat;
|
||||
onTimeFormatChange: (is24Hour: boolean) => void;
|
||||
timeZone?: string;
|
||||
seatsPerTimeSlot?: number;
|
||||
bookingAttendees?: number;
|
||||
recurringEventCount?: number;
|
||||
users: string[];
|
||||
weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
}) => {
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>();
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs>();
|
||||
let { duration = eventType.length.toString() } = useRouterQuery("duration");
|
||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||
const router = useRouter();
|
||||
|
||||
if (!eventType.metadata?.multipleDuration) {
|
||||
duration = eventType.length.toString();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
|
||||
// Etc/GMT is not actually a timeZone, so handle this select option explicitly to prevent a hard crash.
|
||||
if (timeZone === "Etc/GMT") {
|
||||
setBrowsingDate(dayjs.utc(month).set("date", 1).set("hour", 0).set("minute", 0).set("second", 0));
|
||||
if (date) {
|
||||
setSelectedDate(dayjs.utc(date));
|
||||
}
|
||||
} else {
|
||||
// Set the start of the month without shifting time like startOf() may do.
|
||||
setBrowsingDate(
|
||||
dayjs.tz(month, timeZone).set("date", 1).set("hour", 0).set("minute", 0).set("second", 0)
|
||||
);
|
||||
if (date) {
|
||||
// It's important to set the date immediately to the timeZone, dayjs(date) will convert to browsertime.
|
||||
setSelectedDate(dayjs.tz(date, timeZone));
|
||||
}
|
||||
}
|
||||
}, [router.isReady, month, date, duration, timeZone]);
|
||||
|
||||
const { i18n, isLocaleReady } = useLocale();
|
||||
const { slots: monthSlots, isLoading } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
usernameList: users,
|
||||
startTime: dayjs().startOf("day"),
|
||||
endTime: browsingDate?.endOf("month"),
|
||||
timeZone,
|
||||
duration,
|
||||
});
|
||||
const { slots: selectedDateSlots, isLoading: _isLoadingSelectedDateSlots } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
usernameList: users,
|
||||
startTime: selectedDate?.startOf("day"),
|
||||
endTime: selectedDate?.endOf("day"),
|
||||
timeZone,
|
||||
duration,
|
||||
/** Prevent refetching is we already have this data from month slots */
|
||||
enabled: !!selectedDate,
|
||||
});
|
||||
|
||||
/** Hide skeleton if we have the slot loaded in the month query */
|
||||
const isLoadingSelectedDateSlots = (() => {
|
||||
if (!selectedDate) return _isLoadingSelectedDateSlots;
|
||||
if (!!selectedDateSlots[selectedDate.format("YYYY-MM-DD")]) return false;
|
||||
if (!!monthSlots[selectedDate.format("YYYY-MM-DD")]) return false;
|
||||
return false;
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DatePicker
|
||||
isLoading={isLoading}
|
||||
className={classNames(
|
||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-4 lg:min-w-[455px]",
|
||||
selectedDate ? " border-subtle sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||
)}
|
||||
includedDates={Object.keys(monthSlots).filter((k) => monthSlots[k].length > 0)}
|
||||
locale={isLocaleReady ? i18n.language : "en"}
|
||||
selected={selectedDate}
|
||||
onChange={(newDate) => {
|
||||
setDate(newDate.format("YYYY-MM-DD"));
|
||||
}}
|
||||
onMonthChange={(newMonth) => {
|
||||
setMonth(newMonth.format("YYYY-MM"));
|
||||
}}
|
||||
browsingDate={browsingDate}
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
<AvailableTimes
|
||||
isLoading={isLoadingSelectedDateSlots}
|
||||
slots={
|
||||
selectedDate &&
|
||||
(selectedDateSlots[selectedDate.format("YYYY-MM-DD")] ||
|
||||
monthSlots[selectedDate.format("YYYY-MM-DD")])
|
||||
}
|
||||
date={selectedDate}
|
||||
timeFormat={timeFormat}
|
||||
onTimeFormatChange={onTimeFormatChange}
|
||||
eventTypeId={eventType.id}
|
||||
eventTypeSlug={eventType.slug}
|
||||
seatsPerTimeSlot={seatsPerTimeSlot}
|
||||
bookingAttendees={bookingAttendees}
|
||||
recurringCount={recurringEventCount}
|
||||
duration={parseInt(duration)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { ITimezoneOption } from "@calcom/ui";
|
||||
import { TimezoneSelect } from "@calcom/ui";
|
||||
|
||||
import { timeZone } from "../../lib/clock";
|
||||
|
||||
type Props = {
|
||||
onSelectTimeZone: (selectedTimeZone: string) => void;
|
||||
};
|
||||
|
||||
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTimeZone(timeZone());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) {
|
||||
onSelectTimeZone(timeZone(selectedTimeZone));
|
||||
}
|
||||
}, [selectedTimeZone, onSelectTimeZone]);
|
||||
|
||||
return !!selectedTimeZone ? (
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
classNames={{
|
||||
singleValue: () => "text-default",
|
||||
dropdownIndicator: () => "text-default",
|
||||
menu: () => "!w-64 max-w-[90vw] shadow-dropdown bg-default border-subtle border rounded-md mt-1",
|
||||
}}
|
||||
variant="minimal"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default TimeOptions;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Globe } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
|
||||
import TimeOptions from "@components/booking/TimeOptions";
|
||||
|
||||
export function TimezoneDropdown({
|
||||
onChangeTimeZone,
|
||||
}: {
|
||||
onChangeTimeZone: (newTimeZone: string) => void;
|
||||
timeZone?: string;
|
||||
}) {
|
||||
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||
onChangeTimeZone(newTimeZone);
|
||||
localStorageTimeZone(newTimeZone);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:hover:bg-darkgray-200 !mt-3 flex w-full max-w-[20rem] items-center rounded-[4px] px-1 text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white">
|
||||
<Globe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-2 rtl:ml-2" />
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import type { AvatarGroupProps } from "@calcom/ui";
|
||||
import { AvatarGroup } from "@calcom/ui";
|
||||
|
||||
export const UserAvatars = ({
|
||||
profile,
|
||||
users,
|
||||
...props
|
||||
}: {
|
||||
profile: { image: string | null; name?: string | null; username?: string | null };
|
||||
showMembers: boolean;
|
||||
users: { username: string | null; name?: string | null }[];
|
||||
} & Pick<AvatarGroupProps, "size" | "truncateAfter">) => {
|
||||
const showMembers = !users.find((user) => user.name === profile.name) && props.showMembers;
|
||||
return (
|
||||
<AvatarGroup
|
||||
items={
|
||||
[
|
||||
{
|
||||
image: profile.image,
|
||||
alt: profile.name,
|
||||
title: profile.name,
|
||||
href: profile.username ? `${CAL_URL}/${profile.username}` : undefined,
|
||||
},
|
||||
...(showMembers
|
||||
? users.map((user) => ({
|
||||
title: user.name,
|
||||
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||
alt: user.name || undefined,
|
||||
href: user.username ? `${CAL_URL}/${user.username}` : undefined,
|
||||
}))
|
||||
: []),
|
||||
].filter((item) => !!item.image) as {
|
||||
image: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
href?: string;
|
||||
}[]
|
||||
}
|
||||
size="sm"
|
||||
truncateAfter={props.truncateAfter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -104,6 +104,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
phone: z.string().optional().nullable(),
|
||||
locationAddress: z.string().optional(),
|
||||
credentialId: z.number().optional(),
|
||||
teamName: z.string().optional(),
|
||||
locationLink: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -298,8 +299,19 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
}
|
||||
|
||||
if (values.credentialId) {
|
||||
details = { ...details, credentialId: values.credentialId };
|
||||
details = {
|
||||
...details,
|
||||
credentialId: values.credentialId,
|
||||
};
|
||||
}
|
||||
|
||||
if (values.teamName) {
|
||||
details = {
|
||||
...details,
|
||||
teamName: values.teamName,
|
||||
};
|
||||
}
|
||||
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
@@ -344,6 +356,11 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
if (val.credential) {
|
||||
locationFormMethods.setValue("credentialId", val.credential.id);
|
||||
locationFormMethods.setValue("teamName", val.credential.team?.name);
|
||||
}
|
||||
|
||||
locationFormMethods.unregister([
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
CheckboxField,
|
||||
Label,
|
||||
@@ -32,9 +33,8 @@ import {
|
||||
showToast,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Alert,
|
||||
} from "@calcom/ui";
|
||||
import { Edit, Copy } from "@calcom/ui/components/icon";
|
||||
import { Copy, Edit } from "@calcom/ui/components/icon";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
@@ -204,6 +204,21 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="requiresBookerEmailVerification"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.requiresBookerEmailVerification}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("requires_booker_email_verification")}
|
||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||
description={t("description_requires_booker_email_verification")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
control={formMethods.control}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
import findDurationType from "@calcom/lib/findDurationType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
import type { PeriodType } from "@calcom/prisma/enums";
|
||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||
@@ -462,7 +463,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
type="number"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
defaultValue={eventType.offsetStart}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
hint={t("offset_start_description", {
|
||||
@@ -477,11 +477,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
|
||||
type IntervalLimitsKey = keyof IntervalLimit;
|
||||
|
||||
const intervalOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"] as const;
|
||||
|
||||
const INTERVAL_LIMIT_OPTIONS = intervalOrderKeys.map((key) => ({
|
||||
const INTERVAL_LIMIT_OPTIONS = ascendingLimitKeys.map((key) => ({
|
||||
value: key as keyof IntervalLimit,
|
||||
label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
|
||||
label: `Per ${intervalLimitKeyToUnit(key)}`,
|
||||
}));
|
||||
|
||||
type IntervalLimitItemProps = {
|
||||
@@ -590,8 +588,8 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||
Object.entries(currentIntervalLimits)
|
||||
.sort(([limitKeyA], [limitKeyB]) => {
|
||||
return (
|
||||
intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
ascendingLimitKeys.indexOf(limitKeyA as IntervalLimitsKey) -
|
||||
ascendingLimitKeys.indexOf(limitKeyB as IntervalLimitsKey)
|
||||
);
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
|
||||
@@ -306,7 +306,9 @@ export const EventSetupTab = (
|
||||
)}
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{eventLabel}</span>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
||||
location.teamName ? `(${location.teamName})` : ""
|
||||
}`}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
@@ -99,6 +101,7 @@ const CheckedHostField = ({
|
||||
isFixed,
|
||||
value,
|
||||
onChange,
|
||||
helperText,
|
||||
...rest
|
||||
}: {
|
||||
labelText: string;
|
||||
@@ -107,6 +110,7 @@ const CheckedHostField = ({
|
||||
value: { isFixed: boolean; userId: number }[];
|
||||
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
helperText?: React.ReactNode | string;
|
||||
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
||||
return (
|
||||
<div className="bg-muted flex flex-col space-y-5 p-4">
|
||||
@@ -136,11 +140,24 @@ const CheckedHostField = ({
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
{helperText && <p className="text-subtle text-sm">{helperText}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FixedHostHelper = (
|
||||
<Trans i18nKey="fixed_host_helper">
|
||||
Add anyone who needs to attend the event.
|
||||
<Link
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#fixed-hosts">
|
||||
Learn more
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
const RoundRobinHosts = ({
|
||||
teamMembers,
|
||||
value,
|
||||
@@ -167,6 +184,7 @@ const RoundRobinHosts = ({
|
||||
value={value}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
helperText={FixedHostHelper}
|
||||
/>
|
||||
<CheckedHostField
|
||||
options={teamMembers.sort(sortByLabel)}
|
||||
@@ -175,6 +193,7 @@ const RoundRobinHosts = ({
|
||||
isFixed={false}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("round_robin_hosts")}
|
||||
helperText={t("round_robin_helper")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Webhook as TbWebhook } from "lucide-react";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo, useState, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -51,7 +52,6 @@ import {
|
||||
Loader,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import type { AvailabilityOption } from "@components/eventtype/EventAvailabilityTab";
|
||||
|
||||
type Props = {
|
||||
@@ -148,7 +148,7 @@ function EventTypeSingleLayout({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.eventTypes.invalidate();
|
||||
showToast(t("event_type_deleted_successfully"), "success");
|
||||
await router.push("/event-types");
|
||||
router.push("/event-types");
|
||||
setDeleteDialogOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -310,7 +310,7 @@ function EventTypeSingleLayout({
|
||||
showToast("Link copied!", "success");
|
||||
}}
|
||||
/>
|
||||
<EmbedButton
|
||||
<EventTypeEmbedButton
|
||||
embedUrl={encodeURIComponent(embedLink)}
|
||||
StartIcon={Code}
|
||||
color="secondary"
|
||||
@@ -450,7 +450,7 @@ function EventTypeSingleLayout({
|
||||
</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<EmbedDialog />
|
||||
<EventTypeEmbedDialog />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Schedule } from "@calcom/features/schedules";
|
||||
@@ -21,12 +20,10 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
const { t } = useLocale();
|
||||
const { nextStep } = props;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: defaultScheduleId! },
|
||||
{
|
||||
enabled: router.isReady && !!defaultScheduleId,
|
||||
enabled: !!defaultScheduleId,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FormEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -94,7 +94,12 @@ const DisableTwoFactorAuthModal = ({
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" className="me-2 ms-2" data-testid="disable-2fa" disabled={isDisabling}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
data-testid="disable-2fa"
|
||||
loading={isDisabling}
|
||||
disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -194,6 +194,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
onClick={handleSetup}
|
||||
loading={isSubmitting}
|
||||
disabled={password.length === 0 || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
@@ -208,7 +209,12 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="me-2 ms-2" data-testid="enable-2fa" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="me-2 ms-2"
|
||||
data-testid="enable-2fa"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
type TeamType = NonNullable<TeamWithMembers>;
|
||||
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number] & { safeBio: string | null };
|
||||
|
||||
type TeamTypeWithSafeHtml = Omit<TeamType, "members" | "inviteToken"> & { members: MemberType[] };
|
||||
|
||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||
const routerQuery = useRouterQuery();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const isBioEmpty = !member.bio || !member.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
// We don't want to forward orgSlug and user which are route params to the next route
|
||||
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = router.query;
|
||||
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery;
|
||||
|
||||
return (
|
||||
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import classNames from "classnames";
|
||||
import { debounce, noop } from "lodash";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { RefCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
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, Input, Label } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, Input, Label } from "@calcom/ui";
|
||||
import { Check, Edit2, ExternalLink, Star as StarSolid } from "@calcom/ui/components/icon";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
@@ -46,6 +46,9 @@ const obtainNewUsernameChangeCondition = ({
|
||||
};
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { data: session, update } = useSession();
|
||||
const {
|
||||
@@ -61,8 +64,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const router = useRouter();
|
||||
const { paymentStatus: recentAttemptPaymentStatus } = router.query;
|
||||
const recentAttemptPaymentStatus = searchParams?.get("recentAttemptPaymentStatus");
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
|
||||
const isCurrentUsernamePremium =
|
||||
@@ -96,7 +98,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const updateUsername = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
await update();
|
||||
await update({ username: inputUsernameValue });
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -120,7 +122,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
|
||||
inputUsernameValue || usernameFromStripe
|
||||
}&action=${usernameChangeCondition}&callbackUrl=${WEBAPP_URL}${router.asPath}`;
|
||||
}&action=${usernameChangeCondition}&callbackUrl=${WEBAPP_URL}${pathname}`;
|
||||
|
||||
const ActionButtons = () => {
|
||||
if (paymentRequired) {
|
||||
@@ -223,7 +225,11 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
// Reset payment status
|
||||
delete router.query.paymentStatus;
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
_searchParams.delete("paymentStatus");
|
||||
if (searchParams.toString() !== _searchParams.toString()) {
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
}
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
@@ -284,7 +290,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
<DialogFooter className="mt-4">
|
||||
{/* redirect to checkout */}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE && (
|
||||
<Button
|
||||
@@ -312,7 +318,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button, Dialog, DialogClose, DialogContent, TextField } from "@calcom/ui";
|
||||
import { Button, Dialog, DialogClose, DialogContent, TextField, DialogFooter } from "@calcom/ui";
|
||||
import { Check, Edit2 } from "@calcom/ui/components/icon";
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
@@ -25,6 +25,7 @@ interface ICustomUsernameProps {
|
||||
const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.ComponentProps<typeof TextField>>) => {
|
||||
const { t } = useLocale();
|
||||
const { data: session, update } = useSession();
|
||||
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername = noop,
|
||||
@@ -71,7 +72,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setOpenDialogSaveUsername(false);
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
await update();
|
||||
await update({ username: inputUsernameValue });
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
@@ -175,7 +176,8 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 mt-4 flex flex-row-reverse gap-x-2">
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsernameMutation.isLoading}
|
||||
@@ -187,7 +189,7 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
<DialogClose color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
@@ -35,12 +35,12 @@ export const UsernameAvailabilityField = ({
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
}: UsernameAvailabilityFieldProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const [currentUsernameState, setCurrentUsernameState] = useState(user.username || "");
|
||||
const { username: usernameFromQuery, setQuery: setUsernameFromQuery } = useRouterQuery("username");
|
||||
const { username: currentUsername, setQuery: setCurrentUsername } =
|
||||
router.query["username"] && user.username === null
|
||||
searchParams?.get("username") && user.username === null
|
||||
? { username: usernameFromQuery, setQuery: setUsernameFromQuery }
|
||||
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
|
||||
const formMethods = useForm({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GroupBase, Props, SingleValue } from "react-select";
|
||||
import { components } from "react-select";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { Select } from "@calcom/ui";
|
||||
@@ -12,6 +13,7 @@ export type LocationOption = {
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
address?: string;
|
||||
credential?: CredentialDataWithTeamName;
|
||||
};
|
||||
|
||||
export type SingleValueLocationOption = SingleValue<LocationOption>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { EventCollectionProvider } from "next-collect/client";
|
||||
@@ -6,8 +7,7 @@ import type { SSRConfig } from "next-i18next";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
@@ -18,25 +18,32 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import usePublicPage from "@lib/hooks/usePublicPage";
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
|
||||
const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children: React.ReactNode }>(
|
||||
({ children }) => <>{children}</>
|
||||
);
|
||||
const I18nextAdapter = appWithTranslation<
|
||||
NextJsAppProps<SSRConfig> & {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
>(({ children }) => <>{children}</>);
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<
|
||||
NextAppProps<WithNonceProps & { themeBasis?: string } & Record<string, unknown>>,
|
||||
NextAppProps<
|
||||
WithNonceProps & {
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
} & Record<string, unknown>
|
||||
>,
|
||||
"Component"
|
||||
> & {
|
||||
Component: NextAppProps["Component"] & {
|
||||
requiresLicense?: boolean;
|
||||
isThemeSupported?: boolean;
|
||||
isBookingPage?: boolean | ((arg: { router: NextRouter }) => boolean);
|
||||
getLayout?: (page: React.ReactElement, router: NextRouter) => ReactNode;
|
||||
isBookingPage?: boolean | ((arg: { router: NextAppProps["router"] }) => boolean);
|
||||
getLayout?: (page: React.ReactElement, router: NextAppProps["router"]) => ReactNode;
|
||||
PageWrapper?: (props: AppProps) => JSX.Element;
|
||||
};
|
||||
|
||||
@@ -48,7 +55,7 @@ type AppPropsWithChildren = AppProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const getEmbedNamespace = (query: ReturnType<typeof useRouter>["query"]) => {
|
||||
const getEmbedNamespace = (query: ParsedUrlQuery) => {
|
||||
// Mostly embed query param should be available on server. Use that there.
|
||||
// Use the most reliable detection on client
|
||||
return typeof window !== "undefined" ? window.getEmbedNamespace() : (query.embed as string) || null;
|
||||
@@ -76,6 +83,7 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
||||
},
|
||||
router: locale ? { locale } : props.router,
|
||||
} as unknown as ComponentProps<typeof I18nextAdapter>;
|
||||
|
||||
return <I18nextAdapter {...passedProps} />;
|
||||
};
|
||||
|
||||
@@ -89,20 +97,19 @@ const enum ThemeSupport {
|
||||
}
|
||||
|
||||
type CalcomThemeProps = PropsWithChildren<
|
||||
Pick<AppProps["pageProps"], "nonce" | "themeBasis"> &
|
||||
Pick<AppProps, "router"> &
|
||||
Pick<AppProps["pageProps"], "nonce" | "themeBasis"> &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
|
||||
>;
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
|
||||
// One such example is our Embeds Demo and Testing page at http://localhost:3100
|
||||
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
|
||||
const embedNamespace = getEmbedNamespace(router.query);
|
||||
const embedNamespace = getEmbedNamespace(props.router.query);
|
||||
const isEmbedMode = typeof embedNamespace === "string";
|
||||
|
||||
return (
|
||||
<ThemeProvider {...getThemeProviderProps({ props, isEmbedMode, embedNamespace, router })}>
|
||||
<ThemeProvider {...getThemeProviderProps({ props, isEmbedMode, embedNamespace })}>
|
||||
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
|
||||
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
|
||||
{typeof window !== "undefined" && !isEmbedMode && (
|
||||
@@ -148,16 +155,14 @@ function getThemeProviderProps({
|
||||
props,
|
||||
isEmbedMode,
|
||||
embedNamespace,
|
||||
router,
|
||||
}: {
|
||||
props: Omit<CalcomThemeProps, "children">;
|
||||
isEmbedMode: boolean;
|
||||
embedNamespace: string | null;
|
||||
router: NextRouter;
|
||||
}) {
|
||||
const isBookingPage = (() => {
|
||||
if (typeof props.isBookingPage === "function") {
|
||||
return props.isBookingPage({ router: router });
|
||||
return props.isBookingPage({ router: props.router });
|
||||
}
|
||||
return props.isBookingPage;
|
||||
})();
|
||||
@@ -241,9 +246,8 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
const session = trpc.viewer.public.session.useQuery().data;
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isPublicPage = usePublicPage();
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const { pageProps, ...rest } = props;
|
||||
const { _nonce, ...restPageProps } = pageProps;
|
||||
const propsWithoutNonce = {
|
||||
@@ -255,7 +259,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<SessionProvider session={session || undefined}>
|
||||
<SessionProvider session={pageProps.session ?? undefined}>
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
@@ -263,7 +267,8 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
themeBasis={props.pageProps.themeBasis}
|
||||
nonce={props.pageProps.nonce}
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage}>
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
@@ -276,7 +281,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
</EventCollectionProvider>
|
||||
);
|
||||
|
||||
if (isPublicPage) {
|
||||
if (isBookingPage) {
|
||||
return RemainingProviders;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function useIsBookingPage() {
|
||||
const pathname = usePathname();
|
||||
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const userParam = searchParams.get("user");
|
||||
const teamParam = searchParams.get("team");
|
||||
|
||||
return !!(isBookingPage || userParam || teamParam);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function usePublicPage() {
|
||||
const router = useRouter();
|
||||
const isPublicPage = ["/[user]", "/booking", "/cancel", "/reschedule"].find((route) =>
|
||||
router.pathname.startsWith(route)
|
||||
);
|
||||
return isPublicPage;
|
||||
}
|
||||
@@ -1,36 +1,17 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function useRouterQuery<T extends string>(name: T) {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const existingQueryParams = router.asPath.split("?")[1];
|
||||
|
||||
const urlParams = new URLSearchParams(existingQueryParams);
|
||||
const query: Record<string, string | string[]> = {};
|
||||
// Following error is thrown by Typescript:
|
||||
// 'Type 'URLSearchParams' can only be iterated through when using the '--downlevelIteration' flag or with a '--target' of 'es2015' or higher'
|
||||
// We should change the target to higher ES2019 atleast maybe
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
for (const [key, value] of urlParams) {
|
||||
if (!query[key]) {
|
||||
query[key] = value;
|
||||
} else {
|
||||
let queryValue = query[key];
|
||||
if (queryValue instanceof Array) {
|
||||
queryValue.push(value);
|
||||
} else {
|
||||
queryValue = query[key] = [queryValue];
|
||||
queryValue.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setQuery = (newValue: string | number | null | undefined) => {
|
||||
router.replace({ query: { ...router.query, [name]: newValue } }, undefined, { shallow: true });
|
||||
router.replace({ query: { ...router.query, ...query, [name]: newValue } }, undefined, { shallow: true });
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
_searchParams.set(name, newValue as string);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
};
|
||||
|
||||
return { [name]: query[name], setQuery } as {
|
||||
return { [name]: searchParams.get(name), setQuery } as {
|
||||
[K in T]: string | undefined;
|
||||
} & { setQuery: typeof setQuery };
|
||||
}
|
||||
|
||||
@@ -1,31 +1,9 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export function useToggleQuery(name: string) {
|
||||
const router = useRouter();
|
||||
|
||||
const hrefOff = useMemo(() => {
|
||||
const query = {
|
||||
...router.query,
|
||||
};
|
||||
delete query[name];
|
||||
return {
|
||||
query,
|
||||
};
|
||||
}, [router.query, name]);
|
||||
const hrefOn = useMemo(() => {
|
||||
const query = {
|
||||
...router.query,
|
||||
[name]: "1",
|
||||
};
|
||||
return {
|
||||
query,
|
||||
};
|
||||
}, [router.query, name]);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return {
|
||||
hrefOn,
|
||||
hrefOff,
|
||||
isOn: router.query[name] === "1",
|
||||
isOn: searchParams?.get(name) === "1",
|
||||
};
|
||||
}
|
||||
|
||||
+20
-32
@@ -1,22 +1,14 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import { collectEvents } from "next-collect/server";
|
||||
import type { NextMiddleware } from "next/server";
|
||||
import { NextResponse, userAgent } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CONSOLE_URL, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { isIpInBanlist } from "@calcom/lib/getIP";
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
const middleware: NextMiddleware = async (req) => {
|
||||
const url = req.nextUrl;
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
|
||||
if (isIpInBanlist(req) && url.pathname !== "/api/nope") {
|
||||
// DDOS Prevention: Immediately end request with no response - Avoids a redirect as well initiated by NextAuth on invalid callback
|
||||
req.nextUrl.pathname = "/api/nope";
|
||||
return NextResponse.redirect(req.nextUrl);
|
||||
}
|
||||
|
||||
if (!url.pathname.startsWith("/api")) {
|
||||
//
|
||||
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
||||
@@ -39,25 +31,9 @@ const middleware: NextMiddleware = async (req) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (["/api/collect-events", "/api/auth"].some((p) => url.pathname.startsWith(p))) {
|
||||
const callbackUrl = url.searchParams.get("callbackUrl");
|
||||
const { isBot } = userAgent(req);
|
||||
|
||||
if (
|
||||
isBot ||
|
||||
(callbackUrl && ![CONSOLE_URL, WEBAPP_URL, WEBSITE_URL].some((u) => callbackUrl.startsWith(u))) ||
|
||||
isIpInBanlist(req)
|
||||
) {
|
||||
// DDOS Prevention: Immediately end request with no response - Avoids a redirect as well initiated by NextAuth on invalid callback
|
||||
req.nextUrl.pathname = "/api/nope";
|
||||
return NextResponse.redirect(req.nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't 404 old routing_forms links
|
||||
if (url.pathname.startsWith("/apps/routing_forms")) {
|
||||
url.pathname = url.pathname.replace("/apps/routing_forms", "/apps/routing-forms");
|
||||
return NextResponse.rewrite(url);
|
||||
const res = routingForms.handle(url);
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/trpc/")) {
|
||||
@@ -76,15 +52,27 @@ const middleware: NextMiddleware = async (req) => {
|
||||
});
|
||||
};
|
||||
|
||||
const routingForms = {
|
||||
handle: (url: URL) => {
|
||||
// Don't 404 old routing_forms links
|
||||
if (url.pathname.startsWith("/apps/routing_forms")) {
|
||||
url.pathname = url.pathname.replace(/^\/apps\/routing_forms($|\/)/, "/apps/routing-forms/");
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const config = {
|
||||
// Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here.
|
||||
// https://github.com/vercel/next.js/discussions/42458
|
||||
matcher: [
|
||||
"/((?!_next|.*avatar.png$|favicon.ico$).*)",
|
||||
"/api/collect-events/:path*",
|
||||
"/api/auth/:path*",
|
||||
"/apps/routing_forms/:path*",
|
||||
"/:path*/embed",
|
||||
"/api/trpc/:path*",
|
||||
"/auth/login",
|
||||
/**
|
||||
* Paths required by routingForms.handle
|
||||
*/
|
||||
"/apps/routing_forms/:path*",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
+13
-3
@@ -25,8 +25,10 @@ if (!process.env.NEXTAUTH_URL && process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
||||
if (!process.env.NEXT_PUBLIC_WEBSITE_URL) {
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
}
|
||||
|
||||
if (process.env.CSP_POLICY === "strict" && process.env.NODE_ENV === "production") {
|
||||
if (
|
||||
process.env.CSP_POLICY === "strict" &&
|
||||
(process.env.CALCOM_ENV === "production" || process.env.NODE_ENV === "production")
|
||||
) {
|
||||
throw new Error(
|
||||
"Strict CSP policy(for style-src) is not yet supported in production. You can experiment with it in Dev Mode"
|
||||
);
|
||||
@@ -127,7 +129,10 @@ const matcherConfigUserTypeEmbedRoute = {
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
i18n,
|
||||
i18n: {
|
||||
...i18n,
|
||||
localeDetection: false,
|
||||
},
|
||||
productionBrowserSourceMaps: true,
|
||||
/* We already do type check on GH actions */
|
||||
typescript: {
|
||||
@@ -376,6 +381,11 @@ const nextConfig = {
|
||||
destination: "/signup",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/auth",
|
||||
destination: "/auth/login",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/settings",
|
||||
destination: "/settings/my-account/profile",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -58,7 +58,6 @@
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanem/react-nprogress": "^5.0.44",
|
||||
"@tanstack/react-query": "^4.3.9",
|
||||
"@tremor/react": "^2.0.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
|
||||
+9
-10
@@ -1,10 +1,10 @@
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, JOIN_DISCORD, WEBSITE_URL, IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { BookOpen, Check, ChevronRight, FileText, Shield } from "@calcom/ui/components/icon";
|
||||
@@ -22,9 +22,8 @@ enum pageType {
|
||||
}
|
||||
|
||||
export default function Custom404() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useLocale();
|
||||
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState<string>("");
|
||||
const [currentPageType, setCurrentPageType] = useState<pageType>(pageType.USER);
|
||||
|
||||
@@ -52,7 +51,7 @@ export default function Custom404() {
|
||||
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
|
||||
useEffect(() => {
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
|
||||
const [routerUsername] = router.asPath.replace("%20", "-").split(/[?#]/);
|
||||
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/);
|
||||
if (!isValidOrgDomain || !currentOrgDomain) {
|
||||
const splitPath = routerUsername.split("/");
|
||||
if (splitPath[1] === "team" && splitPath.length === 3) {
|
||||
@@ -78,14 +77,14 @@ export default function Custom404() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isSuccessPage = router.asPath.startsWith("/booking");
|
||||
const isSubpage = router.asPath.includes("/", 2) || isSuccessPage;
|
||||
const isSignup = router.asPath.startsWith("/signup");
|
||||
const isSuccessPage = pathname?.startsWith("/booking");
|
||||
const isSubpage = pathname?.includes("/", 2) || isSuccessPage;
|
||||
const isSignup = pathname?.startsWith("/signup");
|
||||
/**
|
||||
* If we're on 404 and the route is insights it means it is disabled
|
||||
* TODO: Abstract this for all disabled features
|
||||
**/
|
||||
const isInsights = router.asPath.startsWith("/insights");
|
||||
const isInsights = pathname?.startsWith("/insights");
|
||||
if (isInsights) {
|
||||
return (
|
||||
<>
|
||||
|
||||
+10
-13
@@ -1,7 +1,7 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
import { Copy } from "@calcom/ui/components/icon";
|
||||
@@ -9,8 +9,8 @@ import { Copy } from "@calcom/ui/components/icon";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function Error500() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="bg-subtle flex h-screen">
|
||||
@@ -21,24 +21,21 @@ export default function Error500() {
|
||||
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
|
||||
<h1 className="font-cal text-emphasis text-6xl">500</h1>
|
||||
<h2 className="text-emphasis mt-6 text-2xl font-medium">It's not you, it's us.</h2>
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
|
||||
Something went wrong on our end. Get in touch with our support team, and we’ll get it fixed right
|
||||
away for you.
|
||||
</p>
|
||||
{router.query.error && (
|
||||
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("something_went_wrong_on_our_end")}</p>
|
||||
{searchParams?.get("error") && (
|
||||
<div className="mb-8 flex flex-col">
|
||||
<p className="text-default mb-4 max-w-2xl text-sm">
|
||||
Please provide the following text when contacting support to better help you:
|
||||
{t("please_provide_following_text_to_suppport")}:
|
||||
</p>
|
||||
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
|
||||
{router.query.error}
|
||||
{searchParams?.get("error")}
|
||||
<br />
|
||||
<Button
|
||||
color="secondary"
|
||||
className="mt-2 border-0 font-sans font-normal hover:bg-gray-300"
|
||||
StartIcon={Copy}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(router.query.error as string);
|
||||
navigator.clipboard.writeText(searchParams?.get("error") as string);
|
||||
showToast("Link copied!", "success");
|
||||
}}>
|
||||
{t("copy")}
|
||||
@@ -46,9 +43,9 @@ export default function Error500() {
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
|
||||
<Button href="mailto:support@cal.com">{t("contact_support")}</Button>
|
||||
<Button color="secondary" href="javascript:history.back()" className="ml-2">
|
||||
Go back
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+106
-50
@@ -1,8 +1,9 @@
|
||||
import type { DehydratedState } from "@tanstack/react-query";
|
||||
import classNames from "classnames";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import type { z } from "zod";
|
||||
|
||||
import {
|
||||
sdkActionManager,
|
||||
@@ -10,34 +11,34 @@ import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { EventType, User } from "@calcom/prisma/client";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar, HeadSeo } from "@calcom/ui";
|
||||
import { Avatar, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type UserPageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
export function UserPage(props: UserPageProps) {
|
||||
const { users, profile, eventTypes, isSingleUser, markdownStrippedBio } = props;
|
||||
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
|
||||
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||
useTheme(user.theme);
|
||||
useTheme(profile.theme);
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const isBioEmpty = !user.bio || !user.bio.replace("<p><br></p>", "").length;
|
||||
|
||||
@@ -45,9 +46,12 @@ export function UserPage(props: UserPageProps) {
|
||||
const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem");
|
||||
const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left";
|
||||
const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed;
|
||||
const query = { ...router.query };
|
||||
delete query.user; // So it doesn't display in the Link (and make tests fail)
|
||||
delete query.orgSlug;
|
||||
const {
|
||||
// So it doesn't display in the Link (and make tests fail)
|
||||
user: _user,
|
||||
orgSlug: _orgSlug,
|
||||
...query
|
||||
} = useRouterQuery();
|
||||
const nameOrUsername = user.name || user.username || "";
|
||||
|
||||
/*
|
||||
@@ -58,11 +62,20 @@ export function UserPage(props: UserPageProps) {
|
||||
telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/[user]"));
|
||||
}
|
||||
}, [telemetry, router.asPath]); */
|
||||
|
||||
if (entity?.isUnpublished) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<UnpublishedEntity {...entity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEventListEmpty = eventTypes.length === 0;
|
||||
return (
|
||||
<>
|
||||
<HeadSeo
|
||||
title={nameOrUsername}
|
||||
title={profile.name}
|
||||
description={markdownStrippedBio}
|
||||
meeting={{
|
||||
title: markdownStrippedBio,
|
||||
@@ -78,25 +91,23 @@ export function UserPage(props: UserPageProps) {
|
||||
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
|
||||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
{isSingleUser && ( // When we deal with a single user, not dynamic group
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar imageSrc={user.avatar} size="xl" alt={nameOrUsername} />
|
||||
<h1 className="font-cal text-emphasis mb-1 text-3xl">
|
||||
{nameOrUsername}
|
||||
{user.verified && (
|
||||
<Verified className=" mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black" />
|
||||
)}
|
||||
</h1>
|
||||
{!isBioEmpty && (
|
||||
<>
|
||||
<div
|
||||
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
dangerouslySetInnerHTML={{ __html: props.safeBio }}
|
||||
/>
|
||||
</>
|
||||
<div className="mb-8 text-center">
|
||||
<Avatar imageSrc={profile.image} size="xl" alt={profile.name} />
|
||||
<h1 className="font-cal text-emphasis mb-1 text-3xl">
|
||||
{profile.name}
|
||||
{user.verified && (
|
||||
<Verified className=" mx-1 -mt-1 inline h-6 w-6 fill-blue-500 text-white dark:text-black" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</h1>
|
||||
{!isBioEmpty && (
|
||||
<>
|
||||
<div
|
||||
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
dangerouslySetInnerHTML={{ __html: props.safeBio }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames("rounded-md ", !isEventListEmpty && "border-subtle border")}
|
||||
@@ -141,7 +152,7 @@ export function UserPage(props: UserPageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEventListEmpty && <EmptyPage name={user.name ?? "User"} />}
|
||||
{isEventListEmpty && <EmptyPage name={profile.name || "User"} />}
|
||||
</main>
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
@@ -195,11 +206,48 @@ const getEventTypesWithHiddenFromDB = async (userId: number) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const crypto = await import("crypto");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
export type UserPageProps = {
|
||||
trpcState: DehydratedState;
|
||||
profile: {
|
||||
name: string;
|
||||
image: string;
|
||||
theme: string | null;
|
||||
brandColor: string;
|
||||
darkBrandColor: string;
|
||||
};
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
||||
themeBasis: string | null;
|
||||
markdownStrippedBio: string;
|
||||
safeBio: string;
|
||||
entity: {
|
||||
isUnpublished?: boolean;
|
||||
orgSlug?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
eventTypes: ({
|
||||
descriptionAsSafeHTML: string;
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
} & Pick<
|
||||
EventType,
|
||||
| "id"
|
||||
| "title"
|
||||
| "slug"
|
||||
| "length"
|
||||
| "hidden"
|
||||
| "requiresConfirmation"
|
||||
| "requiresBookerEmailVerification"
|
||||
| "price"
|
||||
| "currency"
|
||||
| "recurringEvent"
|
||||
>)[];
|
||||
} & EmbedProps;
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const dataFetchStart = Date.now();
|
||||
const usersWithoutAvatar = await prisma.user.findMany({
|
||||
@@ -207,11 +255,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
username: {
|
||||
in: usernameList,
|
||||
},
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -222,6 +266,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
away: true,
|
||||
verified: true,
|
||||
@@ -260,7 +310,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const [user] = users; //to be used when dealing with single user, not dynamic group
|
||||
|
||||
const profile = {
|
||||
name: user.name || user.username,
|
||||
name: user.name || user.username || "",
|
||||
image: user.avatar,
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
@@ -280,25 +330,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
|
||||
}));
|
||||
|
||||
const isSingleUser = users.length === 1;
|
||||
|
||||
const safeBio = markdownToSafeHTML(user.bio) || "";
|
||||
|
||||
const markdownStrippedBio = stripMarkdown(user?.bio || "");
|
||||
const org = usersWithoutAvatar[0].organization;
|
||||
|
||||
return {
|
||||
props: {
|
||||
users,
|
||||
users: users.map((user) => ({
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
away: user.away,
|
||||
verified: user.verified,
|
||||
})),
|
||||
entity: {
|
||||
isUnpublished: org?.slug === null,
|
||||
orgSlug: currentOrgDomain,
|
||||
name: org?.name ?? null,
|
||||
},
|
||||
eventTypes,
|
||||
safeBio,
|
||||
profile,
|
||||
// Dynamic group has no theme preference right now. It uses system theme.
|
||||
themeBasis: user.username,
|
||||
user: {
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
},
|
||||
eventTypes,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isSingleUser,
|
||||
markdownStrippedBio,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
@@ -26,7 +27,7 @@ export default function Type({
|
||||
away,
|
||||
isBrandingHidden,
|
||||
rescheduleUid,
|
||||
org,
|
||||
entity,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
@@ -35,7 +36,7 @@ export default function Type({
|
||||
eventSlug={slug}
|
||||
rescheduleUid={rescheduleUid ?? undefined}
|
||||
hideBranding={isBrandingHidden}
|
||||
org={org}
|
||||
entity={entity}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
@@ -43,7 +44,7 @@ export default function Type({
|
||||
bookingData={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
org={org}
|
||||
entity={entity}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -58,7 +59,10 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
@@ -106,7 +110,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
return {
|
||||
props: {
|
||||
org,
|
||||
entity: eventData.entity,
|
||||
booking,
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
@@ -124,18 +128,17 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
},
|
||||
select: {
|
||||
away: true,
|
||||
@@ -158,7 +161,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
// as well as to check if the event exist, so we can show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username,
|
||||
eventSlug: slug,
|
||||
@@ -177,7 +180,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
away: user?.away,
|
||||
user: username,
|
||||
slug,
|
||||
org,
|
||||
entity: eventData.entity,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
themeBasis: username,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { DocumentContext, DocumentProps } from "next/document";
|
||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps;
|
||||
@@ -49,7 +51,7 @@ class MyDocument extends Document<Props> {
|
||||
<meta name="msapplication-TileColor" content="#ff0000" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9fafb" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1C1C" />
|
||||
{(process.env.NODE_ENV === "development" || process.env.VERCEL_ENV === "preview") && (
|
||||
{(!IS_PRODUCTION || process.env.VERCEL_ENV === "preview") && (
|
||||
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||
<script
|
||||
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
|
||||
|
||||
@@ -10,6 +10,7 @@ import React from "react";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
|
||||
import { ErrorPage } from "@components/error/error-page";
|
||||
|
||||
@@ -59,10 +60,17 @@ CustomError.getInitialProps = async (ctx: AugmentedNextPageContext) => {
|
||||
|
||||
// If a HttpError message, let's override defaults
|
||||
if (err instanceof HttpError) {
|
||||
const redactedError = redactError(err);
|
||||
errorInitialProps.statusCode = err.statusCode;
|
||||
errorInitialProps.title = err.name;
|
||||
errorInitialProps.message = err.message;
|
||||
errorInitialProps.err = err;
|
||||
errorInitialProps.title = redactedError.name;
|
||||
errorInitialProps.message = redactedError.message;
|
||||
errorInitialProps.err = {
|
||||
...redactedError,
|
||||
url: err.url,
|
||||
statusCode: err.statusCode,
|
||||
cause: err.cause,
|
||||
method: err.method,
|
||||
};
|
||||
}
|
||||
|
||||
if (res) {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { ResetPasswordRequest } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendPasswordResetEmail } from "@calcom/emails";
|
||||
import { PASSWORD_RESET_EXPIRY_HOURS } from "@calcom/emails/templates/forgot-password-email";
|
||||
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -37,63 +33,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
});
|
||||
|
||||
try {
|
||||
const maybeUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.data,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
identityProvider: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
},
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.data },
|
||||
select: { name: true, email: true, locale: true },
|
||||
});
|
||||
|
||||
if (!maybeUser) {
|
||||
// Don't leak information about whether an email is registered or not
|
||||
return res
|
||||
.status(200)
|
||||
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
||||
}
|
||||
|
||||
const t = await getTranslation(maybeUser.locale ?? "en", "common");
|
||||
|
||||
const maybePreviousRequest = await prisma.resetPasswordRequest.findMany({
|
||||
where: {
|
||||
email: maybeUser.email,
|
||||
expires: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let passwordRequest: ResetPasswordRequest;
|
||||
|
||||
if (maybePreviousRequest && maybePreviousRequest?.length >= 1) {
|
||||
passwordRequest = maybePreviousRequest[0];
|
||||
} else {
|
||||
const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate();
|
||||
const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({
|
||||
data: {
|
||||
email: maybeUser.email,
|
||||
expires: expiry,
|
||||
},
|
||||
});
|
||||
passwordRequest = createdResetPasswordRequest;
|
||||
}
|
||||
|
||||
const resetLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/forgot-password/${passwordRequest.id}`;
|
||||
await sendPasswordResetEmail({
|
||||
language: t,
|
||||
user: maybeUser,
|
||||
resetLink,
|
||||
});
|
||||
|
||||
return res
|
||||
.status(201)
|
||||
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
||||
// Don't leak info about whether the user exists
|
||||
if (!user) return res.status(201).json({ message: "password_reset_email_sent" });
|
||||
await passwordResetRequest(user);
|
||||
return res.status(201).json({ message: "password_reset_email_sent" });
|
||||
} catch (reason) {
|
||||
// console.error(reason);
|
||||
console.error(reason);
|
||||
return res.status(500).json({ message: "Unable to create password reset request" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
// Only send reminders if the event hasn't finished
|
||||
endTime: { gte: new Date() },
|
||||
OR: [
|
||||
// no payment required
|
||||
{
|
||||
payment: { none: {} },
|
||||
},
|
||||
// paid but awaiting approval
|
||||
{
|
||||
payment: { some: {} },
|
||||
paid: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { renderEmail } from "@calcom/emails";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (process.env.NODE_ENV !== "development") return res.write("Only for development purposes"), res.end();
|
||||
if (IS_PRODUCTION) return res.write("Only for development purposes"), res.end();
|
||||
const t = await getTranslation("en", "common");
|
||||
const language = { translate: t, locale: "en" };
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -12,22 +12,33 @@ const querySchema = z
|
||||
.object({
|
||||
username: z.string(),
|
||||
teamname: z.string(),
|
||||
/**
|
||||
* Allow fetching avatar of a particular organization
|
||||
* Avatars being public, we need not worry about others accessing it.
|
||||
*/
|
||||
orgId: z.string().transform((s) => Number(s)),
|
||||
})
|
||||
.partial();
|
||||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname } = querySchema.parse(req.query);
|
||||
const { username, teamname, orgId } = querySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
const orgQuery = orgId
|
||||
? {
|
||||
id: orgId,
|
||||
}
|
||||
: org
|
||||
? getSlugOrRequestedSlug(org)
|
||||
: null;
|
||||
|
||||
if (username) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
organization: orgQuery,
|
||||
},
|
||||
select: { avatar: true, email: true },
|
||||
});
|
||||
@@ -42,11 +53,7 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: teamname,
|
||||
parent: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
parent: orgQuery,
|
||||
},
|
||||
select: { logo: true },
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config";
|
||||
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
|
||||
|
||||
@@ -58,8 +59,8 @@ function getRoute(appName: string, pages: string[]) {
|
||||
|
||||
const AppPage: AppPageType["default"] = function AppPage(props) {
|
||||
const appName = props.appName;
|
||||
const router = useRouter();
|
||||
const pages = router.query.pages as string[];
|
||||
const params = useParamsWithFallback();
|
||||
const pages = (params.pages || []) as string[];
|
||||
const route = getRoute(appName, pages);
|
||||
|
||||
const componentProps = {
|
||||
@@ -127,6 +128,12 @@ export async function getServerSideProps(
|
||||
params.appPages = pages.slice(1);
|
||||
const session = await getServerSession({ req, res });
|
||||
const user = session?.user;
|
||||
const app = await getAppWithMetadata({ slug: appName });
|
||||
if (!app) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await route.getServerSideProps(
|
||||
context as GetServerSidePropsContext<{
|
||||
@@ -153,7 +160,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
props: {
|
||||
appName,
|
||||
appUrl: `/apps/${appName}`,
|
||||
appUrl: app.simplePath || `/apps/${appName}`,
|
||||
...result.props,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
||||
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -38,7 +39,7 @@ const sourceSchema = z.object({
|
||||
function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
||||
// If it's not production environment, it would be a better idea to inform that the App is disabled.
|
||||
if (props.isAppDisabled) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (!IS_PRODUCTION) {
|
||||
// TODO: Improve disabled App UI. This is just a placeholder.
|
||||
return (
|
||||
<div className="p-2">
|
||||
@@ -77,6 +78,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
||||
descriptionItems={source.data?.items as string[] | undefined}
|
||||
isTemplate={data.isTemplate}
|
||||
dependencies={data.dependencies}
|
||||
concurrentMeetings={data.concurrentMeetings}
|
||||
// tos="https://zoom.us/terms"
|
||||
// privacy="https://zoom.us/privacy"
|
||||
body={
|
||||
@@ -122,7 +124,7 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
const isAppAvailableInFileSystem = appMeta;
|
||||
const isAppDisabled = isAppAvailableInFileSystem && (!appFromDb || !appFromDb.enabled);
|
||||
|
||||
if (process.env.NODE_ENV !== "production" && isAppDisabled) {
|
||||
if (!IS_PRODUCTION && isAppDisabled) {
|
||||
return {
|
||||
props: {
|
||||
isAppDisabled: true as const,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetStaticPaths, InferGetStaticPropsType } from "next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
||||
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
|
||||
@@ -9,8 +9,9 @@ import { HeadSeo } from "@calcom/ui";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const slug = router.query.slug as string;
|
||||
const slug = searchParams?.get("slug") as string;
|
||||
const { status } = useSession();
|
||||
|
||||
if (status === "loading") {
|
||||
@@ -18,12 +19,10 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
router.replace({
|
||||
pathname: "/auth/login",
|
||||
query: {
|
||||
callbackUrl: `/apps/${slug}/setup`,
|
||||
},
|
||||
const urlSearchParams = new URLSearchParams({
|
||||
callbackUrl: `/apps/${slug}/setup`,
|
||||
});
|
||||
router.replace(`/auth/login?${urlSearchParams.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { GetStaticPropsContext, InferGetStaticPropsType } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
@@ -13,9 +13,9 @@ import { AppCard, SkeletonText } from "@calcom/ui";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
const searchParams = useSearchParams();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const router = useRouter();
|
||||
const { category } = router.query;
|
||||
const category = searchParams?.get("category");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChangeEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
@@ -94,6 +95,7 @@ export default function Apps({
|
||||
}
|
||||
|
||||
Apps.PageWrapper = PageWrapper;
|
||||
Apps.getLayout = getLayout;
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, res } = context;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useReducer, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
@@ -169,7 +169,7 @@ const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListP
|
||||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && variant === "conferencing" && (
|
||||
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
@@ -350,10 +350,9 @@ type ModalState = {
|
||||
};
|
||||
|
||||
export default function InstalledApps() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const category = router.query.category as querySchemaType["category"];
|
||||
|
||||
const category = searchParams?.get("category") as querySchemaType["category"];
|
||||
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
|
||||
// Exclude calendar and other from categoryList, we handle those slightly differently below
|
||||
return !(category in { other: null, calendar: null });
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ReactElement } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import z from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, SkeletonText } from "@calcom/ui";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { X } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -19,13 +18,10 @@ const querySchema = z.object({
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { error } = querySchema.parse(router.query);
|
||||
const searchParams = useSearchParams();
|
||||
const { error } = querySchema.parse(searchParams);
|
||||
const isTokenVerificationError = error?.toLowerCase() === "verification";
|
||||
let errorMsg: string | ReactElement = <SkeletonText />;
|
||||
if (router.isReady) {
|
||||
errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");
|
||||
}
|
||||
const errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");
|
||||
|
||||
return (
|
||||
<AuthContainer title="" description="">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getCsrfToken } from "next-auth/react";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import Link from "next/link";
|
||||
import type { CSSProperties } from "react";
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -24,6 +23,8 @@ export default function Page({ requestId, isRequestExpired, csrfToken }: Props)
|
||||
const formMethods = useForm<{ new_password: string }>();
|
||||
const success = formMethods.formState.isSubmitSuccessful;
|
||||
const loading = formMethods.formState.isSubmitting;
|
||||
const passwordValue = formMethods.watch("new_password");
|
||||
const isEmpty = passwordValue?.length === 0;
|
||||
|
||||
const submitChangePassword = async ({ password, requestId }: { password: string; requestId: string }) => {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
@@ -123,7 +124,7 @@ export default function Page({ requestId, isRequestExpired, csrfToken }: Props)
|
||||
loading={loading}
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || isEmpty}
|
||||
className="w-full justify-center">
|
||||
{t("reset_password")}
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { CSSProperties, SyntheticEvent } from "react";
|
||||
import React from "react";
|
||||
|
||||
@@ -20,7 +19,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
const [error, setError] = React.useState<{ message: string } | null>(null);
|
||||
const [success, setSuccess] = React.useState(false);
|
||||
const [email, setEmail] = React.useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleChange = (e: SyntheticEvent) => {
|
||||
const target = e.target as typeof e.target & { value: string };
|
||||
@@ -40,8 +38,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) {
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(json);
|
||||
} else if ("resetLink" in json) {
|
||||
router.push(json.resetLink);
|
||||
} else {
|
||||
setSuccess(true);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { jwtVerify } from "jose";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken, signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
@@ -49,6 +49,7 @@ export default function Login({
|
||||
samlProductID,
|
||||
totpEmail,
|
||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const formSchema = z
|
||||
@@ -77,7 +78,7 @@ export default function Login({
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "";
|
||||
let callbackUrl = searchParams.get("callbackUrl") || "";
|
||||
|
||||
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
|
||||
|
||||
@@ -169,7 +170,7 @@ export default function Login({
|
||||
<EmailField
|
||||
id="email"
|
||||
label={t("email_address")}
|
||||
defaultValue={totpEmail || (router.query.email as string)}
|
||||
defaultValue={totpEmail || (searchParams?.get("email") as string)}
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
{...register("email")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
@@ -29,6 +29,12 @@ export function Logout(props: Props) {
|
||||
}, [props.query?.survey]);
|
||||
const { t } = useLocale();
|
||||
|
||||
const message = () => {
|
||||
if (props.query?.passReset === "true") return "reset_your_password";
|
||||
if (props.query?.emailChange === "true") return "email_change";
|
||||
return "hope_to_see_you_soon";
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
|
||||
<div className="mb-4">
|
||||
@@ -40,7 +46,7 @@ export function Logout(props: Props) {
|
||||
{t("youve_been_logged_out")}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-subtle text-sm">{t("hope_to_see_you_soon")}</p>
|
||||
<p className="text-subtle text-sm">{t(message())}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
// To handle the IdP initiated login flow callback
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { code } = router.query;
|
||||
const code = searchParams?.get("code");
|
||||
|
||||
signIn("saml-idp", {
|
||||
callbackUrl: "/",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
||||
@@ -19,15 +19,25 @@ import EnterpriseLicense from "@components/setup/EnterpriseLicense";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
function useSetStep() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const setStep = (newStep = 1) => {
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
_searchParams.set("step", newStep.toString());
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
};
|
||||
return setStep;
|
||||
}
|
||||
|
||||
export function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState(props.isFreeLicense ? "FREE" : "EE");
|
||||
const isFreeLicense = value === "FREE";
|
||||
const [isEnabledEE, setIsEnabledEE] = useState(!props.isFreeLicense);
|
||||
const setStep = (newStep: number) => {
|
||||
router.replace(`/auth/setup?step=${newStep || 1}`, undefined, { shallow: true });
|
||||
};
|
||||
const setStep = useSetStep();
|
||||
|
||||
const steps: React.ComponentProps<typeof WizardForm>["steps"] = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
@@ -27,11 +27,12 @@ import { ssrInit } from "@server/lib/ssr";
|
||||
export type SSOProviderPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Provider(props: SSOProviderPageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.provider === "saml") {
|
||||
const email = typeof router.query?.email === "string" ? router.query?.email : null;
|
||||
const email = searchParams?.get("email");
|
||||
|
||||
if (!email) {
|
||||
router.push("/auth/error?error=" + "Email not provided");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MailOpenIcon } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, showToast } from "@calcom/ui";
|
||||
import { Check, MailOpen, AlertTriangle } from "@calcom/ui/components/icon";
|
||||
import { AlertTriangle, Check, MailOpen } from "@calcom/ui/components/icon";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -54,8 +54,11 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
export default function Verify() {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t, sessionId, stripeCustomerId } = querySchema.parse(router.query);
|
||||
const routerQuery = useRouterQuery();
|
||||
const { t, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
||||
const [secondsLeft, setSecondsLeft] = useState(30);
|
||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery({
|
||||
stripeCustomerId,
|
||||
@@ -88,7 +91,7 @@ export default function Verify() {
|
||||
}
|
||||
}, [secondsLeft]);
|
||||
|
||||
if (!router.isReady || !data) {
|
||||
if (!data) {
|
||||
// Loading state
|
||||
return <Loader />;
|
||||
}
|
||||
@@ -106,7 +109,7 @@ export default function Verify() {
|
||||
<Head>
|
||||
<title>
|
||||
{/* @note: Ternary can look ugly ant his might be extracted later but I think at 3 it's not yet worth
|
||||
it or too hard to read. */}
|
||||
it or too hard to read. */}
|
||||
{hasPaymentFailed
|
||||
? "Your payment failed"
|
||||
: sessionId
|
||||
@@ -155,16 +158,9 @@ export default function Verify() {
|
||||
e.preventDefault();
|
||||
setSecondsLeft(30);
|
||||
// Update query params with t:timestamp, shallow: true doesn't re-render the page
|
||||
router.push(
|
||||
router.asPath,
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
t: Date.now(),
|
||||
},
|
||||
},
|
||||
{ shallow: true }
|
||||
);
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
_searchParams.set("t", `${Date.now()}`);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
return await sendVerificationLogin(customer.email, customer.username);
|
||||
}}>
|
||||
{secondsLeft > 0 ? `Resend in ${secondsLeft} seconds` : "Send another mail"}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
||||
@@ -9,13 +8,20 @@ import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
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";
|
||||
import {
|
||||
Button,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
Label,
|
||||
showToast,
|
||||
@@ -24,26 +30,14 @@ import {
|
||||
Switch,
|
||||
TimezoneSelect,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DropdownMenuSeparator,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownItem,
|
||||
DropdownMenuTrigger,
|
||||
ConfirmationDialogContent,
|
||||
VerticalDivider,
|
||||
} from "@calcom/ui";
|
||||
import { Info, Plus, Trash, MoreHorizontal } from "@calcom/ui/components/icon";
|
||||
import { Info, MoreHorizontal, Plus, Trash } from "@calcom/ui/components/icon";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
import EditableHeading from "@components/ui/EditableHeading";
|
||||
|
||||
const querySchema = z.object({
|
||||
schedule: z.coerce.number().positive().optional(),
|
||||
});
|
||||
|
||||
type AvailabilityFormValues = {
|
||||
name: string;
|
||||
schedule: ScheduleType;
|
||||
@@ -93,15 +87,13 @@ const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => {
|
||||
};
|
||||
|
||||
export default function Availability() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const me = useMeQuery();
|
||||
const {
|
||||
data: { schedule: scheduleId },
|
||||
} = useTypedQuery(querySchema);
|
||||
|
||||
const { fromEventType } = router.query;
|
||||
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
|
||||
const fromEventType = searchParams?.get("fromEventType");
|
||||
const { timeFormat } = me.data || { timeFormat: null };
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -130,15 +131,17 @@ export default function AvailabilityPage() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<Shell
|
||||
<ShellMain
|
||||
heading={t("availability")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<NewScheduleButton />}>
|
||||
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AvailabilityPage.getLayout = getLayout;
|
||||
|
||||
AvailabilityPage.PageWrapper = PageWrapper;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createEvent } from "ics";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RRule } from "rrule";
|
||||
import { z } from "zod";
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
@@ -47,11 +48,9 @@ import { localStorage } from "@calcom/lib/webstorage";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Button, EmailInput, HeadSeo, Badge, useCalcomTheme, Alert } from "@calcom/ui";
|
||||
import { X, ExternalLink, ChevronLeft, Check, Calendar } from "@calcom/ui/components/icon";
|
||||
import { AlertCircle } from "@calcom/ui/components/icon";
|
||||
import { bookingMetadataSchema, customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Alert, Badge, Button, EmailInput, HeadSeo, useCalcomTheme } from "@calcom/ui";
|
||||
import { AlertCircle, Calendar, Check, ChevronLeft, ExternalLink, X } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -99,6 +98,9 @@ const querySchema = z.object({
|
||||
export default function Success(props: SuccessProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const {
|
||||
allRemainingBookings,
|
||||
isSuccessBookingPage,
|
||||
@@ -106,7 +108,7 @@ export default function Success(props: SuccessProps) {
|
||||
formerTime,
|
||||
email,
|
||||
seatReferenceUid,
|
||||
} = querySchema.parse(router.query);
|
||||
} = querySchema.parse(routerQuery);
|
||||
|
||||
const attendeeTimeZone = props?.bookingInfo?.attendees.find(
|
||||
(attendee) => attendee.email === email
|
||||
@@ -145,30 +147,27 @@ export default function Success(props: SuccessProps) {
|
||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||
|
||||
function setIsCancellationMode(value: boolean) {
|
||||
const query_ = { ...router.query };
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
|
||||
if (value) {
|
||||
query_.cancel = "true";
|
||||
_searchParams.set("cancel", "true");
|
||||
} else {
|
||||
if (query_.cancel) {
|
||||
delete query_.cancel;
|
||||
if (_searchParams.get("cancel")) {
|
||||
_searchParams.delete("cancel");
|
||||
}
|
||||
}
|
||||
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: { ...query_ },
|
||||
},
|
||||
undefined,
|
||||
{ scroll: false }
|
||||
);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
}
|
||||
|
||||
let evtName = props.eventType.eventName;
|
||||
if (eventType.isDynamic && bookingInfo.responses?.title) {
|
||||
evtName = bookingInfo.responses.title as string;
|
||||
}
|
||||
const eventNameObject = {
|
||||
attendeeName,
|
||||
eventType: props.eventType.title,
|
||||
eventName: (props.dynamicEventName as string) || props.eventType.eventName,
|
||||
eventName: evtName,
|
||||
host: props.profile.name || "Nameless",
|
||||
location: location,
|
||||
bookingFields: bookingInfo.responses,
|
||||
@@ -405,7 +404,9 @@ export default function Success(props: SuccessProps) {
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{eventName}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0" data-testid="booking-title">
|
||||
{eventName}
|
||||
</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
{reschedule && !!formerTime && (
|
||||
@@ -440,7 +441,7 @@ export default function Success(props: SuccessProps) {
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<span data-testid={`host-name-${bookingInfo.user.name}`} className="mr-2">
|
||||
<span data-testid="booking-host-name" className="mr-2">
|
||||
{bookingInfo.user.name}
|
||||
</span>
|
||||
<Badge variant="blue">{t("Host")}</Badge>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import type { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
import BookingLayout from "@calcom/features/bookings/layout/BookingLayout";
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
|
||||
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { HorizontalTabs } from "@calcom/ui";
|
||||
import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui";
|
||||
import { Alert, Button, EmptyScreen } from "@calcom/ui";
|
||||
import { Calendar } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -32,6 +37,28 @@ type RecurringInfo = {
|
||||
bookings: { [key: string]: Date[] };
|
||||
};
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
href: "/bookings/unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
},
|
||||
];
|
||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
||||
|
||||
const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
|
||||
@@ -47,9 +74,9 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
export default function Bookings() {
|
||||
const params = useParamsWithFallback();
|
||||
const { data: filterQuery } = useFilterQuery();
|
||||
const router = useRouter();
|
||||
const { status } = router.isReady ? querySchema.parse(router.query) : { status: "upcoming" as const };
|
||||
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
|
||||
const { t } = useLocale();
|
||||
|
||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||
@@ -62,7 +89,7 @@ export default function Bookings() {
|
||||
},
|
||||
{
|
||||
// first render has status `undefined`
|
||||
enabled: router.isReady,
|
||||
enabled: true,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
@@ -112,90 +139,101 @@ export default function Bookings() {
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<BookingLayout heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<div className="flex w-full flex-col" ref={animationParentRef}>
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<>
|
||||
{!!bookingsToday.length && status === "upcoming" && (
|
||||
<div className="mb-6 pt-2 xl:pt-0">
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
|
||||
<Fragment>
|
||||
{bookingsToday.map((booking: BookingOutput) => (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfoToday}
|
||||
{...booking}
|
||||
/>
|
||||
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col flex-wrap lg:flex-row">
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
<div className="max-w-full overflow-x-auto xl:ml-auto">
|
||||
<FiltersContainer />
|
||||
</div>
|
||||
</div>
|
||||
<main className="w-full">
|
||||
<div className="flex w-full flex-col" ref={animationParentRef}>
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<>
|
||||
{!!bookingsToday.length && status === "upcoming" && (
|
||||
<div className="mb-6 pt-2 xl:pt-0">
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
|
||||
<Fragment>
|
||||
{bookingsToday.map((booking: BookingOutput) => (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfoToday}
|
||||
{...booking}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 xl:pt-0">
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
|
||||
const recurringInfo = page.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
);
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{query.status === "success" && isEmpty && (
|
||||
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||
<EmptyScreen
|
||||
Icon={Calendar}
|
||||
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
||||
description={t("no_status_bookings_yet_description", {
|
||||
status: t(status).toLowerCase(),
|
||||
description: t(descriptionByStatus[status]),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 xl:pt-0">
|
||||
<div className="border-subtle overflow-hidden rounded-md border">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
|
||||
const recurringInfo = page.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
);
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
|
||||
<Button
|
||||
color="minimal"
|
||||
loading={query.isFetchingNextPage}
|
||||
disabled={!query.hasNextPage}
|
||||
onClick={() => query.fetchNextPage()}>
|
||||
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{query.status === "success" && isEmpty && (
|
||||
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||
<EmptyScreen
|
||||
Icon={Calendar}
|
||||
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
|
||||
description={t("no_status_bookings_yet_description", {
|
||||
status: t(status).toLowerCase(),
|
||||
description: t(descriptionByStatus[status]),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BookingLayout>
|
||||
</ShellMain>
|
||||
);
|
||||
}
|
||||
|
||||
Bookings.PageWrapper = PageWrapper;
|
||||
Bookings.getLayout = getLayout;
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||
const params = querySchema.safeParse(ctx.params);
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Type({
|
||||
away,
|
||||
isBrandingHidden,
|
||||
isTeamEvent,
|
||||
org,
|
||||
entity,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
@@ -34,7 +34,7 @@ export default function Type({
|
||||
eventSlug={slug}
|
||||
rescheduleUid={booking?.uid}
|
||||
hideBranding={isBrandingHidden}
|
||||
org={org}
|
||||
entity={entity}
|
||||
/>
|
||||
<Booker
|
||||
username={user}
|
||||
@@ -43,7 +43,7 @@ export default function Type({
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent={isTeamEvent}
|
||||
org={org}
|
||||
entity={entity}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -132,7 +132,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
return {
|
||||
props: {
|
||||
org,
|
||||
entity: eventData.entity,
|
||||
booking,
|
||||
away: user?.away,
|
||||
user: username,
|
||||
|
||||
@@ -87,6 +87,7 @@ export type FormValues = {
|
||||
description: string;
|
||||
disableGuests: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
requiresBookerEmailVerification: boolean;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
schedulingType: SchedulingType | null;
|
||||
hidden: boolean;
|
||||
@@ -102,6 +103,7 @@ export type FormValues = {
|
||||
phone?: string;
|
||||
hostDefault?: string;
|
||||
credentialId?: number;
|
||||
teamName?: string;
|
||||
}[];
|
||||
customInputs: CustomInputParsed[];
|
||||
schedule: number | null;
|
||||
@@ -205,6 +207,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
message = `${err.data.code}: ${t(err.message)}`;
|
||||
}
|
||||
|
||||
if (err.data?.code === "INTERNAL_SERVER_ERROR") {
|
||||
message = t("unexpected_error_try_again");
|
||||
}
|
||||
|
||||
showToast(message ? t(message) : t(err.message), "error");
|
||||
},
|
||||
});
|
||||
@@ -247,10 +253,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
durationLimits: eventType.durationLimits || undefined,
|
||||
length: eventType.length,
|
||||
hidden: eventType.hidden,
|
||||
hashedLink: eventType.hashedLink?.link || undefined,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
endDate: periodDates.endDate,
|
||||
},
|
||||
offsetStart: eventType.offsetStart,
|
||||
bookingFields: eventType.bookingFields,
|
||||
periodType: eventType.periodType,
|
||||
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
|
||||
@@ -526,6 +534,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
const EventTypePageWrapper = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
|
||||
|
||||
if (!data) return null;
|
||||
return <EventTypePage {...(data as EventTypeSetupProps)} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,35 +2,40 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import type { User } from "@prisma/client";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState, memo } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
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 { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ConfirmationDialogContent,
|
||||
CreateButton,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -40,15 +45,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
EmptyScreen,
|
||||
HeadSeo,
|
||||
HorizontalTabs,
|
||||
Label,
|
||||
showToast,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
CreateButton,
|
||||
HorizontalTabs,
|
||||
HeadSeo,
|
||||
Skeleton,
|
||||
Label,
|
||||
Alert,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -63,18 +66,18 @@ import {
|
||||
MoreHorizontal,
|
||||
Trash,
|
||||
Upload,
|
||||
Users,
|
||||
User as UserIcon,
|
||||
Users,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
|
||||
|
||||
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
type GetByViewerResponse = RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
|
||||
|
||||
interface EventTypeListHeadingProps {
|
||||
profile: EventTypeGroupProfile;
|
||||
@@ -201,6 +204,8 @@ const MemoizedItem = memo(Item);
|
||||
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -291,25 +296,19 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "duplicate",
|
||||
title: eventType.title,
|
||||
description: eventType.description,
|
||||
slug: eventType.slug,
|
||||
id: eventType.id,
|
||||
length: eventType.length,
|
||||
pageSlug: group.profile.slug,
|
||||
};
|
||||
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
|
||||
if (value) newSearchParams.set(key, value.toString());
|
||||
if (value === null) newSearchParams.delete(key);
|
||||
}
|
||||
setParamsIfDefined("dialog", "duplicate");
|
||||
setParamsIfDefined("title", eventType.title);
|
||||
setParamsIfDefined("description", eventType.description);
|
||||
setParamsIfDefined("slug", eventType.slug);
|
||||
setParamsIfDefined("id", eventType.id);
|
||||
setParamsIfDefined("length", eventType.length);
|
||||
setParamsIfDefined("pageSlug", group.profile.slug);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
};
|
||||
|
||||
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||
@@ -509,7 +508,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
)}
|
||||
{!isManagedEventType && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<EmbedButton
|
||||
<EventTypeEmbedButton
|
||||
as={DropdownItem}
|
||||
type="button"
|
||||
StartIcon={Code}
|
||||
@@ -517,7 +516,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
embedUrl={encodeURIComponent(embedLink)}
|
||||
eventId={type.id}>
|
||||
{t("embed")}
|
||||
</EmbedButton>
|
||||
</EventTypeEmbedButton>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
|
||||
@@ -717,10 +716,7 @@ const EventTypeListHeading = ({
|
||||
<Avatar
|
||||
alt={profile?.name || ""}
|
||||
href={teamId ? `/settings/teams/${teamId}/profile` : "/settings/my-account/profile"}
|
||||
imageSrc={
|
||||
`${orgBranding?.fullDomain ?? WEBAPP_URL}/${teamId ? "team/" : ""}${profile.slug}/avatar.png` ||
|
||||
undefined
|
||||
}
|
||||
imageSrc={`${orgBranding?.fullDomain ?? WEBAPP_URL}/${profile.slug}/avatar.png` || undefined}
|
||||
size="md"
|
||||
className="mt-1 inline-flex justify-center"
|
||||
/>
|
||||
@@ -769,14 +765,12 @@ const CreateFirstEventTypeView = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CTA = () => {
|
||||
const CTA = ({ data }: { data: GetByViewerResponse }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const query = trpc.viewer.eventTypes.getByViewer.useQuery();
|
||||
if (!data) return null;
|
||||
|
||||
if (!query.data) return null;
|
||||
|
||||
const profileOptions = query.data.profiles
|
||||
const profileOptions = data.profiles
|
||||
.filter((profile) => !profile.readOnly)
|
||||
.map((profile) => {
|
||||
return {
|
||||
@@ -834,25 +828,29 @@ const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Main = () => {
|
||||
const Main = ({
|
||||
status,
|
||||
error,
|
||||
data,
|
||||
filters,
|
||||
}: {
|
||||
status: string;
|
||||
data: GetByViewerResponse;
|
||||
error: any;
|
||||
filters: ReturnType<typeof getTeamsFiltersFromQuery>;
|
||||
}) => {
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const router = useRouter();
|
||||
const filters = getTeamsFiltersFromQuery(router.query);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
|
||||
const res = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters });
|
||||
|
||||
if (res.status === "loading") {
|
||||
if (!data || status === "loading") {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (res.status === "error") {
|
||||
return <Alert severity="error" title="Something went wrong" message={res.error.message} />;
|
||||
if (status === "error") {
|
||||
return <Alert severity="error" title="Something went wrong" message={error.message} />;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
const isFilteredByOnlyOneItem =
|
||||
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) && data.eventTypeGroups.length === 1;
|
||||
return (
|
||||
@@ -892,20 +890,28 @@ const Main = () => {
|
||||
)
|
||||
)}
|
||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
|
||||
<EmbedDialog />
|
||||
{router.query.dialog === "duplicate" && <DuplicateDialog />}
|
||||
<EventTypeEmbedDialog />
|
||||
{searchParams?.get("dialog") === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypesPage = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { open } = useIntercom();
|
||||
const { query } = router;
|
||||
const { data: user } = useMeQuery();
|
||||
const [showProfileBanner, setShowProfileBanner] = useState(false);
|
||||
const orgBranding = useOrgBranding();
|
||||
const routerQuery = useRouterQuery();
|
||||
const filters = getTeamsFiltersFromQuery(routerQuery);
|
||||
|
||||
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
|
||||
const { data, status, error } = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters }, {
|
||||
refetchOnWindowFocus: false,
|
||||
cacheTime: 1 * 60 * 60 * 1000,
|
||||
staleTime: 1 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
function closeBanner() {
|
||||
setShowProfileBanner(false);
|
||||
@@ -914,35 +920,38 @@ const EventTypesPage = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (query?.openIntercom && query?.openIntercom === "true") {
|
||||
if (searchParams?.get("openIntercom") === "true") {
|
||||
open();
|
||||
}
|
||||
setShowProfileBanner(
|
||||
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setShowProfileBanner(
|
||||
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
|
||||
);
|
||||
}, [orgBranding, user]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ShellMain
|
||||
withoutSeo
|
||||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA data={data} />}>
|
||||
<HeadSeo
|
||||
title="Event Types"
|
||||
description="Create events to share for people to book on your calendar."
|
||||
/>
|
||||
<Shell
|
||||
withoutSeo
|
||||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA />}>
|
||||
<Main />
|
||||
</Shell>
|
||||
</div>
|
||||
<Main data={data} status={status} error={error} filters={filters} />
|
||||
</ShellMain>
|
||||
);
|
||||
};
|
||||
|
||||
EventTypesPage.getLayout = getLayout;
|
||||
|
||||
EventTypesPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default EventTypesPage;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { z } from "zod";
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button, StepCard, Steps } from "@calcom/ui";
|
||||
@@ -47,11 +48,12 @@ const stepRouteSchema = z.object({
|
||||
|
||||
// TODO: Refactor how steps work to be contained in one array/object. Currently we have steps,initalsteps,headers etc. These can all be in one place
|
||||
const OnboardingPage = () => {
|
||||
const pathname = usePathname();
|
||||
const params = useParamsWithFallback();
|
||||
const router = useRouter();
|
||||
const [user] = trpc.viewer.me.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
const result = stepRouteSchema.safeParse(router.query);
|
||||
const result = stepRouteSchema.safeParse(params);
|
||||
const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
|
||||
const from = result.success ? result.data.from : "";
|
||||
|
||||
@@ -96,12 +98,7 @@ const OnboardingPage = () => {
|
||||
|
||||
const goToIndex = (index: number) => {
|
||||
const newStep = steps[index];
|
||||
router.push(
|
||||
{
|
||||
pathname: `/getting-started/${stepTransform(newStep)}`,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
router.push(`/getting-started/${stepTransform(newStep)}`);
|
||||
};
|
||||
|
||||
const currentStepIndex = steps.indexOf(currentStep);
|
||||
@@ -118,16 +115,16 @@ const OnboardingPage = () => {
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}
|
||||
key={router.asPath}>
|
||||
key={pathname}>
|
||||
<Head>
|
||||
<title>{`${APP_NAME} - ${t("getting_started")}`}</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<div className="mx-auto px-4 py-6 md:py-24">
|
||||
<div className="mx-auto py-6 sm:px-4 md:py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
<div className="mx-auto sm:max-w-[520px]">
|
||||
<div className="mx-auto px-4 sm:max-w-[520px]">
|
||||
<header>
|
||||
<p className="font-cal mb-3 text-[28px] font-medium leading-7">
|
||||
{headers[currentStepIndex]?.title || "Undefined title"}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getLayout } from "@calcom/features/MainLayout";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ShellMain } from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -56,7 +57,7 @@ export default function InsightsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell hideHeadingOnMobile>
|
||||
<ShellMain hideHeadingOnMobile>
|
||||
<UpgradeTip
|
||||
title={t("make_informed_decisions")}
|
||||
description={t("make_informed_decisions_description")}
|
||||
@@ -111,12 +112,13 @@ export default function InsightsPage() {
|
||||
</FiltersProvider>
|
||||
)}
|
||||
</UpgradeTip>
|
||||
</Shell>
|
||||
</ShellMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InsightsPage.PageWrapper = PageWrapper;
|
||||
InsightsPage.getLayout = getLayout;
|
||||
|
||||
// If feature flag is disabled, return not found on getServerSideProps
|
||||
export const getServerSideProps = async () => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import type { PageProps as UserTypePageProps } from "../../../[user]/[type]";
|
||||
import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../[user]/[type]";
|
||||
import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../team/[slug]/[type]";
|
||||
import type { PageProps as TeamTypePageProps } from "../../../team/[slug]/[type]";
|
||||
import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../team/[slug]/[type]";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
@@ -16,9 +17,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
parent: {
|
||||
slug: ctx.query.orgSlug as string,
|
||||
},
|
||||
parent: getSlugOrRequestedSlug(ctx.query.orgSlug as string),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -16,9 +17,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
parent: {
|
||||
slug: ctx.query.orgSlug as string,
|
||||
},
|
||||
parent: getSlugOrRequestedSlug(ctx.query.orgSlug as string),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user