diff --git a/.env.example b/.env.example index 418388c3..b28da85c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Set this value to 'agree' to accept our license: +# Set this value to 'agree' to accept our license: # LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE # # Summary of terms: @@ -8,14 +8,28 @@ NEXT_PUBLIC_LICENSE_CONSENT='' # DATABASE_URL='postgresql://:@:/' -DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public" +DATABASE_URL="postgresql://postgres:@localhost:5450/calendso" -GOOGLE_API_CREDENTIALS='secret' +# Needed to enable Google Calendar integrationa and Login with Google +# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials +GOOGLE_API_CREDENTIALS='{}' + +# To enable Login with Google you need to: +# 1. Set `GOOGLE_API_CREDENTIALS` above +# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` +GOOGLE_LOGIN_ENABLED=false BASE_URL='http://localhost:3000' NEXT_PUBLIC_APP_URL='http://localhost:3000' JWT_SECRET='secret' +# This is used so we can bypass emails in auth flows for E2E testing +PLAYWRIGHT_SECRET= + +# To enable SAML login, set both these variables +# @see https://github.com/calendso/calendso/tree/main/ee#setting-up-saml-login +# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml" +# SAML_ADMINS='pro@example.com' # @see: https://github.com/calendso/calendso/issues/263 # Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL @@ -34,6 +48,7 @@ ZOOM_CLIENT_SECRET= #Used for the Daily integration DAILY_API_KEY= +DAILY_SCALE_PLAN='' # E-mail settings @@ -50,16 +65,24 @@ EMAIL_SERVER_PORT=587 EMAIL_SERVER_USER='' # Keep in mind that if you have 2FA enabled, you will need to provision an App Password. EMAIL_SERVER_PASSWORD='' +# The following configuration for Gmail has been verified to work. +# EMAIL_SERVER_HOST='smtp.gmail.com' +# EMAIL_SERVER_PORT=465 +# EMAIL_SERVER_USER='' +## You will need to provision an App Password. +## @see https://support.google.com/accounts/answer/185833 +# EMAIL_SERVER_PASSWORD='' + # ApiKey for cronjobs CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0' # Stripe Config NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_... -STRIPE_PRIVATE_KEY= # sk_test_... -STRIPE_CLIENT_ID= # ca_... -STRIPE_WEBHOOK_SECRET= # whsec_... -PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission -PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission +STRIPE_PRIVATE_KEY= # sk_test_... +STRIPE_CLIENT_ID= # ca_... +STRIPE_WEBHOOK_SECRET= # whsec_... +PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission +PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission # Application Key for symmetric encryption and decryption # must be 32 bytes for AES256 encryption algorithm diff --git a/.eslintignore b/.eslintignore index 3c3629e6..7ca119ec 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ node_modules +prisma/zod diff --git a/.eslintrc.json b/.eslintrc.json index f27a7d1d..a1385622 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,7 +12,8 @@ "plugin:@typescript-eslint/recommended", "prettier", "plugin:react/recommended", - "plugin:react-hooks/recommended" + "plugin:react-hooks/recommended", + "plugin:playwright/playwright-test" ], "plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"], "rules": { diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index a68c42d1..87d2eb23 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,12 +2,10 @@ name: Feature request about: Suggest a feature or idea title: "" -labels: enhancement +labels: feature assignees: "" --- -> Please check if your Feature Request has not been already raised in the [Discussions Tab](https://github.com/calendso/calendso/discussions), as we would like to reduce duplicates. If it has been already raised, simply upvote it 🔼. - ### Is your proposal related to a problem? + +Fixes # (issue) + +## Type of change + + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +## How should this be tested? + + + +- [ ] Test A +- [ ] Test B + +## Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code and corrected any misspellings +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index e50d3424..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: 2 -updates: - - package-ecosystem: npm - directory: "/" - schedule: - interval: daily - commit-message: - prefix: "⬆️" - open-pull-requests-limit: 4 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index e75c52cf..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Build -on: [push] -jobs: - build: - name: Build on Node ${{ matrix.node }} and ${{ matrix.os }} - - env: - DATABASE_URL: postgresql://postgres:@localhost:5432/calendso - NODE_ENV: test - BASE_URL: http://localhost:3000 - JWT_SECRET: secret - services: - postgres: - image: postgres:12.1 - env: - POSTGRES_USER: postgres - POSTGRES_DB: calendso - ports: - - 5432:5432 - runs-on: ${{ matrix.os }} - strategy: - matrix: - node: ["14.x"] - os: [ubuntu-latest] - - steps: - - name: Checkout repo - uses: actions/checkout@v2 - - - name: Use Node ${{ matrix.node }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node }} - - - name: Install deps - uses: bahmutov/npm-install@v1 - - - name: Next.js cache - uses: actions/cache@v2 - with: - path: ${{ github.workspace }}/.next/cache - key: ${{ runner.os }}-nextjs - - - run: yarn prisma migrate deploy - - run: yarn test - - run: yarn build - - types: - name: Check types - - strategy: - matrix: - node: ["14.x"] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - - steps: - - name: Checkout repo - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Use Node ${{ matrix.node }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node }} - - - name: Install deps - uses: bahmutov/npm-install@v1 - - - run: yarn check-changed-files diff --git a/.github/workflows/check-types.yml b/.github/workflows/check-types.yml new file mode 100644 index 00000000..bd836031 --- /dev/null +++ b/.github/workflows/check-types.yml @@ -0,0 +1,30 @@ +name: Check types +on: + pull_request: + branches: + - main +jobs: + types: + name: Check types + + strategy: + matrix: + node: ["14.x"] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repo + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Use Node ${{ matrix.node }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node }} + + - name: Install deps + uses: bahmutov/npm-install@v1 + + - run: yarn check-changed-files diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 944daf44..4d2dfb2d 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v2 - name: crowdin action - uses: crowdin/github-action@1.4.0 + uses: crowdin/github-action@1.4.2 with: upload_translations: true download_translations: true diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 71b09114..cb797303 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,18 +1,29 @@ name: E2E test -on: [push] +on: + pull_request_target: + branches: + - main jobs: test: timeout-minutes: 10 name: ${{ matrix.node }} and ${{ matrix.os }} - env: DATABASE_URL: postgresql://postgres:@localhost:5432/calendso - NODE_ENV: test BASE_URL: http://localhost:3000 JWT_SECRET: secret - # GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} + PLAYWRIGHT_SECRET: ${{ secrets.CI_PLAYWRIGHT_SECRET }} + GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} + GOOGLE_LOGIN_ENABLED: true # CRON_API_KEY: xxx - # CALENDSO_ENCRYPTION_KEY: xxx + CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }} + NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }} + STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} + STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} + PAYMENT_FEE_PERCENTAGE: 0.005 + PAYMENT_FEE_FIXED: 10 + SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso + SAML_ADMINS: pro@example.com # NEXTAUTH_URL: xxx # EMAIL_FROM: xxx # EMAIL_SERVER_HOST: xxx @@ -39,6 +50,9 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@v2 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 2 - name: Use Node ${{ matrix.node }} uses: actions/setup-node@v1 @@ -51,15 +65,30 @@ jobs: uses: actions/cache@v2 with: path: ${{ github.workspace }}/.next/cache - key: ${{ runner.os }}-nextjs + # Generate a new cache whenever packages or source files change. + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} + # If source files changed but packages didn't, rebuild from a prior cache. + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - - run: yarn test - run: yarn prisma migrate deploy - run: yarn db-seed + - run: yarn test - run: yarn build - - run: yarn start & - - run: npx wait-port 3000 --timeout 10000 - - run: yarn playwright install-deps + + - name: Cache playwright binaries + uses: actions/cache@v2 + id: playwright-cache + with: + path: | + ~/Library/Caches/ms-playwright + ~/.cache/ms-playwright + **/node_modules/playwright + key: cache-playwright-${{ hashFiles('**/yarn.lock') }} + - name: Install playwright deps + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: yarn playwright install --with-deps + - run: yarn test-playwright - name: Upload videos @@ -70,3 +99,4 @@ jobs: path: | playwright/screenshots playwright/videos + playwright/results diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5f718207..6ad1735c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,5 +1,8 @@ name: Lint -on: [push] +on: + pull_request: + branches: + - main jobs: lint: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index ec68fa67..47bcaa29 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ .nyc_output playwright/videos playwright/screenshots +playwright/artifacts +playwright/results # next.js /.next/ @@ -36,6 +38,7 @@ yarn-error.log* .env.development.local .env.test.local .env.production.local +.env.* # vercel .vercel @@ -54,3 +57,5 @@ yarn-error.log* # Local History for Visual Studio Code .history/ +# Typescript +tsconfig.tsbuildinfo diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8088ca44..d95fa075 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,10 +1,12 @@ { "recommendations": [ + "DavidAnson.vscode-markdownlint", // markdown linting "yzhang.markdown-all-in-one", // nicer markdown support "esbenp.prettier-vscode", // prettier plugin "dbaeumer.vscode-eslint", // eslint plugin "bradlc.vscode-tailwindcss", // hinting / autocompletion for tailwind "heybourn.headwind", // automatically sort tailwind classes in predictable order, kinda like "prettier for tailwind", + "ban.spellright", // Spell check for docs "stripe.vscode-stripe" // stripe VSCode extension ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 404259b4..b5e0d759 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,8 +6,7 @@ "source.fixAll.eslint": true }, "eslint.run": "onSave", - "workbench.colorCustomizations": { - "titleBar.activeBackground": "#888888", - "titleBar.inactiveBackground": "#292929" - } + "typescript.preferences.importModuleSpecifier": "non-relative", + "spellright.language": ["en"], + "spellright.documentTypes": ["markdown"] } diff --git a/@types/ical.d.ts b/@types/ical.d.ts new file mode 100644 index 00000000..b699d054 --- /dev/null +++ b/@types/ical.d.ts @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: © 2019 EteSync Authors +// SPDX-License-Identifier: GPL-3.0-only +// https://github.com/mozilla-comm/ical.js/issues/367#issuecomment-568493517 +declare module "ical.js" { + function parse(input: string): any[]; + + export class helpers { + public updateTimezones(vcal: Component): Component; + } + + class Component { + public fromString(str: string): Component; + + public name: string; + + constructor(jCal: any[] | string, parent?: Component); + + public toJSON(): any[]; + + public getFirstSubcomponent(name?: string): Component | null; + public getAllSubcomponents(name?: string): Component[]; + + public getFirstPropertyValue(name?: string): T; + + public getFirstProperty(name?: string): Property; + public getAllProperties(name?: string): Property[]; + + public addProperty(property: Property): Property; + public addPropertyWithValue(name: string, value: string | number | Record): Property; + + public hasProperty(name?: string): boolean; + + public updatePropertyWithValue(name: string, value: string | number | Record): Property; + + public removeAllProperties(name?: string): boolean; + + public addSubcomponent(component: Component): Component; + } + + export class Event { + public uid: string; + public summary: string; + public startDate: Time; + public endDate: Time; + public description: string; + public location: string; + public attendees: Property[]; + /** + * The sequence value for this event. Used for scheduling. + * + * @type {number} + * @memberof Event + */ + public sequence: number; + /** + * The duration. This can be the result directly from the property, or the + * duration calculated from start date and end date. Setting the property + * will remove any `dtend` properties. + * + * @type {Duration} + * @memberof Event + */ + public duration: Duration; + /** + * The organizer value as an uri. In most cases this is a mailto: uri, + * but it can also be something else, like urn:uuid:... + */ + public organizer: string; + /** The sequence value for this event. Used for scheduling */ + public sequence: number; + /** The recurrence id for this event */ + public recurrenceId: Time; + + public component: Component; + + public constructor( + component?: Component | null, + options?: { strictExceptions: boolean; exepctions: Array } + ); + + public isRecurring(): boolean; + public iterator(startTime?: Time): RecurExpansion; + } + + export class Property { + public name: string; + public type: string; + + constructor(jCal: any[] | string, parent?: Component); + + public getFirstValue(): T; + public getValues(): T[]; + + public setParameter(name: string, value: string | string[]): void; + public setValue(value: string | Record): void; + public setValues(values: (string | Record)[]): void; + public toJSON(): any; + } + + interface TimeJsonData { + year?: number; + month?: number; + day?: number; + hour?: number; + minute?: number; + second?: number; + isDate?: boolean; + } + + export class Time { + public fromString(str: string): Time; + public fromJSDate(aDate: Date | null, useUTC: boolean): Time; + public fromData(aData: TimeJsonData): Time; + + public now(): Time; + + public isDate: boolean; + public timezone: string; + public zone: Timezone; + + public year: number; + public month: number; + public day: number; + public hour: number; + public minute: number; + public second: number; + + constructor(data?: TimeJsonData); + public compare(aOther: Time): number; + + public clone(): Time; + public convertToZone(zone: Timezone): Time; + + public adjust( + aExtraDays: number, + aExtraHours: number, + aExtraMinutes: number, + aExtraSeconds: number, + aTimeopt?: Time + ): void; + + public addDuration(aDuration: Duration): void; + public subtractDateTz(aDate: Time): Duration; + + public toUnixTime(): number; + public toJSDate(): Date; + public toJSON(): TimeJsonData; + public get icaltype(): "date" | "date-time"; + } + + export class Duration { + public weeks: number; + public days: number; + public hours: number; + public minutes: number; + public seconds: number; + public isNegative: boolean; + public icalclass: string; + public icaltype: string; + } + + export class RecurExpansion { + public complete: boolean; + public dtstart: Time; + public last: Time; + public next(): Time; + public fromData(options); + public toJSON(); + constructor(options: { + /** Start time of the event */ + dtstart: Time; + /** Component for expansion, required if not resuming. */ + component?: Component; + }); + } + + export class Timezone { + public utcTimezone: Timezone; + public localTimezone: Timezone; + public convert_time(tt: Time, fromZone: Timezone, toZone: Timezone): Time; + + public tzid: string; + public component: Component; + + constructor( + data: + | Component + | { + component: string | Component; + tzid?: string; + location?: string; + tznames?: string; + latitude?: number; + longitude?: number; + } + ); + } + + export class TimezoneService { + public get(tzid: string): Timezone | null; + public has(tzid: string): boolean; + public register(tzid: string, zone: Timezone | Component); + public remove(tzid: string): Timezone | null; + } + + export type FrequencyValues = + | "YEARLY" + | "MONTHLY" + | "WEEKLY" + | "DAILY" + | "HOURLY" + | "MINUTELY" + | "SECONDLY"; + + export enum WeekDay { + SU = 1, + MO, + TU, + WE, + TH, + FR, + SA, + } + + export class RecurData { + public freq?: FrequencyValues; + public interval?: number; + public wkst?: WeekDay; + public until?: Time; + public count?: number; + public bysecond?: number[] | number; + public byminute?: number[] | number; + public byhour?: number[] | number; + public byday?: string[] | string; + public bymonthday?: number[] | number; + public byyearday?: number[] | number; + public byweekno?: number[] | number; + public bymonth?: number[] | number; + public bysetpos?: number[] | number; + } + + export class RecurIterator { + public next(): Time; + } + + export class Recur { + constructor(data?: RecurData); + public until: Time | null; + public freq: FrequencyValues; + public count: number | null; + + public clone(): Recur; + public toJSON(): Omit & { until?: string }; + public iterator(startTime?: Time): RecurIterator; + public isByCount(): boolean; + } +} diff --git a/README.md b/README.md index e583e9a6..b24d9d0c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This version of [Cal.com](https://cal.com?ref=wego-technologies) is modified for

Logo - +

Cal.com (formerly Calendso)

@@ -31,7 +31,9 @@ This version of [Cal.com](https://cal.com?ref=wego-technologies) is modified for License Commits-per-month - Pricing + Pricing + Jitsu Tracked +

@@ -40,7 +42,7 @@ This version of [Cal.com](https://cal.com?ref=wego-technologies) is modified for booking-screen -# Scheduling infrastructure for absolutely everyone. +# Scheduling infrastructure for absolutely everyone The open source Calendly alternative. You are in charge of your own data, workflow and appearance. @@ -49,7 +51,7 @@ Calendly and other scheduling tools are awesome. It made our lives massively eas That's where Cal.com comes in. Self-hosted or hosted by us. White-label by design. API-driven and ready to be deployed on your own domain. Full control of your events and data. -### Product of the Month: April +## Product of the Month: April #### Support us on [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso) @@ -82,43 +84,55 @@ Here is what you need to be able to run Cal. - PostgreSQL - Yarn _(recommended)_ -You will also need Google API credentials. You can get this from the [Google API Console](https://console.cloud.google.com/apis/dashboard). More details on this can be found below under the [Obtaining the Google API Credentials section](#Obtaining-the-Google-API-Credentials). +> If you want to enable any of the available integrations, you may want to obtain additional credentials for each one. More details on this can be found below under the [integrations section](#integrations). ## Development ### Setup -#### Quick start with `yarn dx` -> - **Requires Docker to be installed** -> - Will start a local Postgres instance with a few test users - the credentials will be logged in the console +1. Clone the repo -```bash -git clone git@github.com:calendso/calendso.git -cd calendso -yarn -yarn dx -``` + ```sh + git clone https://github.com/calendso/calendso.git + ``` -#### Manual +1. Go to the project folder -1. Clone the repo ```sh - git clone https://github.com/calendso/calendso.git + cd calendso + ``` + +1. Copy `.env.example` to `.env` + + ```sh + cp .env.example .env ``` -2. Install packages with yarn + +1. Install packages with yarn + ```sh - yarn install + yarn ``` -3. Copy `.env.example` to `.env` -4. Configure environment variables in the .env file. Replace ``, ``, ``, `` with their applicable values + +#### Quick start with `yarn dx` + +> - **Requires Docker and Docker Compose to be installed** +> - Will start a local Postgres instance with a few test users - the credentials will be logged in the console + +```sh +yarn dx +``` + +#### Manual setup + +1. Configure environment variables in the .env file. Replace ``, ``, ``, `` with their applicable values ``` DATABASE_URL='postgresql://:@:' - GOOGLE_API_CREDENTIALS='secret' ```
- If you don't know how to configure the DATABASE_URL, then follow the steps here + If you don't know how to configure the DATABASE_URL, then follow the steps here to create a quick DB using Heroku 1. Create a free account with [Heroku](https://www.heroku.com/). @@ -144,26 +158,35 @@ yarn dx 8. To view your DB, once you add new data in Prisma, you can use [Heroku Data Explorer](https://heroku-data-explorer.herokuapp.com/).
-5. Set up the database using the Prisma schema (found in `prisma/schema.prisma`) +1. Set a 32 character random string in your .env file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one). +1. Set up the database using the Prisma schema (found in `prisma/schema.prisma`) + ```sh npx prisma migrate deploy ``` -6. Run (in development mode) + +1. Run (in development mode) + ```sh yarn dev ``` -7. Open [Prisma Studio](https://www.prisma.io/studio) to look at or modify the database content: - ``` + +#### Setting up your first user + +1. Open [Prisma Studio](https://www.prisma.io/studio) to look at or modify the database content: + + ```sh npx prisma studio ``` -8. Click on the `User` model to add a new user record. -9. Fill out the fields (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user. -10. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user. -11. Set a 32 character random string in your .env file for the CALENDSO_ENCRYPTION_KEY. + +1. Click on the `User` model to add a new user record. +1. Fill out the fields `email`, `username`, `password`, and set `metadata` to empty `{}` (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user. + > New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `prisma/schema.prisma` file. +1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user. ### E2E-Testing -```bash +```sh # In first terminal yarn dx # In second terminal @@ -173,14 +196,16 @@ yarn test-playwright ### Upgrading from earlier versions 1. Pull the current version: - ``` + + ```sh git pull ``` + 2. Apply database migrations by running one of the following commands: In a development environment, run: - ``` + ```sh npx prisma migrate dev ``` @@ -188,7 +213,7 @@ yarn test-playwright In a production environment, run: - ``` + ```sh npx prisma migrate deploy ``` @@ -202,14 +227,18 @@ yarn test-playwright ``` 4. Start the server. In a development environment, just do: - ``` + + ```sh yarn dev ``` + For a production build, run for example: - ``` + + ```sh yarn build yarn start ``` + 5. Enjoy the new version. @@ -218,11 +247,17 @@ yarn test-playwright ### Docker The Docker configuration for Cal is an effort powered by people within the community. Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk. - + If you want to contribute to the Docker repository, [reply here](https://github.com/calendso/docker/discussions/32). The Docker configuration can be found [in our docker repository](https://github.com/calendso/docker). + +### Heroku + + + Deploy + ### Railway @@ -249,7 +284,9 @@ Contributions are what make the open source community such an amazing place to b 5. Push to the branch (`git push origin feature/AmazingFeature`) 6. Open a pull request -## Obtaining the Google API Credentials +## Integrations + +### Obtaining the Google API Credentials 1. Open [Google API Console](https://console.cloud.google.com/apis/dashboard). If you don't have a project in your Google Cloud subscription, you'll need to create one before proceeding further. Under Dashboard pane, select Enable APIS and Services. 2. In the search box, type calendar and select the Google Calendar API search result. @@ -263,16 +300,16 @@ Contributions are what make the open source community such an amazing place to b 10. The key will be created and you will be redirected back to the Credentials page. Select the newly generated client ID under OAuth 2.0 Client IDs. 11. Select Download JSON. Copy the contents of this file and paste the entire JSON string in the .env file as the value for GOOGLE_API_CREDENTIALS key. -## Obtaining Microsoft Graph Client ID and Secret +### Obtaining Microsoft Graph Client ID and Secret 1. Open [Azure App Registration](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) and select New registration 2. Name your application 3. Set **Who can use this application or access this API?** to **Accounts in any organizational directory (Any Azure AD directory - Multitenant)** 4. Set the **Web** redirect URI to `/api/integrations/office365calendar/callback` replacing Cal.com URL with the URI at which your application runs. 5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env -6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attriubte +6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute -## Obtaining Zoom Client ID and Secret +### Obtaining Zoom Client ID and Secret 1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account. 2. On the upper right, click "Develop" => "Build App". @@ -288,12 +325,12 @@ Contributions are what make the open source community such an amazing place to b 12. Click "Done". 13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings. -## Obtaining Daily API Credentials +### Obtaining Daily API Credentials - 1. Open [Daily](https://www.daily.co/) and sign into your account. - 2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab. - 3. Copy your API key. - 4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file. +1. Open [Daily](https://www.daily.co/) and sign into your account. +2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab. +3. Copy your API key. +4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file. @@ -314,3 +351,8 @@ Special thanks to these amazing projects which help power Cal.com: - [Day.js](https://day.js.org/) - [Tailwind CSS](https://tailwindcss.com/) - [Prisma](https://prisma.io/) + +[](https://jitsu.com/?utm_source=cal.com-gihub) + +Cal.com is an [open startup](https://jitsu.com) and [Jitsu](https://github.com/jitsucum/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics. + diff --git a/app.json b/app.json new file mode 100644 index 00000000..15095fb5 --- /dev/null +++ b/app.json @@ -0,0 +1,7 @@ +{ + "name": "Cal.com", + "description": "Open Source Scheduling", + "repository": "https://github.com/calendso/calendso", + "logo": "https://cal.com/android-chrome-512x512.png", + "keywords": ["react", "typescript", "node", "nextjs", "prisma", "postgres", "trpc"] +} diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index 902bb1e6..00000000 --- a/babel.config.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = function (api) { - api.cache(true); - const plugins = []; - if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") { - console.log("------ 💯 Adding test coverage support 💯 ------"); - plugins.push("istanbul"); - } - - return { - presets: ["next/babel"], - plugins, - }; -}; diff --git a/components/AddToHomescreen.tsx b/components/AddToHomescreen.tsx index 312894dc..8730e948 100644 --- a/components/AddToHomescreen.tsx +++ b/components/AddToHomescreen.tsx @@ -13,14 +13,14 @@ export default function AddToHomescreen() { } } return !closeBanner ? ( -
-
+
+
-
-
- +
+
+ @@ -34,13 +34,13 @@ export default function AddToHomescreen() {

-
+
diff --git a/components/CustomBranding.tsx b/components/CustomBranding.tsx new file mode 100644 index 00000000..75acc79c --- /dev/null +++ b/components/CustomBranding.tsx @@ -0,0 +1,40 @@ +import { useEffect } from "react"; + +function computeContrastRatio(a: number[], b: number[]) { + const lum1 = computeLuminance(a[0], a[1], a[2]); + const lum2 = computeLuminance(b[0], b[1], b[2]); + const brightest = Math.max(lum1, lum2); + const darkest = Math.min(lum1, lum2); + return (brightest + 0.05) / (darkest + 0.05); +} + +function computeLuminance(r: number, g: number, b: number) { + const a = [r, g, b].map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; +} + +function hexToRGB(hex: string) { + const color = hex.replace("#", ""); + return [parseInt(color.slice(0, 2), 16), parseInt(color.slice(2, 4), 16), parseInt(color.slice(4, 6), 16)]; +} + +function getContrastingTextColor(bgColor: string | null): string { + bgColor = bgColor == "" || bgColor == null ? "#292929" : bgColor; + const rgb = hexToRGB(bgColor); + const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]); + const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929 + return whiteContrastRatio > blackContrastRatio ? "#ffffff" : "#292929"; +} + +const BrandColor = ({ val = "#292929" }: { val: string | undefined | null }) => { + useEffect(() => { + document.documentElement.style.setProperty("--brand-color", val); + document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(val)); + }, [val]); + return null; +}; + +export default BrandColor; diff --git a/components/DestinationCalendarSelector.tsx b/components/DestinationCalendarSelector.tsx new file mode 100644 index 00000000..ef9dd1f7 --- /dev/null +++ b/components/DestinationCalendarSelector.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from "react"; +import Select from "react-select"; + +import { useLocale } from "@lib/hooks/useLocale"; +import { trpc } from "@lib/trpc"; + +import Button from "@components/ui/Button"; + +interface Props { + onChange: (value: { externalId: string; integration: string }) => void; + isLoading?: boolean; + hidePlaceholder?: boolean; + /** The external Id of the connected calendar */ + value: string | undefined; +} + +const DestinationCalendarSelector = ({ + onChange, + isLoading, + value, + hidePlaceholder, +}: Props): JSX.Element | null => { + const { t } = useLocale(); + const query = trpc.useQuery(["viewer.connectedCalendars"]); + const [selectedOption, setSelectedOption] = useState<{ value: string; label: string } | null>(null); + + useEffect(() => { + if (!selectedOption) { + const selected = query.data?.connectedCalendars + .map((connected) => connected.calendars ?? []) + .flat() + .find((cal) => cal.externalId === value); + + if (selected) { + setSelectedOption({ + value: `${selected.integration}:${selected.externalId}`, + label: selected.name || "", + }); + } + } + }, [query.data?.connectedCalendars, selectedOption, value]); + + if (!query.data?.connectedCalendars.length) { + return null; + } + const options = + query.data.connectedCalendars.map((selectedCalendar) => ({ + key: selectedCalendar.credentialId, + label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`, + options: (selectedCalendar.calendars ?? []).map((cal) => ({ + label: cal.name || "", + value: `${cal.integration}:${cal.externalId}`, + })), + })) ?? []; + return ( +
+ {/* There's no easy way to customize the displayed value for a Select, so we fake it. */} + {!hidePlaceholder && ( +
+ +
+ )} +
@@ -276,15 +339,11 @@ const BookingPage = (props: BookingPageProps) => { {t("email_address")}
-
@@ -293,16 +352,14 @@ const BookingPage = (props: BookingPageProps) => { {t("location")} - {locations.map((location) => ( -
)} - {props.eventType.customInputs && - props.eventType.customInputs - .sort((a, b) => a.id - b.id) - .map((input) => ( -
- {input.type !== EventTypeCustomInputType.BOOL && ( + {props.eventType.customInputs + .sort((a, b) => a.id - b.id) + .map((input) => ( +
+ {input.type !== EventTypeCustomInputType.BOOL && ( + + )} + {input.type === EventTypeCustomInputType.TEXTLONG && ( + -

{t("team_description")}

-
-
-
-
- - - -
-
-
-
-

{t("members")}

-
- -
-
-
- {!!members.length && ( - - )} -
-
-
-
-
- -
-
- -

{t("disable_cal_branding_description")}

-
-
-
-
-

{t("danger_zone")}

-
-
- - { - e.stopPropagation(); - }} - className="btn-sm btn-white"> - - {t("disband_team")} - - deleteTeam()}> - {t("disband_team_confirmation_message")} - - -
-
-
-
-
-
- -
-
- - {showMemberInvitationModal && ( - - )} -
- - ); -} diff --git a/components/team/MemberChangeRoleModal.tsx b/components/team/MemberChangeRoleModal.tsx new file mode 100644 index 00000000..f28c28f7 --- /dev/null +++ b/components/team/MemberChangeRoleModal.tsx @@ -0,0 +1,86 @@ +import { MembershipRole } from "@prisma/client"; +import { useState } from "react"; +import React, { SyntheticEvent } from "react"; + +import { useLocale } from "@lib/hooks/useLocale"; +import { trpc } from "@lib/trpc"; + +import Button from "@components/ui/Button"; +import ModalContainer from "@components/ui/ModalContainer"; + +export default function MemberChangeRoleModal(props: { + memberId: number; + teamId: number; + initialRole: MembershipRole; + onExit: () => void; +}) { + const [role, setRole] = useState(props.initialRole || MembershipRole.MEMBER); + const [errorMessage, setErrorMessage] = useState(""); + const { t } = useLocale(); + const utils = trpc.useContext(); + + const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", { + async onSuccess() { + await utils.invalidateQueries(["viewer.teams.get"]); + props.onExit(); + }, + async onError(err) { + setErrorMessage(err.message); + }, + }); + + function changeRole(e: SyntheticEvent) { + e.preventDefault(); + + changeRoleMutation.mutate({ + teamId: props.teamId, + memberId: props.memberId, + role, + }); + } + + return ( + + <> +
+
+ +
+
+
+
+ + +
+ + {errorMessage && ( +

+ Error: + {errorMessage} +

+ )} +
+ + +
+
+ +
+ ); +} diff --git a/components/team/MemberInvitationModal.tsx b/components/team/MemberInvitationModal.tsx index f9a602f6..30f43f22 100644 --- a/components/team/MemberInvitationModal.tsx +++ b/components/team/MemberInvitationModal.tsx @@ -1,58 +1,50 @@ -import { UsersIcon } from "@heroicons/react/outline"; +import { UserIcon } from "@heroicons/react/outline"; +import { MembershipRole } from "@prisma/client"; import { useState } from "react"; import React, { SyntheticEvent } from "react"; import { useLocale } from "@lib/hooks/useLocale"; -import { Team } from "@lib/team"; +import { TeamWithMembers } from "@lib/queries/teams"; +import { trpc } from "@lib/trpc"; +import { EmailInput } from "@components/form/fields"; import Button from "@components/ui/Button"; -export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) { +export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) { const [errorMessage, setErrorMessage] = useState(""); const { t, i18n } = useLocale(); + const utils = trpc.useContext(); - const handleError = async (res: Response) => { - const responseData = await res.json(); + const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", { + async onSuccess() { + await utils.invalidateQueries(["viewer.teams.get"]); + props.onExit(); + }, + async onError(err) { + setErrorMessage(err.message); + }, + }); - if (res.ok === false) { - setErrorMessage(responseData.message); - throw new Error(responseData.message); - } - - return responseData; - }; - - const inviteMember = (e: SyntheticEvent) => { + function inviteMember(e: SyntheticEvent) { e.preventDefault(); + if (!props.team) return; const target = e.target as typeof e.target & { elements: { - role: { value: string }; + role: { value: MembershipRole }; inviteUser: { value: string }; sendInviteEmail: { checked: boolean }; }; }; - const payload = { + inviteMemberMutation.mutate({ + teamId: props.team.id, language: i18n.language, role: target.elements["role"].value, usernameOrEmail: target.elements["inviteUser"].value, sendEmailInvitation: target.elements["sendInviteEmail"].checked, - }; - - return fetch("/api/teams/" + props?.team?.id + "/invite", { - method: "POST", - body: JSON.stringify(payload), - headers: { - "Content-Type": "application/json", - }, - }) - .then(handleError) - .then(props.onExit) - .catch(() => { - // do nothing. - }); - }; + }); + } return (
-
- +
+
@@ -104,9 +96,9 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
@@ -116,7 +108,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined | name="sendInviteEmail" defaultChecked id="sendInviteEmail" - className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-black sm:text-sm" + className="text-black border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm" />
@@ -133,7 +125,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |

)}
- + + + + + + + + + + + + + + + {(props.team.membership.role === MembershipRole.OWNER || + props.team.membership.role === MembershipRole.ADMIN) && ( <> - - {t("pending")} - - - {t("member")} - + + + + + + + + + + + {t("remove_member_confirmation_message")} + + + )} - {props.member.role === "MEMBER" && ( - - {t("member")} - - )} - {props.member.role === "OWNER" && ( - - {t("owner")} - - )} -
-
-
- {/*
*/} - - - - - - - - - - - - props.onActionSelect("remove")}> - {t("remove_member_confirmation_message")} - - - - - - {/*
*/} -
+ +
- - ) +
+ {showChangeMemberRoleModal && ( + setShowChangeMemberRoleModal(false)} + /> + )} + {showTeamAvailabilityModal && ( + + +
+ + {props.team.membership.role !== MembershipRole.MEMBER && ( + + + + )} +
+
+ )} + ); } diff --git a/components/team/TeamCreateModal.tsx b/components/team/TeamCreateModal.tsx new file mode 100644 index 00000000..a768f3c1 --- /dev/null +++ b/components/team/TeamCreateModal.tsx @@ -0,0 +1,86 @@ +import { UsersIcon } from "@heroicons/react/outline"; +import { useRef } from "react"; + +import { useLocale } from "@lib/hooks/useLocale"; +import { trpc } from "@lib/trpc"; + +interface Props { + onClose: () => void; +} + +export default function TeamCreate(props: Props) { + const { t } = useLocale(); + const utils = trpc.useContext(); + + const nameRef = useRef() as React.MutableRefObject; + + const createTeamMutation = trpc.useMutation("viewer.teams.create", { + onSuccess: () => { + utils.invalidateQueries(["viewer.teams.list"]); + props.onClose(); + }, + }); + + const createTeam = (e: React.FormEvent) => { + e.preventDefault(); + createTeamMutation.mutate({ name: nameRef?.current?.value }); + }; + + return ( +
+
+ + + + +
+
+
+ +
+
+ +
+

{t("create_new_team_description")}

+
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/components/team/TeamList.tsx b/components/team/TeamList.tsx index 6a6ae787..2c71cb97 100644 --- a/components/team/TeamList.tsx +++ b/components/team/TeamList.tsx @@ -1,39 +1,44 @@ -import { Team } from "@lib/team"; +import showToast from "@lib/notification"; +import { trpc, inferQueryOutput } from "@lib/trpc"; import TeamListItem from "./TeamListItem"; -export default function TeamList(props: { - teams: Team[]; - onChange: () => void; - onEditTeam: (text: Team) => void; -}) { - const selectAction = (action: string, team: Team) => { +interface Props { + teams: inferQueryOutput<"viewer.teams.list">; +} + +export default function TeamList(props: Props) { + const utils = trpc.useContext(); + + function selectAction(action: string, teamId: number) { switch (action) { - case "edit": - props.onEditTeam(team); - break; case "disband": - deleteTeam(team); + deleteTeam(teamId); break; } - }; + } + + const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", { + async onSuccess() { + await utils.invalidateQueries(["viewer.teams.list"]); + }, + async onError(err) { + showToast(err.message, "error"); + }, + }); - const deleteTeam = async (team: Team) => { - await fetch("/api/teams/" + team.id, { - method: "DELETE", - }); - return props.onChange(); - }; + function deleteTeam(teamId: number) { + deleteTeamMutation.mutate({ teamId }); + } return (
-
    - {props.teams.map((team: Team) => ( +
      + {props.teams.map((team) => ( selectAction(action, team)}> + onActionSelect={(action: string) => selectAction(action, team?.id as number)}> ))}
diff --git a/components/team/TeamListItem.tsx b/components/team/TeamListItem.tsx index c6f34b96..8677e65d 100644 --- a/components/team/TeamListItem.tsx +++ b/components/team/TeamListItem.tsx @@ -1,173 +1,212 @@ -import { - DotsHorizontalIcon, - ExternalLinkIcon, - LinkIcon, - PencilAltIcon, - TrashIcon, -} from "@heroicons/react/outline"; +import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline"; +import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid"; import Link from "next/link"; -import { useState } from "react"; +import classNames from "@lib/classNames"; +import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; import { useLocale } from "@lib/hooks/useLocale"; import showToast from "@lib/notification"; +import { trpc, inferQueryOutput } from "@lib/trpc"; import { Dialog, DialogTrigger } from "@components/Dialog"; import { Tooltip } from "@components/Tooltip"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import Avatar from "@components/ui/Avatar"; import Button from "@components/ui/Button"; +import Dropdown, { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@components/ui/Dropdown"; -import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown"; +import TeamRole from "./TeamRole"; +import { MembershipRole } from ".prisma/client"; -interface Team { - id: number; - name: string | null; - slug: string | null; - logo: string | null; - bio: string | null; - role: string | null; - hideBranding: boolean; - prevState: null; -} - -export default function TeamListItem(props: { - onChange: () => void; +interface Props { + team: inferQueryOutput<"viewer.teams.list">[number]; key: number; - team: Team; onActionSelect: (text: string) => void; -}) { - const [team, setTeam] = useState(props.team); - const { t } = useLocale(); +} - const acceptInvite = () => invitationResponse(true); - const declineInvite = () => invitationResponse(false); +export default function TeamListItem(props: Props) { + const { t } = useLocale(); + const utils = trpc.useContext(); + const team = props.team; - const invitationResponse = (accept: boolean) => - fetch("/api/user/membership", { - method: accept ? "PATCH" : "DELETE", - body: JSON.stringify({ teamId: props.team.id }), - headers: { - "Content-Type": "application/json", - }, - }).then(() => { - // success - setTeam(null); - props.onChange(); + const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", { + onSuccess: () => { + utils.invalidateQueries(["viewer.teams.list"]); + }, + }); + function acceptOrLeave(accept: boolean) { + acceptOrLeaveMutation.mutate({ + teamId: team?.id as number, + accept, }); + } + const acceptInvite = () => acceptOrLeave(true); + const declineInvite = () => acceptOrLeave(false); + + const isOwner = props.team.role === MembershipRole.OWNER; + const isInvitee = !props.team.accepted; + const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN; + + if (!team) return <>; + + const teamInfo = ( +
+ +
+ {team.name} + + {process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug} + +
+
+ ); return ( - team && ( -
  • -
    -
    - -
    - {props.team.name} - - {process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug} - -
    -
    - {props.team.role === "INVITEE" && ( -
    +
  • +
    + {!isInvitee ? ( + + + {teamInfo} + + + ) : ( + teamInfo + )} +
    + {isInvitee && ( + <> - -
    + )} - {props.team.role === "MEMBER" && ( -
    - -
    - )} - {props.team.role === "OWNER" && ( -
    - - {t("owner")} - - + {!isInvitee && ( +
    + + + - - + + + {isAdmin && ( + + + + + + + + )} + {isAdmin && } - - - - + - - - - - - - props.onActionSelect("disband")}> - {t("disband_team_confirmation_message")} - - - + + {isOwner && ( + + + + + + props.onActionSelect("disband")}> + {t("disband_team_confirmation_message")} + + + + )} + + {!isOwner && ( + + + + + + + {t("leave_team_confirmation_message")} + + + + )}
    )}
    -
  • - ) +
    + ); } diff --git a/components/team/TeamRole.tsx b/components/team/TeamRole.tsx new file mode 100644 index 00000000..a5a04076 --- /dev/null +++ b/components/team/TeamRole.tsx @@ -0,0 +1,37 @@ +import { MembershipRole } from "@prisma/client"; +import classNames from "classnames"; + +import { useLocale } from "@lib/hooks/useLocale"; + +interface Props { + role?: MembershipRole; + invitePending?: boolean; +} + +export default function TeamRole(props: Props) { + const { t } = useLocale(); + + return ( + + {(() => { + if (props.invitePending) return t("invitee"); + switch (props.role) { + case "OWNER": + return t("owner"); + case "ADMIN": + return t("admin"); + case "MEMBER": + return t("member"); + default: + return ""; + } + })()} + + ); +} diff --git a/components/team/TeamSettings.tsx b/components/team/TeamSettings.tsx new file mode 100644 index 00000000..7b98e57d --- /dev/null +++ b/components/team/TeamSettings.tsx @@ -0,0 +1,210 @@ +import { HashtagIcon, InformationCircleIcon, LinkIcon, PhotographIcon } from "@heroicons/react/solid"; +import React, { useRef, useState } from "react"; + +import { useLocale } from "@lib/hooks/useLocale"; +import showToast from "@lib/notification"; +import { TeamWithMembers } from "@lib/queries/teams"; +import { trpc } from "@lib/trpc"; + +import ImageUploader from "@components/ImageUploader"; +import { TextField } from "@components/form/fields"; +import { Alert } from "@components/ui/Alert"; +import Button from "@components/ui/Button"; +import SettingInputContainer from "@components/ui/SettingInputContainer"; + +interface Props { + team: TeamWithMembers | null | undefined; +} + +export default function TeamSettings(props: Props) { + const { t } = useLocale(); + + const [hasErrors, setHasErrors] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const team = props.team; + const hasLogo = !!team?.logo; + + const utils = trpc.useContext(); + const mutation = trpc.useMutation("viewer.teams.update", { + onError: (err) => { + setHasErrors(true); + setErrorMessage(err.message); + }, + async onSuccess() { + await utils.invalidateQueries(["viewer.teams.get"]); + showToast(t("your_team_updated_successfully"), "success"); + setHasErrors(false); + }, + }); + + const nameRef = useRef() as React.MutableRefObject; + const teamUrlRef = useRef() as React.MutableRefObject; + const descriptionRef = useRef() as React.MutableRefObject; + const hideBrandingRef = useRef() as React.MutableRefObject; + const logoRef = useRef() as React.MutableRefObject; + + function updateTeamData() { + if (!team) return; + const variables = { + name: nameRef.current?.value, + slug: teamUrlRef.current?.value, + bio: descriptionRef.current?.value, + hideBranding: hideBrandingRef.current?.checked, + }; + // remove unchanged variables + for (const key in variables) { + //@ts-expect-error will fix types + if (variables[key] === team?.[key]) delete variables[key]; + } + mutation.mutate({ id: team.id, ...variables }); + } + + function updateLogo(newLogo: string) { + if (!team) return; + logoRef.current.value = newLogo; + mutation.mutate({ id: team.id, logo: newLogo }); + } + + const removeLogo = () => updateLogo(""); + + return ( +
    +
    + {hasErrors && } +
    { + e.preventDefault(); + updateTeamData(); + }}> +
    +
    +
    + + {process.env.NEXT_PUBLIC_APP_URL}/{"team/"} + + } + ref={teamUrlRef} + defaultValue={team?.slug as string} + /> + } + /> + + } + /> +
    +
    + + +

    {t("team_description")}

    + + } + /> +
    +
    + +
    + + + {hasLogo && ( + + )} +
    + + } + /> + +
    +
    + +
    +
    + +
    +
    + +

    {t("disable_cal_branding_description")}

    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + ); +} diff --git a/components/team/TeamSettingsRightSidebar.tsx b/components/team/TeamSettingsRightSidebar.tsx new file mode 100644 index 00000000..f2635c2c --- /dev/null +++ b/components/team/TeamSettingsRightSidebar.tsx @@ -0,0 +1,130 @@ +import { ClockIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, TrashIcon } from "@heroicons/react/solid"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import React from "react"; + +import { useLocale } from "@lib/hooks/useLocale"; +import showToast from "@lib/notification"; +import { TeamWithMembers } from "@lib/queries/teams"; +import { trpc } from "@lib/trpc"; + +import { Dialog, DialogTrigger } from "@components/Dialog"; +import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; +import CreateEventTypeButton from "@components/eventtype/CreateEventType"; +import LinkIconButton from "@components/ui/LinkIconButton"; + +import { MembershipRole } from ".prisma/client"; + +// import Switch from "@components/ui/Switch"; + +export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) { + const { t } = useLocale(); + const utils = trpc.useContext(); + const router = useRouter(); + + const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`; + + const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", { + async onSuccess() { + await utils.invalidateQueries(["viewer.teams.get"]); + showToast(t("your_team_updated_successfully"), "success"); + }, + }); + const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", { + onSuccess: () => { + utils.invalidateQueries(["viewer.teams.list"]); + router.push(`/settings/teams`); + }, + }); + + function deleteTeam() { + if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id }); + } + function leaveTeam() { + if (props.team?.id) + acceptOrLeaveMutation.mutate({ + teamId: props.team.id, + accept: false, + }); + } + + return ( +
    + + {/* */} +
    + + + {t("preview")} + + + { + navigator.clipboard.writeText(permalink); + showToast("Copied to clipboard", "success"); + }}> + {t("copy_link_team")} + + {props.role === "OWNER" ? ( + + + { + e.stopPropagation(); + }} + Icon={TrashIcon}> + {t("disband_team")} + + + + {t("disband_team_confirmation_message")} + + + ) : ( + + + { + e.stopPropagation(); + }}> + {t("leave_team")} + + + + {t("leave_team_confirmation_message")} + + + )} +
    + {props.team?.id && props.role !== MembershipRole.MEMBER && ( + +
    + {"View Availability"} +

    See your team members availability at a glance.

    +
    + + )} +
    + ); +} diff --git a/components/team/screens/Team.tsx b/components/team/screens/Team.tsx index 13c0bae0..4fbcf49f 100644 --- a/components/team/screens/Team.tsx +++ b/components/team/screens/Team.tsx @@ -2,34 +2,41 @@ import { ArrowRightIcon } from "@heroicons/react/outline"; import { ArrowLeftIcon } from "@heroicons/react/solid"; import classnames from "classnames"; import Link from "next/link"; +import { TeamPageProps } from "pages/team/[slug]"; import React from "react"; +import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; import { useLocale } from "@lib/hooks/useLocale"; import Avatar from "@components/ui/Avatar"; import Button from "@components/ui/Button"; import Text from "@components/ui/Text"; -const Team = ({ team }) => { +type TeamType = TeamPageProps["team"]; +type MembersType = TeamType["members"]; +type MemberType = MembersType[number]; + +const Team = ({ team }: TeamPageProps) => { const { t } = useLocale(); - const Member = ({ member }) => { + const Member = ({ member }: { member: MemberType }) => { const classes = classnames( "group", "relative", "flex flex-col", "space-y-4", "p-4", + "min-w-full sm:min-w-64 sm:max-w-64", "bg-white dark:bg-neutral-900 dark:border-0 dark:bg-opacity-8", "border border-neutral-200", "hover:cursor-pointer", - "hover:border-black dark:border-neutral-700 dark:hover:border-neutral-600", + "hover:border-brand dark:border-neutral-700 dark:hover:border-neutral-600", "rounded-sm", "hover:shadow-md" ); return ( - +
    { />
    - -
    - {member.user.name} - - {member.user.bio} + +
    + {member.name} + + {member.bio || t("user_from_team", { user: member.name, team: team.name })}
    @@ -55,15 +66,15 @@ const Team = ({ team }) => { ); }; - const Members = ({ members }) => { + const Members = ({ members }: { members: MembersType }) => { if (!members || members.length === 0) { return null; } return ( -
    +
    {members.map((member) => { - return member.user.username !== null && ; + return member.username !== null && ; })}
    ); @@ -73,7 +84,7 @@ const Team = ({ team }) => {
    {team.eventTypes.length > 0 && ( -
    -
    +

    {props.title}

    {props.message}
    diff --git a/components/ui/AuthContainer.tsx b/components/ui/AuthContainer.tsx new file mode 100644 index 00000000..253ea3fe --- /dev/null +++ b/components/ui/AuthContainer.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +import Loader from "@components/Loader"; +import { HeadSeo } from "@components/seo/head-seo"; + +interface Props { + title: string; + description: string; + footerText?: React.ReactNode | string; + showLogo?: boolean; + heading?: string; + loading?: boolean; +} + +export default function AuthContainer(props: React.PropsWithChildren) { + return ( +
    + +
    + {props.showLogo && ( + Cal.com Logo + )} + {props.heading && ( +

    {props.heading}

    + )} +
    + {props.loading && ( +
    + +
    + )} +
    +
    + {props.children} +
    +
    {props.footerText}
    +
    +
    + ); +} diff --git a/components/ui/Avatar.tsx b/components/ui/Avatar.tsx index cbaf5f0f..f1a3734c 100644 --- a/components/ui/Avatar.tsx +++ b/components/ui/Avatar.tsx @@ -36,7 +36,7 @@ export default function Avatar(props: AvatarProps) { return title ? ( {avatar} - + {title} diff --git a/components/ui/AvatarGroup.tsx b/components/ui/AvatarGroup.tsx index f4ce1f57..958d116f 100644 --- a/components/ui/AvatarGroup.tsx +++ b/components/ui/AvatarGroup.tsx @@ -12,7 +12,7 @@ export type AvatarGroupProps = { items: { image: string; title?: string; - alt: string; + alt?: string; }[]; className?: string; }; @@ -28,19 +28,23 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) { return (
      - {props.items.slice(0, props.truncateAfter).map((item, idx) => ( -
    • - -
    • - ))} + {props.items.slice(0, props.truncateAfter).map((item, idx) => { + if (item.image != null) { + return ( +
    • + +
    • + ); + } + })} {/*props.items.length > props.truncateAfter && ( -
    • +
    • +1 {truncatedAvatars.length !== 0 && ( - +
        {truncatedAvatars.map((title) => ( diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index c303b28c..7ec90c14 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -54,16 +54,17 @@ export const Button = forwardRef {StartIcon && ( - + )} {props.children} {loading && ( diff --git a/components/ui/Dropdown.tsx b/components/ui/Dropdown.tsx index 7f5dc8f0..c092087d 100644 --- a/components/ui/Dropdown.tsx +++ b/components/ui/Dropdown.tsx @@ -26,7 +26,7 @@ export const DropdownMenuContent = forwardRef {children} diff --git a/components/ui/InfoBadge.tsx b/components/ui/InfoBadge.tsx new file mode 100644 index 00000000..a79a9fd2 --- /dev/null +++ b/components/ui/InfoBadge.tsx @@ -0,0 +1,15 @@ +import { InformationCircleIcon } from "@heroicons/react/solid"; + +import { Tooltip } from "@components/Tooltip"; + +export default function InfoBadge({ content }: { content: string }) { + return ( + <> + + + + + + + ); +} diff --git a/components/ui/LinkIconButton.tsx b/components/ui/LinkIconButton.tsx new file mode 100644 index 00000000..009715ac --- /dev/null +++ b/components/ui/LinkIconButton.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +import { SVGComponent } from "@lib/types/SVGComponent"; + +interface LinkIconButtonProps extends React.ButtonHTMLAttributes { + Icon: SVGComponent; +} + +export default function LinkIconButton(props: LinkIconButtonProps) { + return ( +
        + +
        + ); +} diff --git a/components/ui/ModalContainer.tsx b/components/ui/ModalContainer.tsx new file mode 100644 index 00000000..1fa130f7 --- /dev/null +++ b/components/ui/ModalContainer.tsx @@ -0,0 +1,39 @@ +import classNames from "classnames"; +import React from "react"; + +interface Props extends React.PropsWithChildren { + wide?: boolean; + scroll?: boolean; + noPadding?: boolean; +} + +export default function ModalContainer(props: Props) { + return ( +
        +
        + + +
        + {props.children} +
        +
        +
        + ); +} diff --git a/components/ui/Schedule/Schedule.tsx b/components/ui/Schedule/Schedule.tsx deleted file mode 100644 index 3642c482..00000000 --- a/components/ui/Schedule/Schedule.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import { PlusIcon, TrashIcon } from "@heroicons/react/outline"; -import classnames from "classnames"; -import dayjs, { Dayjs } from "dayjs"; -import React from "react"; - -import { useLocale } from "@lib/hooks/useLocale"; - -import Text from "@components/ui/Text"; - -export const SCHEDULE_FORM_ID = "SCHEDULE_FORM_ID"; -export const toCalendsoAvailabilityFormat = (schedule: Schedule) => { - return schedule; -}; - -export const _24_HOUR_TIME_FORMAT = `HH:mm:ss`; - -const DEFAULT_START_TIME = "09:00:00"; -const DEFAULT_END_TIME = "17:00:00"; - -/** Begin Time Increments For Select */ -const increment = 15; - -/** - * Creates an array of times on a 15 minute interval from - * 00:00:00 (Start of day) to - * 23:45:00 (End of day with enough time for 15 min booking) - */ -const TIMES = (() => { - const starting_time = dayjs().startOf("day"); - const ending_time = dayjs().endOf("day"); - - const times = []; - let t: Dayjs = starting_time; - - while (t.isBefore(ending_time)) { - times.push(t); - t = t.add(increment, "minutes"); - } - return times; -})(); -/** End Time Increments For Select */ - -const DEFAULT_SCHEDULE: Schedule = { - monday: [{ start: "09:00:00", end: "17:00:00" }], - tuesday: [{ start: "09:00:00", end: "17:00:00" }], - wednesday: [{ start: "09:00:00", end: "17:00:00" }], - thursday: [{ start: "09:00:00", end: "17:00:00" }], - friday: [{ start: "09:00:00", end: "17:00:00" }], - saturday: null, - sunday: null, -}; - -type DayOfWeek = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday"; -export type TimeRange = { - start: string; - end: string; -}; - -export type FreeBusyTime = TimeRange[]; - -export type Schedule = { - monday?: FreeBusyTime | null; - tuesday?: FreeBusyTime | null; - wednesday?: FreeBusyTime | null; - thursday?: FreeBusyTime | null; - friday?: FreeBusyTime | null; - saturday?: FreeBusyTime | null; - sunday?: FreeBusyTime | null; -}; - -type ScheduleBlockProps = { - day: DayOfWeek; - ranges?: FreeBusyTime | null; - selected?: boolean; -}; - -type Props = { - schedule?: Schedule; - onChange?: (data: Schedule) => void; - onSubmit: (data: Schedule) => void; -}; - -const SchedulerForm = ({ schedule = DEFAULT_SCHEDULE, onSubmit }: Props) => { - const { t } = useLocale(); - const ref = React.useRef(null); - - const transformElementsToSchedule = (elements: HTMLFormControlsCollection): Schedule => { - const schedule: Schedule = {}; - const formElements = Array.from(elements) - .map((element) => { - return element.id; - }) - .filter((value) => value); - - /** - * elementId either {day} or {day.N.start} or {day.N.end} - * If elementId in DAYS_ARRAY add elementId to scheduleObj - * then element is the checkbox and can be ignored - * - * If elementId starts with a day in DAYS_ARRAY - * the elementId should be split by "." resulting in array length 3 - * [day, rangeIndex, "start" | "end"] - */ - formElements.forEach((elementId) => { - const [day, rangeIndex, rangeId] = elementId.split("."); - if (rangeIndex && rangeId) { - if (!schedule[day]) { - schedule[day] = []; - } - - if (!schedule[day][parseInt(rangeIndex)]) { - schedule[day][parseInt(rangeIndex)] = {}; - } - - schedule[day][parseInt(rangeIndex)][rangeId] = elements[elementId].value; - } - }); - - return schedule; - }; - - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - const elements = ref.current?.elements; - if (elements) { - const schedule = transformElementsToSchedule(elements); - onSubmit && typeof onSubmit === "function" && onSubmit(schedule); - } - }; - - const ScheduleBlock = ({ day, ranges: defaultRanges, selected: defaultSelected }: ScheduleBlockProps) => { - const [ranges, setRanges] = React.useState(defaultRanges); - const [selected, setSelected] = React.useState(defaultSelected); - React.useEffect(() => { - if (!ranges || ranges.length === 0) { - setSelected(false); - } else { - setSelected(true); - } - }, [ranges]); - - const handleSelectedChange = () => { - if (!selected && (!ranges || ranges.length === 0)) { - setRanges([ - { - start: "09:00:00", - end: "17:00:00", - }, - ]); - } - setSelected(!selected); - }; - - const handleAddRange = () => { - let rangeToAdd; - if (!ranges || ranges?.length === 0) { - rangeToAdd = { - start: DEFAULT_START_TIME, - end: DEFAULT_END_TIME, - }; - setRanges([rangeToAdd]); - } else { - const lastRange = ranges[ranges.length - 1]; - - const [hour, minute, second] = lastRange.end.split(":"); - const date = dayjs() - .set("hour", parseInt(hour)) - .set("minute", parseInt(minute)) - .set("second", parseInt(second)); - const nextStartTime = date.add(1, "hour"); - const nextEndTime = date.add(2, "hour"); - - /** - * If next range goes over into "tomorrow" - * i.e. time greater that last value in Times - * return - */ - if (nextStartTime.isAfter(date.endOf("day"))) { - return; - } - - rangeToAdd = { - start: nextStartTime.format(_24_HOUR_TIME_FORMAT), - end: nextEndTime.format(_24_HOUR_TIME_FORMAT), - }; - setRanges([...ranges, rangeToAdd]); - } - }; - - const handleDeleteRange = (range: TimeRange) => { - if (ranges && ranges.length > 0) { - setRanges( - ranges.filter((r: TimeRange) => { - return r.start != range.start; - }) - ); - } - }; - - /** - * Should update ranges values - */ - const handleSelectRangeChange = (event: React.ChangeEvent) => { - const [day, rangeIndex, rangeId] = event.currentTarget.name.split("."); - - if (day && ranges) { - const newRanges = ranges.map((range, index) => { - const newRange = { - ...range, - [rangeId]: event.currentTarget.value, - }; - return index === parseInt(rangeIndex) ? newRange : range; - }); - - setRanges(newRanges); - } - }; - - const TimeRangeField = ({ range, day, index }: { range: TimeRange; day: DayOfWeek; index: number }) => { - const timeOptions = (type: "start" | "end") => - TIMES.map((time) => ( - - )); - return ( -
        -
        - - - - -
        -
        - -
        -
        - ); - }; - - const Actions = () => { - return ( -
        - -
        - ); - }; - - const DeleteAction = ({ range }: { range: TimeRange }) => { - return ( - - ); - }; - - return ( -
        -
        1 ? "sm:items-start" : "sm:items-center" - )}> -
        -
        - - {day} -
        -
        - -
        -
        - -
        - {selected && ranges && ranges.length != 0 ? ( - ranges.map((range, index) => ( - - )) - ) : ( - - {t("unavailable")} - - )} -
        - -
        - -
        -
        -
        - ); - }; - - return ( - <> -
        - {Object.keys(schedule).map((day) => { - const selected = schedule[day as DayOfWeek] != null; - return ( - - ); - })} - - - ); -}; - -export default SchedulerForm; diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx index 10a1f750..d773c42b 100644 --- a/components/ui/Scheduler.tsx +++ b/components/ui/Scheduler.tsx @@ -4,57 +4,50 @@ import dayjs from "dayjs"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import React, { useEffect, useState } from "react"; -import TimezoneSelect from "react-timezone-select"; +import TimezoneSelect, { ITimezoneOption } from "react-timezone-select"; import { useLocale } from "@lib/hooks/useLocale"; +import Button from "@components/ui/Button"; + import { WeekdaySelect } from "./WeekdaySelect"; import SetTimesModal from "./modal/SetTimesModal"; dayjs.extend(utc); dayjs.extend(timezone); +type AvailabilityInput = Pick; + type Props = { timeZone: string; availability: Availability[]; - setTimeZone: unknown; + setTimeZone: (timeZone: string) => void; + setAvailability: (schedule: { + openingHours: AvailabilityInput[]; + dateOverrides: AvailabilityInput[]; + }) => void; }; -export const Scheduler = ({ - availability, - setAvailability, - timeZone: selectedTimeZone, - setTimeZone, -}: Props) => { - const { t } = useLocale(); +/** + * @deprecated + */ +export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone }: Props) => { + const { t, i18n } = useLocale(); const [editSchedule, setEditSchedule] = useState(-1); - const [dateOverrides, setDateOverrides] = useState([]); - const [openingHours, setOpeningHours] = useState([]); + const [openingHours, setOpeningHours] = useState([]); useEffect(() => { - setOpeningHours( - availability - .filter((item: Availability) => item.days.length !== 0) - .map((item) => { - item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes"); - item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes"); - return item; - }) - ); - setDateOverrides(availability.filter((item: Availability) => item.date)); + setOpeningHours(availability.filter((item: Availability) => item.days.length !== 0)); }, []); - // updates availability to how it should be formatted outside this component. useEffect(() => { - setAvailability({ - dateOverrides: dateOverrides, - openingHours: openingHours, - }); - }, [dateOverrides, openingHours]); + setAvailability({ openingHours, dateOverrides: [] }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openingHours]); const addNewSchedule = () => setEditSchedule(openingHours.length); - const applyEditSchedule = (changed) => { + const applyEditSchedule = (changed: Availability) => { // new entry if (!changed.days) { changed.days = [1, 2, 3, 4, 5]; // Mon - Fri @@ -63,39 +56,33 @@ export const Scheduler = ({ // update const replaceWith = { ...openingHours[editSchedule], ...changed }; openingHours.splice(editSchedule, 1, replaceWith); - setOpeningHours([].concat(openingHours)); + setOpeningHours([...openingHours]); } }; const removeScheduleAt = (toRemove: number) => { openingHours.splice(toRemove, 1); - setOpeningHours([].concat(openingHours)); + setOpeningHours([...openingHours]); }; - const OpeningHours = ({ idx, item }) => ( -
      • + const OpeningHours = ({ idx, item }: { idx: number; item: Availability }) => ( +
      • (item.days = selected)} />
      • ); @@ -111,9 +98,9 @@ export const Scheduler = ({
        setTimeZone(tz.value)} - className="shadow-sm focus:ring-black focus:border-black mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + value={timeZone} + onChange={(tz: ITimezoneOption) => setTimeZone(tz.value)} + className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm" />
    @@ -122,16 +109,36 @@ export const Scheduler = ({ ))} - +
    {editSchedule >= 0 && ( applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })} + startTime={ + openingHours[editSchedule] + ? new Date(openingHours[editSchedule].startTime).getHours() * 60 + + new Date(openingHours[editSchedule].startTime).getMinutes() + : 540 + } + endTime={ + openingHours[editSchedule] + ? new Date(openingHours[editSchedule].endTime).getHours() * 60 + + new Date(openingHours[editSchedule].endTime).getMinutes() + : 1020 + } + onChange={(times: { startTime: number; endTime: number }) => + applyEditSchedule({ + ...(openingHours[editSchedule] || {}), + startTime: new Date( + new Date().setHours(Math.floor(times.startTime / 60), times.startTime % 60, 0, 0) + ), + endTime: new Date( + new Date().setHours(Math.floor(times.endTime / 60), times.endTime % 60, 0, 0) + ), + }) + } onExit={() => setEditSchedule(-1)} /> )} diff --git a/components/ui/SettingInputContainer.tsx b/components/ui/SettingInputContainer.tsx new file mode 100644 index 00000000..9cd938dc --- /dev/null +++ b/components/ui/SettingInputContainer.tsx @@ -0,0 +1,25 @@ +export default function SettingInputContainer({ + Input, + Icon, + label, + htmlFor, +}: { + Input: React.ReactNode; + Icon: (props: React.SVGProps) => JSX.Element; + label: string; + htmlFor?: string; +}) { + return ( +
    +
    +
    + +
    +
    {Input}
    +
    +
    + ); +} diff --git a/components/ui/UsernameInput.tsx b/components/ui/UsernameInput.tsx deleted file mode 100644 index cd3b615f..00000000 --- a/components/ui/UsernameInput.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; - -interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> { - label?: string; -} - -const UsernameInput = React.forwardRef((props, ref) => ( - // todo, check if username is already taken here? -
    - -
    - - {process.env.NEXT_PUBLIC_APP_URL}/{props.label && "team/"} - - -
    -
    -)); - -UsernameInput.displayName = "UsernameInput"; - -export { UsernameInput }; diff --git a/components/ui/WeekdaySelect.tsx b/components/ui/WeekdaySelect.tsx index 83fdddfc..37684bd6 100644 --- a/components/ui/WeekdaySelect.tsx +++ b/components/ui/WeekdaySelect.tsx @@ -34,7 +34,7 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => { }} className={` w-10 h-10 - bg-black text-white focus:outline-none px-3 py-1 rounded + bg-brand text-brandcontrast focus:outline-none px-3 py-1 rounded ${activeDays[idx + 1] ? "rounded-r-none" : ""} ${activeDays[idx - 1] ? "rounded-l-none" : ""} ${idx === 0 ? "rounded-l" : ""} diff --git a/components/ui/form/CheckboxField.tsx b/components/ui/form/CheckboxField.tsx index 1e687c68..fcb66f15 100644 --- a/components/ui/form/CheckboxField.tsx +++ b/components/ui/form/CheckboxField.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, InputHTMLAttributes } from "react"; type Props = InputHTMLAttributes & { - label: string; + label: React.ReactNode; description: string; }; diff --git a/components/ui/form/DatePicker.tsx b/components/ui/form/DatePicker.tsx new file mode 100644 index 00000000..f785cbcf --- /dev/null +++ b/components/ui/form/DatePicker.tsx @@ -0,0 +1,28 @@ +import { CalendarIcon } from "@heroicons/react/solid"; +import React from "react"; +import "react-calendar/dist/Calendar.css"; +import "react-date-picker/dist/DatePicker.css"; +import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle"; + +import classNames from "@lib/classNames"; + +type Props = { + date: Date; + onDatesChange?: ((date: Date) => void) | undefined; + className?: string; +}; + +export const DatePicker = ({ date, onDatesChange, className }: Props) => { + return ( + } + value={date} + onChange={onDatesChange} + /> + ); +}; diff --git a/components/ui/form/MinutesField.tsx b/components/ui/form/MinutesField.tsx index 50cd3caf..20cded63 100644 --- a/components/ui/form/MinutesField.tsx +++ b/components/ui/form/MinutesField.tsx @@ -1,24 +1,30 @@ +import classNames from "classnames"; import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react"; type Props = InputHTMLAttributes & { - label: ReactNode; + label?: ReactNode; }; const MinutesField = forwardRef(({ label, ...rest }, ref) => { return (
    -
    - -
    + {!!label && ( +
    + +
    + )}
    diff --git a/components/ui/form/PhoneInput.tsx b/components/ui/form/PhoneInput.tsx index 4c3c00a4..29c9600a 100644 --- a/components/ui/form/PhoneInput.tsx +++ b/components/ui/form/PhoneInput.tsx @@ -1,14 +1,15 @@ import React from "react"; -import { default as BasePhoneInput, PhoneInputProps } from "react-phone-number-input"; +import BasePhoneInput, { Props as PhoneInputProps } from "react-phone-number-input"; import "react-phone-number-input/style.css"; import classNames from "@lib/classNames"; +import { Optional } from "@lib/types/utils"; -export const PhoneInput = (props: PhoneInputProps) => ( +export const PhoneInput = (props: Optional) => ( { diff --git a/components/ui/form/Schedule.tsx b/components/ui/form/Schedule.tsx new file mode 100644 index 00000000..f30198da --- /dev/null +++ b/components/ui/form/Schedule.tsx @@ -0,0 +1,178 @@ +import { PlusIcon, TrashIcon } from "@heroicons/react/outline"; +import dayjs, { Dayjs, ConfigType } from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import React, { useCallback, useState } from "react"; +import { Controller, useFieldArray } from "react-hook-form"; + +import { defaultDayRange } from "@lib/availability"; +import { weekdayNames } from "@lib/core/i18n/weekday"; +import { useLocale } from "@lib/hooks/useLocale"; +import { TimeRange } from "@lib/types/schedule"; + +import Button from "@components/ui/Button"; +import Select from "@components/ui/form/Select"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +/** Begin Time Increments For Select */ +const increment = 15; +/** + * Creates an array of times on a 15 minute interval from + * 00:00:00 (Start of day) to + * 23:45:00 (End of day with enough time for 15 min booking) + */ +const TIMES = (() => { + const end = dayjs().endOf("day"); + let t: Dayjs = dayjs().startOf("day"); + + const times = []; + while (t.isBefore(end)) { + times.push(t); + t = t.add(increment, "minutes"); + } + return times; +})(); +/** End Time Increments For Select */ + +type Option = { + readonly label: string; + readonly value: number; +}; + +type TimeRangeFieldProps = { + name: string; +}; + +const TimeRangeField = ({ name }: TimeRangeFieldProps) => { + // Lazy-loaded options, otherwise adding a field has a noticable redraw delay. + const [options, setOptions] = useState([]); + // const { i18n } = useLocale(); + const getOption = (time: ConfigType) => ({ + value: dayjs(time).toDate().valueOf(), + label: dayjs(time).utc().format("HH:mm"), + // .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }), + }); + + const timeOptions = useCallback((offsetOrLimit: { offset?: number; limit?: number } = {}) => { + const { limit, offset } = offsetOrLimit; + return TIMES.filter((time) => (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset))).map( + (t) => getOption(t) + ); + }, []); + + return ( + <> + ( + setOptions(timeOptions())} + onBlur={() => setOptions([])} + defaultValue={getOption(value)} + onChange={(option) => onChange(new Date(option?.value as number))} + /> + )} + /> + + ); +}; + +type ScheduleBlockProps = { + day: number; + weekday: string; + name: string; +}; + +const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => { + const { t } = useLocale(); + const { fields, append, remove, replace } = useFieldArray({ + name: `${name}.${day}`, + }); + + const handleAppend = () => { + // FIXME: Fix type-inference, can't get this to work. @see https://github.com/react-hook-form/react-hook-form/issues/4499 + const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end); + const nextRangeEnd = dayjs(nextRangeStart).add(1, "hour"); + + if (nextRangeEnd.isBefore(nextRangeStart.endOf("day"))) { + return append({ + start: nextRangeStart.toDate(), + end: nextRangeEnd.toDate(), + }); + } + }; + + return ( +
    +
    + +
    +
    + {fields.map((field, index) => ( +
    +
    + +
    +
    + ))} + {!fields.length && t("no_availability")} +
    +
    +
    +
    + ); +}; + +const Schedule = ({ name }: { name: string }) => { + const { i18n } = useLocale(); + return ( +
    + {weekdayNames(i18n.language).map((weekday, num) => ( + + ))} +
    + ); +}; + +export default Schedule; diff --git a/components/ui/form/Select.tsx b/components/ui/form/Select.tsx index a7f768df..c5fa0fa1 100644 --- a/components/ui/form/Select.tsx +++ b/components/ui/form/Select.tsx @@ -1,29 +1,33 @@ -import React, { PropsWithChildren } from "react"; -import Select, { components, NamedProps } from "react-select"; +import React from "react"; +import ReactSelect, { components, GroupBase, Props } from "react-select"; import classNames from "@lib/classNames"; -export const SelectComp = (props: PropsWithChildren) => ( - -
    +
    -
    -
    -
    - +
    +
    +
    +
    -
    @@ -59,7 +61,7 @@ export default function SetTimesModal(props) {
    - +
    - : + :
    - +
    - : + :
    - - + +
    diff --git a/docker-compose.yml b/docker-compose.yml index 55d88a66..7e091126 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: volumes: - db_data:/var/lib/postgresql/data environment: + POSTGRES_DB: "cal-saml" POSTGRES_PASSWORD: "" POSTGRES_HOST_AUTH_METHOD: trust volumes: diff --git a/docs/saml-setup.md b/docs/saml-setup.md new file mode 100644 index 00000000..29792aec --- /dev/null +++ b/docs/saml-setup.md @@ -0,0 +1,27 @@ +# SAML Registration with Identity Providers + +This guide explains the settings you’d need to use to configure SAML with your Identity Provider. Once this is set up you should get an XML metadata file that should then be uploaded on your Cal.com self-hosted instance. + +> **Note:** Please do not add a trailing slash at the end of the URLs. Create them exactly as shown below. + +**Assertion consumer service URL / Single Sign-On URL / Destination URL:** [http://localhost:3000/api/auth/saml/callback](http://localhost:3000/api/auth/saml/callback) [Replace this with the URL for your self-hosted Cal instance] + +**Entity ID / Identifier / Audience URI / Audience Restriction:** https://saml.cal.com + +**Response:** Signed + +**Assertion Signature:** Signed + +**Signature Algorithm:** RSA-SHA256 + +**Assertion Encryption:** Unencrypted + +**Mapping Attributes / Attribute Statements:** + +id -> user.id + +email -> user.email + +firstName -> user.firstName + +lastName -> user.lastName diff --git a/ee/LICENSE b/ee/LICENSE index f63f1ebe..60f0a2a1 100644 --- a/ee/LICENSE +++ b/ee/LICENSE @@ -23,10 +23,10 @@ Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, s and/or sell the Software. This EE License applies only to the part of this Software that is not distributed under -the MIT license. Any part of this Software distributed under the MIT license or which +the AGPLv3 license. Any part of this Software distributed under the MIT license or which is served client-side as an image, font, cascading stylesheet (CSS), file which produces or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or -in part, is copyrighted under the MIT license. The full text of this EE License shall +in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR @@ -39,4 +39,4 @@ SOFTWARE. For all third party components incorporated into the Cal.com Software, those components are licensed under the original license provided by the owner of the -applicable component. \ No newline at end of file +applicable component. diff --git a/ee/README.md b/ee/README.md index b67429ee..f2f9aa48 100644 --- a/ee/README.md +++ b/ee/README.md @@ -25,3 +25,14 @@ The [/ee](https://github.com/calendso/calendso/tree/main/ee) subfolder is the pl 6. Open [Stripe Webhooks](https://dashboard.stripe.com/webhooks) and add `/api/integrations/stripepayment/webhook` as webhook for connected applications. 7. Select all `payment_intent` events for the webhook. 8. Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the .env file. + +## Setting up SAML login + +1. Set SAML_DATABASE_URL to a postgres database. Please use a different database than the main Cal instance since the migrations are separate for this database. For example `postgresql://postgres:@localhost:5450/cal-saml` +2. Set SAML_ADMINS to a comma separated list of admin emails from where the SAML metadata can be uploaded and configured. +3. Create a SAML application with your Identity Provider (IdP) using the instructions here - [SAML Setup](../docs/saml-setup.md) +4. Remember to configure access to the IdP SAML app for all your users (who need access to Cal). +5. You will need the XML metadata from your IdP later, so keep it accessible. +6. Log in to one of the admin accounts configured in SAML_ADMINS and then navigate to Settings -> Security. +7. You should see a SAML configuration section, copy and paste the XML metadata from step 5 and click on Save. +8. Your provisioned users can now log into Cal using SAML. diff --git a/ee/components/TrialBanner.tsx b/ee/components/TrialBanner.tsx new file mode 100644 index 00000000..eedb0cb5 --- /dev/null +++ b/ee/components/TrialBanner.tsx @@ -0,0 +1,35 @@ +import dayjs from "dayjs"; + +import { TRIAL_LIMIT_DAYS } from "@lib/config/constants"; +import { useLocale } from "@lib/hooks/useLocale"; + +import { useMeQuery } from "@components/Shell"; +import Button from "@components/ui/Button"; + +const TrialBanner = () => { + const { t } = useLocale(); + const query = useMeQuery(); + const user = query.data; + + if (!user || user.plan !== "TRIAL") return null; + + const trialDaysLeft = dayjs(user.createdDate) + .add(TRIAL_LIMIT_DAYS + 1, "day") + .diff(dayjs(), "day"); + + return ( +
    +
    {t("trial_days_left", { days: trialDaysLeft })}
    + +
    + ); +}; + +export default TrialBanner; diff --git a/ee/components/saml/Configuration.tsx b/ee/components/saml/Configuration.tsx new file mode 100644 index 00000000..9482199b --- /dev/null +++ b/ee/components/saml/Configuration.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState, useRef } from "react"; + +import { useLocale } from "@lib/hooks/useLocale"; +import showToast from "@lib/notification"; +import { trpc } from "@lib/trpc"; + +import { Dialog, DialogTrigger } from "@components/Dialog"; +import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; +import { Alert } from "@components/ui/Alert"; +import Badge from "@components/ui/Badge"; +import Button from "@components/ui/Button"; + +export default function SAMLConfiguration({ + teamsView, + teamId, +}: { + teamsView: boolean; + teamId: null | undefined | number; +}) { + const [isSAMLLoginEnabled, setIsSAMLLoginEnabled] = useState(false); + const [samlConfig, setSAMLConfig] = useState(null); + + const query = trpc.useQuery(["viewer.showSAMLView", { teamsView, teamId }]); + + useEffect(() => { + const data = query.data; + setIsSAMLLoginEnabled(data?.isSAMLLoginEnabled ?? false); + setSAMLConfig(data?.provider ?? null); + }, [query.data]); + + const mutation = trpc.useMutation("viewer.updateSAMLConfig", { + onSuccess: (data: { provider: string | undefined }) => { + showToast(t("saml_config_updated_successfully"), "success"); + setHasErrors(false); // dismiss any open errors + setSAMLConfig(data?.provider ?? null); + samlConfigRef.current.value = ""; + }, + onError: () => { + setHasErrors(true); + setErrorMessage(t("saml_configuration_update_failed")); + document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); + }, + }); + + const deleteMutation = trpc.useMutation("viewer.deleteSAMLConfig", { + onSuccess: () => { + showToast(t("saml_config_deleted_successfully"), "success"); + setHasErrors(false); // dismiss any open errors + setSAMLConfig(null); + samlConfigRef.current.value = ""; + }, + onError: () => { + setHasErrors(true); + setErrorMessage(t("saml_configuration_delete_failed")); + document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); + }, + }); + + const samlConfigRef = useRef() as React.MutableRefObject; + + const [hasErrors, setHasErrors] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + async function updateSAMLConfigHandler(event: React.FormEvent) { + event.preventDefault(); + + const rawMetadata = samlConfigRef.current.value; + + mutation.mutate({ + rawMetadata: rawMetadata, + teamId, + }); + } + + async function deleteSAMLConfigHandler(event: React.MouseEvent) { + event.preventDefault(); + + deleteMutation.mutate({ + teamId, + }); + } + + const { t } = useLocale(); + return ( + <> +
    + + {isSAMLLoginEnabled ? ( + <> +
    +

    + {t("saml_configuration")} + + {samlConfig ? t("enabled") : t("disabled")} + + {samlConfig ? ( + <> + + {samlConfig ? samlConfig : ""} + + + ) : null} +

    +
    + + {samlConfig ? ( +
    + + + + + + {t("delete_saml_configuration_confirmation_message")} + + +
    + ) : ( +

    {!samlConfig ? t("saml_not_configured_yet") : ""}

    + )} + +

    {t("saml_configuration_description")}

    + +
    + {hasErrors && } + +
    @@ -585,10 +615,22 @@ const EventTypePage = (props: inferSSRProps) => { {t("scheduling_type")}
    - ( + { + // FIXME + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + formMethods.setValue("schedulingType", val); + }} + /> + )} />
    @@ -599,363 +641,505 @@ const EventTypePage = (props: inferSSRProps) => {
    - setUsers(options.map((option) => option.value))} - defaultValue={eventType.users.map(mapUserToValue)} - options={teamMembers.map(mapUserToValue)} - id="users" - placeholder={t("add_attendees")} + user.id.toString())} + render={() => ( + { + formMethods.setValue( + "users", + options.map((user) => user.value) + ); + }} + defaultValue={eventType.users.map(mapUserToValue)} + options={teamMembers.map(mapUserToValue)} + placeholder={t("add_attendees")} + /> + )} />
    )} - - - {({ open }) => ( - <> -
    setAdvancedSettingsVisible(!advancedSettingsVisible)}> - - - - {t("show_advanced_settings")} - - -
    - + setAdvancedSettingsVisible(!advancedSettingsVisible)}> + <> + + + + {t("show_advanced_settings")} + + + + {/** + * Only display calendar selector if user has connected calendars AND if it's not + * a team event. Since we don't have logic to handle each attende calendar (for now). + * This will fallback to each user selected destination calendar. + */} + {!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
    - ( + + )} />
    -
    -
    - + )} +
    +
    + +
    +
    +
    +
    -
    -
      - {customInputs.map((customInput: EventTypeCustomInput, idx: number) => ( -
    • -
      -
      +
      +
      +
      +
      + +
      +
      +
        + {customInputs.map((customInput: EventTypeCustomInput, idx: number) => ( +
      • +
        +
        +
        + + {t("label")}: {customInput.label} + +
        + {customInput.placeholder && (
        - {t("label")}: {customInput.label} - -
        - {customInput.placeholder && ( -
        - - {t("placeholder")}: {customInput.placeholder} - -
        - )} -
        - - {t("type")}: {customInput.type} - -
        -
        - - {customInput.required ? t("required") : t("optional")} + title={`${t("placeholder")}: ${customInput.placeholder}`}> + {t("placeholder")}: {customInput.placeholder}
        + )} +
        + + {t("type")}: {customInput.type} +
        -
        - - +
        + + {customInput.required ? t("required") : t("optional")} +
        -
      • - ))} -
      • - +
        + + +
        +
    • -
    -
    + ))} +
  • + +
  • +
    +
    - - - + ( + { + formMethods.setValue("requiresConfirmation", e?.target.checked); + }} + /> + )} + /> -
    + ( + { + formMethods.setValue("disableGuests", e?.target.checked); + }} + /> + )} + /> - +
    + ( + { + formMethods.setValue("minimumBookingNotice", Number(e.target.value)); + }} + /> + )} + /> -
    -
    - +
    +
    + +
    +
    +
    + { + const slotIntervalOptions = [ + { + label: t("slot_interval_default"), + value: -1, + }, + ...[5, 10, 15, 20, 30, 45, 60].map((minutes) => ({ + label: minutes + " " + t("minutes"), + value: minutes, + })), + ]; + return ( + + +
    + )} + {period.type === "RANGE" && ( +
    + ( + { + formMethods.setValue("periodDates", { startDate, endDate }); + }} + /> )} - aria-hidden="true"> - -
    -
    - - {period.prefix ? {period.prefix}  : null} - {period.type === "rolling" && ( -
    - - -
    - )} - - {checked && period.type === "range" && ( -
    - -
    - )} - {period.suffix ?  {period.suffix} : null} -
    -
    - + /> +
    )} - + {period.suffix ? ( +  {period.suffix} + ) : null} +
    ))} -
    - -
    + + )} + />
    +
    -
    +
    -
    -
    - -
    -
    - -
    +
    +
    + +
    +
    + ( + { + formMethods.setValue("availability", { + openingHours: val.openingHours, + dateOverrides: val.dateOverrides, + }); + }} + setTimeZone={(timeZone) => { + formMethods.setValue("timeZone", timeZone); + setSelectedTimeZone(timeZone); + }} + timeZone={selectedTimeZone} + availability={availability.map((schedule) => ({ + ...schedule, + startTime: new Date(schedule.startTime), + endTime: new Date(schedule.endTime), + }))} + /> + )} + />
    +
    - {hasPaymentIntegration && ( - <> -
    -
    -
    - -
    + {hasPaymentIntegration && ( + <> +
    +
    +
    + +
    -
    -
    -
    -
    -
    -
    - setRequirePayment(event.target.checked)} - id="requirePayment" - name="requirePayment" - type="checkbox" - className="w-4 h-4 border-gray-300 rounded focus:ring-primary-500 text-primary-600" - defaultChecked={requirePayment} - /> -
    -
    -

    - {t("require_payment")} (0.5% +{" "} - - - {" "} - {t("commission_per_transaction")}) -

    -
    +
    +
    +
    +
    +
    +
    + { + setRequirePayment(event.target.checked); + if (!event.target.checked) { + formMethods.setValue("price", 0); + } + }} + id="requirePayment" + name="requirePayment" + type="checkbox" + className="w-4 h-4 border-gray-300 rounded focus:ring-primary-500 text-primary-600" + defaultChecked={requirePayment} + /> +
    +
    +

    + {t("require_payment")} (0.5% +{" "} + + + {" "} + {t("commission_per_transaction")}) +

    - {requirePayment && ( -
    -
    -
    -
    - 0 ? eventType.price / 100.0 : undefined - } - /> -
    - - {new Intl.NumberFormat("en", { - style: "currency", - currency: currency, - maximumSignificantDigits: 1, - maximumFractionDigits: 0, - }) - .format(0) - .replace("0", "")} - -
    +
    + {requirePayment && ( +
    +
    +
    +
    + ( + { + field.onChange(e.target.valueAsNumber * 100); + }} + value={field.value > 0 ? field.value / 100 : 0} + /> + )} + /> +
    + + {new Intl.NumberFormat("en", { + style: "currency", + currency: currency, + maximumSignificantDigits: 1, + maximumFractionDigits: 0, + }) + .format(0) + .replace("0", "")} +
    - )} -
    +
    + )}
    - - )} - - - )} - +
    + + )} + + + {/* )} */} +
    - +
    - +
    -
    +
    - ( + { + formMethods.setValue("hidden", isChecked); + }} + label={t("hide_event_type")} + /> + )} />
    @@ -963,7 +1147,7 @@ const EventTypePage = (props: inferSSRProps) => { href={permalink} target="_blank" rel="noreferrer" - className="flex inline-flex items-center px-2 py-1 text-sm font-medium rounded-sm text-md text-neutral-700 hover:text-gray-900 hover:bg-gray-200"> + className="inline-flex items-center px-2 py-1 text-sm font-medium rounded-sm text-md text-neutral-700 hover:text-gray-900 hover:bg-gray-200">
    - {showLocationModal && ( -
    -
    - - - - -
    -
    -
    - -
    -
    - -
    -
    -
    - { + if (val) { + locationFormMethods.setValue("locationType", val.value); + setSelectedLocation(val); + } + }} + /> + )} + /> + +
    + + +
    +
    + a.id - b.id) || []} + render={() => ( + + +
    +
    +
    + +
    +
    + +
    +

    + {t("this_input_will_shown_booking_this_event")} +

    +
    +
    +
    + { + const customInput: EventTypeCustomInput = { + id: -1, + eventTypeId: -1, + label: values.label, + placeholder: values.placeholder, + required: values.required, + type: values.type, + }; + + if (selectedCustomInput) { + selectedCustomInput.label = customInput.label; + selectedCustomInput.placeholder = customInput.placeholder; + selectedCustomInput.required = customInput.required; + selectedCustomInput.type = customInput.type; + } else { + setCustomInputs(customInputs.concat(customInput)); + formMethods.setValue( + "customInputs", + formMethods.getValues("customInputs").concat(customInput) + ); + } + setSelectedCustomInputModalOpen(false); + }} + onCancel={() => { + setSelectedCustomInputModalOpen(false); + }} + /> +
    +
    +
    + )} + />
    ); @@ -1166,6 +1391,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => requiresConfirmation: true, disableGuests: true, minimumBookingNotice: true, + slotInterval: true, team: { select: { slug: true, @@ -1188,6 +1414,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => userId: true, price: true, currency: true, + destinationCalendar: true, }, }); @@ -1251,7 +1478,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => } type Availability = typeof eventType["availability"]; - const getAvailability = (availability: Availability) => (availability?.length ? availability : null); + const getAvailability = (availability: Availability) => + availability?.length + ? availability.map((schedule) => ({ + ...schedule, + startTime: new Date(new Date().toDateString() + " " + schedule.startTime.toTimeString()).valueOf(), + endTime: new Date(new Date().toDateString() + " " + schedule.endTime.toTimeString()).valueOf(), + })) + : null; const availability = getAvailability(eventType.availability) || []; availability.sort((a, b) => a.startTime - b.startTime); @@ -1259,6 +1493,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const eventTypeObject = Object.assign({}, eventType, { periodStartDate: eventType.periodStartDate?.toString() ?? null, periodEndDate: eventType.periodEndDate?.toString() ?? null, + availability, }); const teamMembers = eventTypeObject.team diff --git a/pages/event-types/index.tsx b/pages/event-types/index.tsx index d57da4f0..40439e9e 100644 --- a/pages/event-types/index.tsx +++ b/pages/event-types/index.tsx @@ -1,42 +1,32 @@ // TODO: replace headlessui with radix-ui import { Menu, Transition } from "@headlessui/react"; -import { UsersIcon } from "@heroicons/react/solid"; -import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid"; -import { DotsHorizontalIcon, ExternalLinkIcon, LinkIcon } from "@heroicons/react/solid"; -import { SchedulingType } from "@prisma/client"; +import { + ArrowDownIcon, + ArrowUpIcon, + DotsHorizontalIcon, + ExternalLinkIcon, + LinkIcon, + UsersIcon, +} from "@heroicons/react/solid"; +import { Trans } from "next-i18next"; import Head from "next/head"; import Link from "next/link"; -import { useRouter } from "next/router"; -import React, { Fragment, useRef } from "react"; -import { useMutation } from "react-query"; +import React, { Fragment, useEffect, useState } from "react"; import { QueryCell } from "@lib/QueryCell"; import classNames from "@lib/classNames"; -import { HttpError } from "@lib/core/http/error"; import { useLocale } from "@lib/hooks/useLocale"; -import { useToggleQuery } from "@lib/hooks/useToggleQuery"; -import createEventType from "@lib/mutations/event-types/create-event-type"; import showToast from "@lib/notification"; import { inferQueryOutput, trpc } from "@lib/trpc"; -import { CreateEventType } from "@lib/types/event-type"; -import { Dialog, DialogClose, DialogContent } from "@components/Dialog"; import Shell from "@components/Shell"; import { Tooltip } from "@components/Tooltip"; +import CreateEventTypeButton from "@components/eventtype/CreateEventType"; import EventTypeDescription from "@components/eventtype/EventTypeDescription"; import { Alert } from "@components/ui/Alert"; import Avatar from "@components/ui/Avatar"; import AvatarGroup from "@components/ui/AvatarGroup"; import Badge from "@components/ui/Badge"; -import { Button } from "@components/ui/Button"; -import Dropdown, { - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@components/ui/Dropdown"; -import * as RadioArea from "@components/ui/form/radio-area"; import UserCalendarIllustration from "@components/ui/svg/UserCalendarIllustration"; type Profiles = inferQueryOutput<"viewer.eventTypes">["profiles"]; @@ -57,7 +47,7 @@ const CreateFirstEventTypeView = ({ canAddEvents, profiles }: CreateEventTypePro

    {t("new_event_type_heading")}

    {t("new_event_type_description")}

    - +
    ); @@ -72,10 +62,40 @@ interface EventTypeListProps { } const EventTypeList = ({ readOnly, types, profile }: EventTypeListProps): JSX.Element => { const { t } = useLocale(); + + const utils = trpc.useContext(); + const mutation = trpc.useMutation("viewer.eventTypeOrder", { + onError: (err) => { + console.error(err.message); + }, + async onSettled() { + await utils.cancelQuery(["viewer.eventTypes"]); + await utils.invalidateQueries(["viewer.eventTypes"]); + }, + }); + const [sortableTypes, setSortableTypes] = useState(types); + useEffect(() => { + setSortableTypes(types); + }, [types]); + function moveEventType(index: number, increment: 1 | -1) { + const newList = [...sortableTypes]; + + const type = sortableTypes[index]; + const tmp = sortableTypes[index + increment]; + if (tmp) { + newList[index] = tmp; + newList[index + increment] = type; + } + setSortableTypes(newList); + mutation.mutate({ + ids: newList.map((type) => type.id), + }); + } + return (
      - {types.map((type) => ( + {sortableTypes.map((type, index) => (
    • -
      +
      + {sortableTypes.length > 1 && ( + <> + + + + + )}
      - {type.title} + {type.title} + {`/${profile.slug}/${type.slug}`} {type.hidden && ( {t("hidden")} @@ -126,7 +162,7 @@ const EventTypeList = ({ readOnly, types, profile }: EventTypeListProps): JSX.El href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`} target="_blank" rel="noreferrer" - className="btn-icon"> + className="appearance-none btn-icon"> @@ -275,7 +311,7 @@ const EventTypesPage = () => { return (
      - {t("event_types_page_title")}| cal.gatego.io + Home | Cal.gatego.io { CTA={ query.data && query.data.eventTypeGroups.length !== 0 && ( - + ) }> ( <> - {data.user.plan === "FREE" && !data.canAddEvents && ( + {data.viewer.plan === "FREE" && !data.viewer.canAddEvents && ( {t("plan_upgrade")}} message={ - <> - {t("to_upgrade_go_to")}{" "} - - {"https://cal.com/upgrade"} + + You can + + upgrade here - + . + } - className="my-4" + className="mb-4" /> )} - {data.eventTypeGroups && - data.eventTypeGroups.map((input) => ( - - {/* hide list heading when there is only one (current user) */} - {(data.eventTypeGroups.length !== 1 || input.teamId) && ( - - )} - ( + + {/* hide list heading when there is only one (current user) */} + {(data.eventTypeGroups.length !== 1 || group.teamId) && ( + - - ))} + )} + + + ))} {data.eventTypeGroups.length === 0 && ( - + )} )} @@ -335,217 +374,4 @@ const EventTypesPage = () => { ); }; -const CreateNewEventButton = ({ profiles, canAddEvents }: CreateEventTypeProps) => { - const router = useRouter(); - const teamId: number | null = Number(router.query.teamId) || null; - const modalOpen = useToggleQuery("new"); - const { t } = useLocale(); - - const createMutation = useMutation(createEventType, { - onSuccess: async ({ eventType }) => { - await router.push("/event-types/" + eventType.id); - showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success"); - }, - onError: (err: HttpError) => { - const message = `${err.statusCode}: ${err.message}`; - showToast(message, "error"); - }, - }); - - const slugRef = useRef(null); - - return ( - { - router.push(isOpen ? modalOpen.hrefOn : modalOpen.hrefOff); - }}> - {!profiles.filter((profile) => profile.teamId).length && ( - - )} - {profiles.filter((profile) => profile.teamId).length > 0 && ( - - - - - - {t("new_event_subtitle")} - - {profiles.map((profile) => ( - - router.push({ - pathname: router.pathname, - query: { - ...router.query, - new: "1", - eventPage: profile.slug, - ...(profile.teamId - ? { - teamId: profile.teamId, - } - : {}), - }, - }) - }> - - {profile.name ? profile.name : profile.slug} - - ))} - - - )} - -
      - -
      -

      {t("new_event_type_to_book_description")}

      -
      -
      -
      { - e.preventDefault(); - - const target = e.target as unknown as Record< - "title" | "slug" | "description" | "length" | "schedulingType", - { value: string } - >; - - const payload: CreateEventType = { - title: target.title.value, - slug: target.slug.value, - description: target.description.value, - length: parseInt(target.length.value), - }; - - if (router.query.teamId) { - payload.teamId = parseInt(`${router.query.teamId}`, 10); - payload.schedulingType = target.schedulingType.value as SchedulingType; - } - - createMutation.mutate(payload); - }}> -
      -
      - -
      - { - if (!slugRef.current) { - return; - } - slugRef.current.value = e.target.value.replace(/\s+/g, "-").toLowerCase(); - }} - type="text" - name="title" - id="title" - required - className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm" - placeholder={t("quick_chat")} - /> -
      -
      -
      - -
      -
      - - {process.env.NEXT_PUBLIC_APP_URL}/{router.query.eventPage || profiles[0].slug}/ - - -
      -
      -
      -
      - -
      - + className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm">
      @@ -266,27 +292,29 @@ function SettingsView(props: ComponentProps & { localeProp: str name="avatar" id="avatar" placeholder="URL" - className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" + className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm" defaultValue={imageSrc} /> - { - avatarRef.current.value = newAvatar; - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value" - )?.set; - nativeInputValueSetter?.call(avatarRef.current, newAvatar); - const ev2 = new Event("input", { bubbles: true }); - avatarRef.current.dispatchEvent(ev2); - updateProfileHandler(ev2 as unknown as FormEvent); - setImageSrc(newAvatar); - }} - imageSrc={imageSrc} - /> +
      + { + avatarRef.current.value = newAvatar; + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeInputValueSetter?.call(avatarRef.current, newAvatar); + const ev2 = new Event("input", { bubbles: true }); + avatarRef.current.dispatchEvent(ev2); + updateProfileHandler(ev2 as unknown as FormEvent); + setImageSrc(newAvatar); + }} + imageSrc={imageSrc} + /> +

      @@ -298,9 +326,9 @@ function SettingsView(props: ComponentProps & { localeProp: str v && setSelectedWeekStartDay(v)} classNamePrefix="react-select" - className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" + className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm" options={[ { value: "Sunday", label: nameOfDay(props.localeProp, 0) }, { value: "Monday", label: nameOfDay(props.localeProp, 1) }, @@ -347,8 +375,8 @@ function SettingsView(props: ComponentProps & { localeProp: str isDisabled={!selectedTheme} defaultValue={selectedTheme || themeOptions[0]} value={selectedTheme || themeOptions[0]} - onChange={setSelectedTheme} - className="shadow-sm | { value: string } focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm" + onChange={(v) => v && setSelectedTheme(v)} + className="shadow-sm | { value: string } focus:ring-neutral-800 focus:border-neutral-800 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm" options={themeOptions} />
      @@ -360,7 +388,7 @@ function SettingsView(props: ComponentProps & { localeProp: str type="checkbox" onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])} checked={!selectedTheme} - className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900" + className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-800 text-neutral-900" />
      @@ -370,6 +398,23 @@ function SettingsView(props: ComponentProps & { localeProp: str
      +
      + +
      + +
      +
      +
      @@ -384,6 +429,34 @@ function SettingsView(props: ComponentProps & { localeProp: str
      +

      {t("danger_zone")}

      +
      +
      + + + + + + {t("confirm_delete_account")} + + } + onConfirm={() => deleteAccount()}> + {t("delete_account_confirmation_message")} + + +
      +

    @@ -434,6 +507,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => hideBranding: true, theme: true, plan: true, + brandColor: true, + metadata: true, }, }); diff --git a/pages/settings/security.tsx b/pages/settings/security.tsx index fa636071..778fbd40 100644 --- a/pages/settings/security.tsx +++ b/pages/settings/security.tsx @@ -1,5 +1,8 @@ import React from "react"; +import SAMLConfiguration from "@ee/components/saml/Configuration"; + +import { identityProviderNameMap } from "@lib/auth"; import { useLocale } from "@lib/hooks/useLocale"; import { trpc } from "@lib/trpc"; @@ -8,14 +11,37 @@ import Shell from "@components/Shell"; import ChangePasswordSection from "@components/security/ChangePasswordSection"; import TwoFactorAuthSection from "@components/security/TwoFactorAuthSection"; +import { IdentityProvider } from ".prisma/client"; + export default function Security() { const user = trpc.useQuery(["viewer.me"]).data; const { t } = useLocale(); return ( - - + {user && user.identityProvider !== IdentityProvider.CAL ? ( + <> +
    +

    + {t("account_managed_by_identity_provider", { + provider: identityProviderNameMap[user.identityProvider], + })} +

    +
    +

    + {t("account_managed_by_identity_provider_description", { + provider: identityProviderNameMap[user.identityProvider], + })} +

    + + ) : ( + <> + + + + )} + +
    ); diff --git a/pages/settings/teams.tsx b/pages/settings/teams.tsx index dffe907d..b953ac31 100644 --- a/pages/settings/teams.tsx +++ b/pages/settings/teams.tsx @@ -1,196 +1,79 @@ -import { UsersIcon } from "@heroicons/react/outline"; import { PlusIcon } from "@heroicons/react/solid"; -import { useSession } from "next-auth/client"; -import { useEffect, useRef, useState } from "react"; +import classNames from "classnames"; +import { useSession } from "next-auth/react"; +import { Trans } from "next-i18next"; +import { useState } from "react"; import { useLocale } from "@lib/hooks/useLocale"; -import { Member } from "@lib/member"; -import { Team } from "@lib/team"; +import { trpc } from "@lib/trpc"; import Loader from "@components/Loader"; import SettingsShell from "@components/SettingsShell"; -import Shell from "@components/Shell"; -import EditTeam from "@components/team/EditTeam"; +import Shell, { useMeQuery } from "@components/Shell"; +import TeamCreateModal from "@components/team/TeamCreateModal"; import TeamList from "@components/team/TeamList"; -import TeamListItem from "@components/team/TeamListItem"; +import { Alert } from "@components/ui/Alert"; import Button from "@components/ui/Button"; export default function Teams() { const { t } = useLocale(); - const noop = () => undefined; - const [, loading] = useSession(); - const [teams, setTeams] = useState([]); - const [invites, setInvites] = useState([]); + const { status } = useSession(); + const loading = status === "loading"; const [showCreateTeamModal, setShowCreateTeamModal] = useState(false); - const [editTeamEnabled, setEditTeamEnabled] = useState(false); - const [teamToEdit, setTeamToEdit] = useState(); - const nameRef = useRef() as React.MutableRefObject; + const [errorMessage, setErrorMessage] = useState(""); - const handleErrors = async (resp: Response) => { - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.message); - } - return resp.json(); - }; + const me = useMeQuery(); - const loadData = () => { - fetch("/api/user/membership") - .then(handleErrors) - .then((data) => { - setTeams(data.membership.filter((m: Member) => m.role !== "INVITEE")); - setInvites(data.membership.filter((m: Member) => m.role === "INVITEE")); - }) - .catch(console.log); - }; + const { data } = trpc.useQuery(["viewer.teams.list"], { + onError: (e) => { + setErrorMessage(e.message); + }, + }); - useEffect(() => { - loadData(); - }, []); + if (loading) return ; - if (loading) { - return ; - } - - const createTeam = (e: React.FormEvent) => { - e.preventDefault(); - return fetch("/api/teams", { - method: "POST", - body: JSON.stringify({ name: nameRef?.current?.value }), - headers: { - "Content-Type": "application/json", - }, - }).then(() => { - loadData(); - setShowCreateTeamModal(false); - }); - }; - - const editTeam = (team: Team) => { - setEditTeamEnabled(true); - setTeamToEdit(team); - }; - - const onCloseEdit = () => { - loadData(); - setEditTeamEnabled(false); - }; + const teams = data?.filter((m) => m.accepted) || []; + const invites = data?.filter((m) => !m.accepted) || []; + const isFreePlan = me.data?.plan === "FREE"; return ( - {!editTeamEnabled && ( -
    -
    -
    -
    - {!(invites.length || teams.length) && ( -
    -
    -

    - {t("create_team_to_get_started")} -

    -
    -

    {t("create_first_team_and_invite_others")}

    -
    -
    -
    - )} -
    -
    - -
    -
    -
    - {!!teams.length && ( - - )} - - {!!invites.length && ( -
    -

    Open Invitations

    -
      - {invites.map((team: Team) => ( - - ))} -
    -
    - )} -
    -
    -
    + {!!errorMessage && } + {isFreePlan && ( + {t("plan_upgrade_teams")}} + message={ + + You can + + upgrade here + + . + + } + className="my-4" + /> )} - {!!editTeamEnabled && } - {showCreateTeamModal && ( -
    -
    - - - - -
    -
    -
    - -
    -
    - -
    -

    {t("create_new_team_description")}

    -
    -
    -
    - -
    - - -
    -
    - - -
    - -
    -
    + {showCreateTeamModal && setShowCreateTeamModal(false)} />} +
    + +
    + {invites.length > 0 && ( +
    +

    {t("open_invitations")}

    +
    )} + {teams.length > 0 && } ); diff --git a/pages/settings/teams/[id].tsx b/pages/settings/teams/[id].tsx new file mode 100644 index 00000000..beb44ed2 --- /dev/null +++ b/pages/settings/teams/[id].tsx @@ -0,0 +1,98 @@ +import { PlusIcon } from "@heroicons/react/solid"; +import { useRouter } from "next/router"; +import { useState } from "react"; + +import SAMLConfiguration from "@ee/components/saml/Configuration"; + +import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; +import { useLocale } from "@lib/hooks/useLocale"; +import { trpc } from "@lib/trpc"; + +import Loader from "@components/Loader"; +import Shell from "@components/Shell"; +import MemberInvitationModal from "@components/team/MemberInvitationModal"; +import MemberList from "@components/team/MemberList"; +import TeamSettings from "@components/team/TeamSettings"; +import TeamSettingsRightSidebar from "@components/team/TeamSettingsRightSidebar"; +import { Alert } from "@components/ui/Alert"; +import Avatar from "@components/ui/Avatar"; +import { Button } from "@components/ui/Button"; + +export function TeamSettingsPage() { + const { t } = useLocale(); + const router = useRouter(); + + const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], { + onError: (e) => { + setErrorMessage(e.message); + }, + }); + + const isAdmin = team && (team.membership.role === "OWNER" || team.membership.role === "ADMIN"); + + return ( + + ) + }> + {!!errorMessage && } + {isLoading && } + {team && ( + <> +
    +
    +
    + {isAdmin ? ( + + ) : ( +
    + Team Info +

    {team.bio}

    +
    + )} +
    +
    +

    {t("members")}

    + {isAdmin && ( +
    + +
    + )} +
    + + {isAdmin ? : null} +
    +
    + +
    +
    + {showMemberInvitationModal && ( + setShowMemberInvitationModal(false)} /> + )} + + )} +
    + ); +} + +export default TeamSettingsPage; diff --git a/pages/settings/teams/[id]/availability.tsx b/pages/settings/teams/[id]/availability.tsx new file mode 100644 index 00000000..d855eb0d --- /dev/null +++ b/pages/settings/teams/[id]/availability.tsx @@ -0,0 +1 @@ +export { default } from "@ee/pages/settings/teams/[id]/availability"; diff --git a/pages/success.tsx b/pages/success.tsx index 2f229bb5..98afd16b 100644 --- a/pages/success.tsx +++ b/pages/success.tsx @@ -18,9 +18,13 @@ import { isBrandingHidden } from "@lib/isBrandingHidden"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; +import CustomBranding from "@components/CustomBranding"; +import { EmailInput } from "@components/form/fields"; import { HeadSeo } from "@components/seo/head-seo"; import Button from "@components/ui/Button"; +import { ssrInit } from "@server/lib/ssr"; + dayjs.extend(utc); dayjs.extend(toArray); dayjs.extend(timezone); @@ -39,7 +43,17 @@ export default function Success(props: inferSSRProps) setIs24h(!!localStorage.getItem("timeOption.is24hClock")); }, []); - const eventName = getEventName(name, props.eventType.title, props.eventType.eventName); + const attendeeName = typeof name === "string" ? name : "Nameless"; + + const eventNameObject = { + attendeeName, + eventType: props.eventType.title, + eventName: props.eventType.eventName, + host: props.profile.name || "Nameless", + t, + }; + + const eventName = getEventName(eventNameObject); function eventLink(): string { const optional: { location?: string } = {}; @@ -50,7 +64,7 @@ export default function Success(props: inferSSRProps) const event = createEvent({ start: [ date.toDate().getUTCFullYear(), - date.toDate().getUTCMonth(), + (date.toDate().getUTCMonth() as number) + 1, date.toDate().getUTCDate(), date.toDate().getUTCHours(), date.toDate().getUTCMinutes(), @@ -74,11 +88,12 @@ export default function Success(props: inferSSRProps) return ( (isReady && ( -
    +
    +
    @@ -133,7 +148,7 @@ export default function Success(props: inferSSRProps)
    {!needsConfirmation && ( -
    +
    {t("add_to_calendar")} @@ -239,13 +254,11 @@ export default function Success(props: inferSSRProps) router.push(`https://cal.com/signup?email=` + (e as any).target.email.value); }} className="flex mt-4"> -