diff --git a/.env.example b/.env.example index e97496b152..ab42758616 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,158 @@ -# It now lives at `apps/web/.env.example` -# DATABASE_URL got moved to `packages/prisma/.env.example` +# ********** INDEX ********** +# +# - LICENSE +# - DATABASE +# - SHARED +# - NEXTAUTH +# - E-MAIL SETTINGS +# - APP STORE +# - DAILY.CO VIDEO +# - GOOGLE CALENDAR/MEET/LOGIN +# - OFFICE 365 +# - SLACK +# - STRIPE +# - TANDEM +# - ZOOM + +# - LICENSE ************************************************************************************************* +# Set this value to 'agree' to accept our license: +# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE +# +# Summary of terms: +# - The codebase has to stay open source, whether it was modified or not +# - You can not repackage or sell the codebase +# - Acquire a commercial license to remove these terms by visiting: cal.com/sales +NEXT_PUBLIC_LICENSE_CONSENT='' +# *********************************************************************************************************** + +# - DATABASE ************************************************************************************************ +# ⚠️ ⚠️ ⚠️ DATABASE_URL got moved to `packages/prisma/.env.example` ⚠️ ⚠️ ⚠️ +# *********************************************************************************************************** + +# - SHARED ************************************************************************************************** +NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000' +# Change to 'http://localhost:3001' if running the website simultaneously +NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000' + +# To enable SAML login, set both these variables +# @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login +# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml" +SAML_DATABASE_URL= +# SAML_ADMINS='pro@example.com' +SAML_ADMINS= +# If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line. +# @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js +# PGSSLMODE='no-verify' +PGSSLMODE= + +# - NEXTAUTH +# @see: https://github.com/calendso/calendso/issues/263 +# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL +# NEXTAUTH_URL='http://localhost:3000' +NEXTAUTH_URL= +JWT_SECRET='secret' +# Used for cross-domain cookie authentication +NEXTAUTH_COOKIE_DOMAIN=.example.com + +# Remove this var if you don't want Cal to collect anonymous usage +NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r + +# ApiKey for cronjobs +CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0' + +# Application Key for symmetric encryption and decryption +# must be 32 bytes for AES256 encryption algorithm +# You can use: `openssl rand -base64 24` to generate one +CALENDSO_ENCRYPTION_KEY= + +# Intercom Config +NEXT_PUBLIC_INTERCOM_APP_ID= + +# Zendesk Config +NEXT_PUBLIC_ZENDESK_KEY= + +# Help Scout Config +NEXT_PUBLIC_HELPSCOUT_KEY= + +# This is used so we can bypass emails in auth flows for E2E testing +# Set it to "1" if you need to run E2E tests locally +NEXT_PUBLIC_IS_E2E= + +# Used for internal billing system +NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT= +NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE= +NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE= +NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE= +# *********************************************************************************************************** + +# - E-MAIL SETTINGS ***************************************************************************************** +# Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to +# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/ +# Configures the global From: header whilst sending emails. +EMAIL_FROM='notifications@yourselfhostedcal.com' + +# Configure SMTP settings (@see https://nodemailer.com/smtp/). +# Note: The below configuration for Office 365 has been verified to work. +EMAIL_SERVER_HOST='smtp.office365.com' +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='' +# ********************************************************************************************************** + +# - APP STORE ********************************************************************************************** +# - DAILY.CO VIDEO +DAILY_API_KEY= +DAILY_SCALE_PLAN='' + +# - GOOGLE CALENDAR/MEET/LOGIN +# Needed to enable Google Calendar integration and Login with Google +# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials +GOOGLE_API_CREDENTIALS='{}' +# To enable Login with Google you need to: +# 1. Set `GOOGLE_API_CREDENTIALS` above +# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` +# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance +# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications +GOOGLE_LOGIN_ENABLED=false + +# - OFFICE 365 +# Used for the Office 365 / Outlook.com Calendar / MS Teams integration +# @see https://github.com/calcom/cal.com/#Obtaining-Microsoft-Graph-Client-ID-and-Secret +MS_GRAPH_CLIENT_ID= +MS_GRAPH_CLIENT_SECRET= + +# - SLACK +# @see https://github.com/calcom/cal.com/#obtaining-slack-client-id-and-secret-and-signing-secret +SLACK_SIGNING_SECRET= +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= + +# - STRIPE +NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_... +STRIPE_PRIVATE_KEY= # sk_test_... +STRIPE_WEBHOOK_SECRET= # whsec_... +STRIPE_CLIENT_ID= # ca_... +PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission +PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission + +# - TANDEM +# Used for the Tandem integration -- contact support@tandem.chat to for API access. +TANDEM_CLIENT_ID="" +TANDEM_CLIENT_SECRET="" +TANDEM_BASE_URL="https://tandem.chat" + +# - ZOOM +# Used for the Zoom integration +# @see https://github.com/calcom/cal.com/#obtaining-zoom-client-id-and-secret +ZOOM_CLIENT_ID= +ZOOM_CLIENT_SECRET= +# ********************************************************************************************************* diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index f68ab303b2..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -packages/prisma/zod diff --git a/.github/workflows/check-types.yml b/.github/workflows/check-types.yml index c170321c77..a8c933d87e 100644 --- a/.github/workflows/check-types.yml +++ b/.github/workflows/check-types.yml @@ -6,7 +6,6 @@ on: jobs: types: name: Check types - strategy: matrix: node: ["14.x"] diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0430294af9..1441aa26a8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,6 +1,6 @@ name: E2E test on: - pull_request_target: + pull_request_target: # So we can test on forks branches: - main paths-ignore: @@ -9,9 +9,16 @@ jobs: test: timeout-minutes: 10 name: Testing ${{ matrix.node }} and ${{ matrix.os }} + strategy: + matrix: + node: ["14.x"] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + env: DATABASE_URL: postgresql://postgres:@localhost:5432/calendso - BASE_URL: http://localhost:3000 + NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000 + NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000 JWT_SECRET: secret GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }} GOOGLE_LOGIN_ENABLED: true @@ -25,12 +32,13 @@ jobs: PAYMENT_FEE_FIXED: 10 SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso SAML_ADMINS: pro@example.com - # NEXTAUTH_URL: xxx - EMAIL_FROM: e2e@cal.com - EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }} - EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }} - EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }} - EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }} + NEXTAUTH_URL: http://localhost:3000/api/auth + NEXT_PUBLIC_IS_E2E: 1 + # EMAIL_FROM: e2e@cal.com + # EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }} + # EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }} + # EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }} + # EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD }} # MS_GRAPH_CLIENT_ID: xxx # MS_GRAPH_CLIENT_SECRET: xxx # ZOOM_CLIENT_ID: xxx @@ -43,25 +51,20 @@ jobs: 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 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} # So we can test on forks fetch-depth: 2 - name: Use Node ${{ matrix.node }} uses: actions/setup-node@v2 with: - cache: "yarn" - cache-dependency-path: yarn.lock node-version: ${{ matrix.node }} + # cache: "yarn" + # cache-dependency-path: yarn.lock - name: Turbo Cache id: turbo-cache diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b190c39fcf..e89e712f52 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,7 +5,11 @@ on: - main jobs: lint: - runs-on: ubuntu-latest + strategy: + matrix: + node: ["14.x"] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout repo @@ -17,9 +21,9 @@ jobs: - name: Use Node.js 14.x uses: actions/setup-node@v2 with: - node-version: 14.x - cache: "yarn" - cache-dependency-path: yarn.lock + node-version: ${{ matrix.node }} + # cache: "yarn" + # cache-dependency-path: yarn.lock - name: Install deps if: steps.yarn-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/submodule-sync.yml b/.github/workflows/submodule-sync.yml new file mode 100644 index 0000000000..c4b61885bd --- /dev/null +++ b/.github/workflows/submodule-sync.yml @@ -0,0 +1,21 @@ +name: Submodule Sync +on: + schedule: + - cron: "15 */4 * * *" + workflow_dispatch: ~ + +jobs: + submodule-sync: + name: Submodule update + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: run action + uses: releasehub-com/github-action-create-pr-parent-submodule@v1 + with: + github_token: ${{ secrets.GH_ACCESS_TOKEN }} + parent_repository: "calcom/cal.com" + checkout_branch: "main" + pr_against_branch: "main" + owner: "calcom" diff --git a/.gitignore b/.gitignore index a5da3c9c97..53ec825669 100644 --- a/.gitignore +++ b/.gitignore @@ -11,11 +11,11 @@ node_modules # testing coverage /test-results/ -playwright/videos -playwright/screenshots -playwright/artifacts -playwright/results -playwright/reports/* +**/playwright/videos +**/playwright/screenshots +**/playwright/artifacts +**/playwright/results +**/playwright/reports/* # next.js .next/ diff --git a/.gitmodules b/.gitmodules index 508657d224..be6c917930 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,8 @@ [submodule "apps/api"] path = apps/api - url = git@github.com:calcom/api.git + url = https://github.com/calcom/api.git + branch = main [submodule "apps/website"] path = apps/website - url = git@github.com:calcom/website.git + url = https://github.com/calcom/website.git + branch = main diff --git a/.vscode/settings.json b/.vscode/settings.json index b5e0d7593a..6502f7cf8c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,9 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "editor.formatOnSave": true, - // Auto-fix issues with ESLint when you save code changes + "editor.formatOnSave": false, "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, - "eslint.run": "onSave", "typescript.preferences.importModuleSpecifier": "non-relative", "spellright.language": ["en"], "spellright.documentTypes": ["markdown"] diff --git a/README.md b/README.md index a0b681dfc1..9bfd4c55eb 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Here is what you need to be able to run Cal. ### Setup -1. Clone the repo +1. Clone the repo into a public GitHub repository (to comply with AGPLv3. To clone in a private repository, [acquire a commercial license](https://cal.com/sales)) ```sh git clone https://github.com/calcom/cal.com.git @@ -317,6 +317,56 @@ We have a list of [good first issues](https://github.com/calcom/cal.com/labels/ 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** attribute +### Obtaining Slack Client ID and Secret and Signing Secret + +To test this you will need to create a Slack app for yourself on [their apps website](https://api.slack.com/apps). + +Copy and paste the app manifest below into the setting on your slack app. Be sure to replace `YOUR_DOMAIN` with your own domain or your proxy host if you're testing locally. + +
+ App Manifest + + ```yaml + display_information: + name: Cal.com Slack +features: + bot_user: + display_name: Cal.com Slack + always_online: false + slash_commands: + - command: /create-event + url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler + description: Create an event within Cal! + should_escape: false + - command: /today + url: https://YOUR_DOMAIN/api/integrations/slackmessaging/commandHandler + description: View all your bookings for today + should_escape: false +oauth_config: + redirect_urls: + - https://YOUR_DOMAIN/api/integrations/slackmessaging/callback + scopes: + bot: + - chat:write + - commands +settings: + interactivity: + is_enabled: true + request_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler + message_menu_options_url: https://YOUR_DOMAIN/api/integrations/slackmessaging/interactiveHandler + org_deploy_enabled: false + socket_mode_enabled: false + token_rotation_enabled: false +``` + +
+ +Add the integration as normal - slack app - add. Follow the oauth flow to add it to a server. + +Next make sure you have your app running `yarn dx`. Then in the slack chat type one of these commands: `/create-event` or `/today` + +> NOTE: Next you will need to setup a proxy server like [ngrok](https://ngrok.com/) to allow your local host machine to be hosted on a public https server. + ### Obtaining Zoom Client ID and Secret 1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account. diff --git a/apps/docs/package.json b/apps/docs/package.json index a5f476affe..c8381960b3 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -7,6 +7,7 @@ "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", "dev": "PORT=4000 next", "lint": "next lint", + "type-check": "tsc --pretty --noEmit", "lint:report": "eslint . --format json --output-file ../../lint-results/docs.json", "start": "PORT=4000 next start", "build": "next build" diff --git a/apps/docs/pages/_app.tsx b/apps/docs/pages/_app.tsx index 590e3dfc64..6c92686cfb 100644 --- a/apps/docs/pages/_app.tsx +++ b/apps/docs/pages/_app.tsx @@ -1,7 +1,8 @@ +import { AppProps } from "next/app"; import "nextra-theme-docs/style.css"; import "./style.css"; -export default function Nextra({ Component, pageProps }) { +export default function Nextra({ Component, pageProps }: AppProps) { return ; } diff --git a/apps/docs/pages/developer/app-store.mdx b/apps/docs/pages/developer/app-store.mdx index 6c30776c6d..11af83786c 100644 --- a/apps/docs/pages/developer/app-store.mdx +++ b/apps/docs/pages/developer/app-store.mdx @@ -12,20 +12,26 @@ All apps can be found under `packages/app-store`. In this folder is `_example` w ```sh ├──_example -| ├──index.ts -| ├──package.json -| ├──.env.example | | ├──api | | ├──example.ts | | ├──index.ts | +| ├──components +| | ├──InstallAppButton.tsx +| | ├──index.ts +| | ├──lib | | ├──adaptor.ts | | ├──index.ts | | ├──static | | ├──icon.svg +| +| ├──index.ts +| ├──package.json +| ├──.env.example +| ├──README.mdx ``` ## Getting Started @@ -38,8 +44,17 @@ In `index.js` fill out the meta data that will be rendered on the app page. Unde Under the `/api` folder, this is where any API calls that are associated with your app will be handled. Since cal.com uses Next.js we use dynamic API routes. In this example if we want to hit `/api/example.ts` the route would be `{BASE_URL}/api/integrations/_example/example`. Export your endpoints in an `index.ts` file under `/api` folder and import them in your main `index.ts` file. +Under the `/components` folder, this is where the install button for your app should live. Follow the template under `_example` to add your on click action (ex. Redirecting to a log in page or opening a modal). + The `/lib` folder is where the functions of your app live. For example, when creating a booking with a MS Teams link the function to make the call to grab the link lives in the `/lib` folder. Export your endpoints in an `index.ts` file under `/lib` folder and import them in your main `index.ts` file. -The `/static` folder is where your assets live. +On the app store page you can customize your apps description by adding a markdown file called `README.mdx`. If you do not add one then the description from you `package.json` will be used instead. + +The `/static` folder is where you can store your app icon and any images that your `README.mdx` may use. + +## Adding Your App to the App Store +To render your app on the app store page, go to `packages/app-store/index.ts`. Import your app into the file and add it to the `appStore` object. + +Under `packages/app-store/components.tsx`, in the `InstallAppButtonMap` object dynamically import your install button. Your install button should live under `{your_app}/components`. If you need any help feel free to join us on [Slack](https://cal.com/slack) diff --git a/apps/docs/pages/integrations/embed.mdx b/apps/docs/pages/integrations/embed.mdx new file mode 100644 index 0000000000..c73d494cac --- /dev/null +++ b/apps/docs/pages/integrations/embed.mdx @@ -0,0 +1,208 @@ +--- +title: Embed +--- + +# Embed + +The Embed allows your website visitors to book a meeting with you directly from your website. + +## Install on any website + +TODO: Mention possibility of installation through tag managers as well + +- _Step-1._ Install the Vanilla JS Snippet + + ```javascript + (function (C, A, L) { + let p = function (a, ar) { + a.q.push(ar); + }; + let d = C.document; + C.Cal = + C.Cal || + function () { + let cal = C.Cal; + let ar = arguments; + if (!cal.loaded) { + cal.ns = {}; + cal.q = cal.q || []; + d.head.appendChild(d.createElement("script")).src = A; + cal.loaded = true; + } + if (ar[0] === L) { + const api = function () { + p(api, arguments); + }; + const namespace = ar[1]; + api.q = api.q || []; + typeof namespace === "string" ? (cal.ns[namespace] = api) && p(api, ar) : p(cal, ar); + return; + } + p(cal, ar); + }; + })(window, "https://cal.com/embed.js", "init"); + ``` + +- _Step-2_. Initialize it + + ```javascript + Cal("init) + ``` + +## Install with a Framework + +### embed-react + +It provides a react component `` that can be used to show the embed inline at that place. + +```bash +yarn add @calcom/embed-react +``` + +### Any XYZ Framework + +You can use Vanilla JS Snippet to install + +## Popular ways in which you can embed on your website + +Assuming that you have followed the steps of installing and initializing the snippet, you can add show the embed in following ways: + +### Inline + +Show the embed inline inside a container element. It would take the width and height of the container element. + +
+ _Vanilla JS_ + +```javascript +Cal("inline", { + elementOrSelector: "Your Embed Container Selector Path", // You can also provide an element directly + calLink: "jane", // The link that you want to embed. It would open https://cal.com/jane in embed + config: { + name: "John Doe", // Prefill Name + email: "johndoe@gmail.com", // Prefill Email + notes: "Test Meeting", // Prefill Notes + guests: ["janedoe@gmail.com", "test@gmail.com"], // Prefill Guests + theme: "dark", // "dark" or "light" theme + }, +}); +``` + +
+ +#### + +
+_React_ + +```jsx +import Cal from "@calcom/embed-react"; + +const MyComponent = () => ( + +); +``` + +
+ +### Popup on any existing element + +To show the embed as a popup on clicking an element, add `data-cal-link` attribute to the element. + +
+ +Vanilla JS + +To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element. + + +
+ +
+ React + ```jsx + import "@calcom/embed-react"; + + const MyComponent = ()=> { + return + } + +```` + +
+### Full Screen + +## Supported Instructions + +Consider an instruction as a function with that name and that would be called with the given arguments. + +### `inline` + +Appends embed inline as the child of the element. + +```javascript +Cal("inline", { elementOrSelector, calLink }); +```` + +- `elementOrSelector` - Give it either a valid CSS selector or an HTMLElement instance directly + +- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john](). It makes it easy to configure the calendar host once and use as many links you want with just usernames + +### `ui` + +Configure UI for embed. Make it look part of your webpage. + +```javascript +Cal("inline", { styles }); +``` + +- `styles` - It supports styling for `body` and `eventTypeListItem`. Right now we support just background on these two. + +### preload + +Usage: + +If you want to open cal link on some action. Make it pop open instantly by preloading it. + +```javascript +Cal("preload", { calLink }); +``` + +- `calLink` - Cal Link that you want to embed e.g. john. Just give the username. No need to give the full URL [https://cal.com/john]() + +## Actions +You can listen to an action that occurs in embedded cal link as follows. You can think of them as DOM events. We are avoiding the term events to not confuse it with Cal Events. +```javascript +Cal("on", { + action: "ANY_ACTION_NAME", + callback: (e)=>{ + // `data` is properties for the event. + // `type` is the name of the action(You can also call it type of the action.) This would be same as "ANY_ACTION_NAME" except when ANY_ACTION_NAME="*" which listens to all the events. + // `namespace` tells you the Cal namespace for which the event is fired/ + const {data, type, namespace} = e.detail; + } +}) +``` + +Following are the list of supported actions. +- +| action | description | properties | +|----------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| eventTypeSelected | When user chooses an event-type from the listing. | eventType:object // Event Type that has been selected" | +| bookingSuccessful | When the booking is successfully done. It might not be confirmed. | confirmed: boolean; //Whether confirmation from organizer is pending or not

eventType: "Object for Event Type that has been booked";

date: string; // Date of Event

duration: number; //Duration of booked Event

organizer: object //Organizer details like name, timezone, email | +| linkReady | Tells that the link is ready to be shown now. | None | +| linkFailed | Fired if link fails to load | code: number; // Error Code

msg: string; //Human Readable msg

data: object // More details to debug the error | +| __iframeReady | It is fired when the embedded iframe is ready to communicate with parent snippet. This is mostly for internal use by Embed Snippet | None | +| __windowLoadComplete | Tells that window load for iframe is complete | None | +| __dimensionChanged | Tells that dimensions of the content inside the iframe changed. | iframeWidth:number, iframeHeight:number | + +_Actions that start with __ are internal._ \ No newline at end of file diff --git a/apps/docs/pages/integrations/introduction.mdx b/apps/docs/pages/integrations/introduction.mdx index 1c7abec297..fc3af40f72 100644 --- a/apps/docs/pages/integrations/introduction.mdx +++ b/apps/docs/pages/integrations/introduction.mdx @@ -5,17 +5,17 @@ title: Introduction # Integrations ## Connecting new calendars -1. Go to the [Cal App Store](https://app.cal.com/integrations). +1. Go to the [Cal App Store](https://app.cal.com/apps). 2. Located at the top right of the screen, press the button saying '+ Connect A New App' 3. Choose the account your calendar is connected too by clicking 'Add'. (e.g. Google, Office 365, Zoom) 4. You will be redirected to the log in page of the chosen account. 5. Allow Cal access to view and edit your calendars. -6. You will be sent back to the [Cal App Store](https://app.cal.com/integrations). From here you will now be able to see your connected calendar! +6. You will be sent back to the [Cal App Store](https://app.cal.com/apps/installed). From here you will now be able to see your connected calendar! ## How to choose the primary Calendar? If you have two or more integrated calendars and you want your events to show in only one, you can define a primary calendar like this: -1. Go to your [Integrations](https://app.cal.com/integrations) page. +1. Go to your [Installed](https://app.cal.com/apps/installed) page. 2. Next to your `Calendars` you will see a dropdown that says `Create events on:`. 3. Select your primary calendar. diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index b98f0952c6..d1581064cf 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "@calcom/tsconfig/nextjs.json", "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "exclude": ["node_modules"], + "compilerOptions": { + "noImplicitAny": false + } } diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index dd87b26d0d..0000000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,109 +0,0 @@ -# Set this value to 'agree' to accept our license: -# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE -# -# Summary of terms: -# - The codebase has to stay open source, whether it was modified or not -# - You can not repackage or sell the codebase -# - Acquire a commercial license to remove these terms by visiting: cal.com/sales -NEXT_PUBLIC_LICENSE_CONSENT='' - -# ⚠️ ⚠️ ⚠️ DATABASE_URL got moved to `packages/prisma/.env.example` ⚠️ ⚠️ ⚠️ - -# Needed to enable Google Calendar integration 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` -# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance -# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications -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 - -# To enable SAML login, set both these variables -# @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login -# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml" -# SAML_ADMINS='pro@example.com' -# If you use Heroku to deploy Postgres (or use self-signed certs for Postgres) then uncomment the follow line. -# @see https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-node-js -##PGSSLMODE='no-verify' - -# @see: https://github.com/calendso/calendso/issues/263 -# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL -# NEXTAUTH_URL='http://localhost:3000' - -# Remove this var if you don't want Cal to collect anonymous usage -NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r - -# Used for the Office 365 / Outlook.com Calendar integration -MS_GRAPH_CLIENT_ID= -MS_GRAPH_CLIENT_SECRET= - -# Used for the Zoom integration -ZOOM_CLIENT_ID= -ZOOM_CLIENT_SECRET= - -#Used for the Daily integration -DAILY_API_KEY= -DAILY_SCALE_PLAN='' - -# Used for the Tandem integration -- contact support@tandem.chat to for API access. -TANDEM_CLIENT_ID="" -TANDEM_CLIENT_SECRET="" -TANDEM_BASE_URL="https://tandem.chat" - -# E-mail settings - -# Cal uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to -# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/ - -# Configures the global From: header whilst sending emails. -EMAIL_FROM='notifications@yourselfhostedcal.com' - -# Configure SMTP settings (@see https://nodemailer.com/smtp/). -# Note: The below configuration for Office 365 has been verified to work. -EMAIL_SERVER_HOST='smtp.office365.com' -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 - -# Application Key for symmetric encryption and decryption -# must be 32 bytes for AES256 encryption algorithm -CALENDSO_ENCRYPTION_KEY= - -# Intercom Config -NEXT_PUBLIC_INTERCOM_APP_ID= - -# Zendesk Config -NEXT_PUBLIC_ZENDESK_KEY= - -# Help Scout Config -NEXT_PUBLIC_HELPSCOUT_KEY= - -# Set it to "1" if you need to run E2E tests locally -NEXT_PUBLIC_IS_E2E= diff --git a/apps/web/components/App.tsx b/apps/web/components/App.tsx index 62fc9c7113..627dbb09e9 100644 --- a/apps/web/components/App.tsx +++ b/apps/web/components/App.tsx @@ -64,14 +64,14 @@ export default function App({ return ( <> -
-
+
+
{t("browse_apps")} -
+
{name}
@@ -82,7 +82,7 @@ export default function App({
-
+
{isGlobal ? (
-
+
{body}

{t("categories")}

diff --git a/apps/web/components/AppsShell.tsx b/apps/web/components/AppsShell.tsx index 16817268e4..810bc843c1 100644 --- a/apps/web/components/AppsShell.tsx +++ b/apps/web/components/AppsShell.tsx @@ -1,7 +1,7 @@ import { useSession } from "next-auth/react"; import React from "react"; -import { useLocale } from "@lib/hooks/useLocale"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import NavTabs from "./NavTabs"; diff --git a/apps/web/components/CustomBranding.tsx b/apps/web/components/CustomBranding.tsx index 66377f8fc8..314443e3ad 100644 --- a/apps/web/components/CustomBranding.tsx +++ b/apps/web/components/CustomBranding.tsx @@ -1,5 +1,7 @@ import { useEffect } from "react"; +import { useBrandColors } from "@calcom/embed-core"; + const brandColor = "#292929"; const brandTextColor = "#ffffff"; const darkBrandColor = "#fafafa"; @@ -220,6 +222,8 @@ const BrandColor = ({ lightVal: string | undefined | null; darkVal: string | undefined | null; }) => { + const embedBrandingColors = useBrandColors(); + lightVal = embedBrandingColors.brandColor || lightVal; // convert to 6 digit equivalent if 3 digit code is entered lightVal = normalizeHexCode(lightVal, false); darkVal = normalizeHexCode(darkVal, true); @@ -235,6 +239,34 @@ const BrandColor = ({ : "#" + darkVal : fallBackHex(darkVal, true); useEffect(() => { + document.documentElement.style.setProperty( + "--booking-highlight-color", + embedBrandingColors.highlightColor || "#10B981" // green--500 + ); + document.documentElement.style.setProperty( + "--booking-lightest-color", + embedBrandingColors.lightestColor || "#E1E1E1" // gray--200 + ); + document.documentElement.style.setProperty( + "--booking-lighter-color", + embedBrandingColors.lighterColor || "#ACACAC" // gray--400 + ); + document.documentElement.style.setProperty( + "--booking-light-color", + embedBrandingColors.lightColor || "#888888" // gray--500 + ); + document.documentElement.style.setProperty( + "--booking-median-color", + embedBrandingColors.medianColor || "#494949" // gray--600 + ); + document.documentElement.style.setProperty( + "--booking-dark-color", + embedBrandingColors.darkColor || "#313131" // gray--800 + ); + document.documentElement.style.setProperty( + "--booking-darker-color", + embedBrandingColors.darkerColor || "#292929" // gray--900 + ); document.documentElement.style.setProperty("--brand-color", lightVal); document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(lightVal, true)); document.documentElement.style.setProperty("--brand-color-dark-mode", darkVal); diff --git a/apps/web/components/DestinationCalendarSelector.tsx b/apps/web/components/DestinationCalendarSelector.tsx index fb515ea99b..7f6933bd28 100644 --- a/apps/web/components/DestinationCalendarSelector.tsx +++ b/apps/web/components/DestinationCalendarSelector.tsx @@ -25,20 +25,18 @@ const DestinationCalendarSelector = ({ 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); + 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 || "", - }); - } + if (selected) { + setSelectedOption({ + value: `${selected.integration}:${selected.externalId}`, + label: selected.name || "", + }); } - }, [query.data?.connectedCalendars, selectedOption, value]); + }, [query.data?.connectedCalendars, value]); if (!query.data?.connectedCalendars.length) { return null; diff --git a/apps/web/components/Shell.tsx b/apps/web/components/Shell.tsx index 4b67070f5e..12dd4ea822 100644 --- a/apps/web/components/Shell.tsx +++ b/apps/web/components/Shell.tsx @@ -14,7 +14,7 @@ import { import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/router"; -import React, { Fragment, ReactNode, useEffect, useState } from "react"; +import React, { Fragment, ReactNode, useEffect } from "react"; import { Toaster } from "react-hot-toast"; import Button from "@calcom/ui/Button"; @@ -53,13 +53,14 @@ export function useMeQuery() { return meQuery; } -function useRedirectToLoginIfUnauthenticated() { +function useRedirectToLoginIfUnauthenticated(isPublic = false) { const { data: session, status } = useSession(); const loading = status === "loading"; const router = useRouter(); + const shouldDisplayUnauthed = router.pathname.startsWith("/apps"); useEffect(() => { - if (router.pathname.startsWith("/apps")) { + if (isPublic) { return; } @@ -72,10 +73,12 @@ function useRedirectToLoginIfUnauthenticated() { }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loading, session]); + }, [loading, session, isPublic]); return { loading: loading && !session, + shouldDisplayUnauthed, + session, }; } @@ -84,11 +87,7 @@ function useRedirectToOnboardingIfNeeded() { const query = useMeQuery(); const user = query.data; - const [isRedirectingToOnboarding, setRedirecting] = useState(false); - - useEffect(() => { - user && setRedirecting(shouldShowOnboarding(user)); - }, [router, user]); + const isRedirectingToOnboarding = user && shouldShowOnboarding(user); useEffect(() => { if (isRedirectingToOnboarding) { @@ -134,10 +133,11 @@ export default function Shell(props: { backPath?: string; // renders back button to specified path // use when content needs to expand with flex flexChildrenContainer?: boolean; + isPublic?: boolean; }) { const { t } = useLocale(); const router = useRouter(); - const { loading } = useRedirectToLoginIfUnauthenticated(); + const { loading, session } = useRedirectToLoginIfUnauthenticated(props.isPublic); const { isRedirectingToOnboarding } = useRedirectToOnboardingIfNeeded(); const telemetry = useTelemetry(); @@ -201,7 +201,7 @@ export default function Shell(props: { const i18n = useViewerI18n(); const { status } = useSession(); - if (i18n.status === "loading" || isRedirectingToOnboarding || loading) { + if (i18n.status === "loading" || query.status === "loading" || isRedirectingToOnboarding || loading) { // show spinner whilst i18n is loading to avoid language flicker return (
@@ -209,6 +209,9 @@ export default function Shell(props: {
); } + + if (!session && !props.isPublic) return null; + return ( <> @@ -288,7 +291,9 @@ export default function Shell(props: {
-
+
@@ -298,8 +303,10 @@ export default function Shell(props: {
© {new Date().getFullYear()} Cal.com, Inc. v.{pkg.version + "-"} - {process.env.NEXT_PUBLIC_APP_URL === "https://cal.com" ? "h" : "sh"} - -{user && user.plan} + {process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? "h" : "sh"} + + -{user && user.plan} +
@@ -350,7 +357,7 @@ export default function Shell(props: {
)} - {props.heading && props.subtitle && ( + {props.heading && (
{props.heading} -

{props.subtitle}

+

{props.subtitle}

{props.CTA &&
{props.CTA}
}
@@ -437,12 +444,7 @@ function UserDropdown({ small }: { small?: boolean }) { )}> {user?.username {!user?.away && ( @@ -496,7 +498,7 @@ function UserDropdown({ small }: { small?: boolean }) { {t("view_public_page")} diff --git a/apps/web/components/UpgradeToProDialog.tsx b/apps/web/components/UpgradeToProDialog.tsx new file mode 100644 index 0000000000..066cfc70b5 --- /dev/null +++ b/apps/web/components/UpgradeToProDialog.tsx @@ -0,0 +1,54 @@ +import { InformationCircleIcon } from "@heroicons/react/outline"; +import { Trans } from "next-i18next"; + +import Button from "@calcom/ui/Button"; +import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog"; + +import { useLocale } from "@lib/hooks/useLocale"; + +export function UpgradeToProDialog({ + modalOpen, + setModalOpen, + children, +}: { + modalOpen: boolean; + setModalOpen: (open: boolean) => void; + children: React.ReactNode; +}) { + const { t } = useLocale(); + return ( + + +
+
+
+
+ +
+
+
+

{children}

+

+ + You can + + upgrade here + + . + +

+
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/components/apps/AllApps.tsx b/apps/web/components/apps/AllApps.tsx index 0b760d25a7..31e6eb5373 100644 --- a/apps/web/components/apps/AllApps.tsx +++ b/apps/web/components/apps/AllApps.tsx @@ -9,7 +9,7 @@ export default function AllApps({ apps }: { apps: App[] }) { return (

{t("all_apps")}

-
+
{apps.map((app) => (

{t("popular_categories")}

-
- {props.categories.map((category: any) => ( +
+ {categories.map((category) => ( - -
- + +
+ {category.name}
-
+

{category.name}

{category.count} apps

diff --git a/apps/web/components/apps/Slider.tsx b/apps/web/components/apps/Slider.tsx index 355a4d3d8f..b22d88c374 100644 --- a/apps/web/components/apps/Slider.tsx +++ b/apps/web/components/apps/Slider.tsx @@ -1,43 +1,40 @@ -import Glide from "@glidejs/glide"; +import Glide, { Options } from "@glidejs/glide"; import "@glidejs/glide/dist/css/glide.core.min.css"; import "@glidejs/glide/dist/css/glide.theme.min.css"; import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/solid"; -import { useEffect, useState } from "react"; +import { useEffect, useRef } from "react"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { App } from "@calcom/types/App"; - -import useMediaQuery from "@lib/hooks/useMediaQuery"; - -import AppCard from "./AppCard"; - -const Slider = ({ items }: { items: T[] }) => { - const { t } = useLocale(); - const isMobile = useMediaQuery("(max-width: 767px)"); - const [size, setSize] = useState(3); +const Slider = ({ + title = "", + className = "", + items, + itemKey = (item) => `${item}`, + renderItem, + options = {}, +}: { + title?: string; + className?: string; + items: T[]; + itemKey?: (item: T) => string; + renderItem?: (item: T) => JSX.Element; + options?: Options; +}) => { + const glide = useRef(null); + const slider = useRef(null); useEffect(() => { - if (isMobile) { - setSize(1); - } else { - setSize(3); + if (glide.current) { + slider.current = new Glide(glide.current, { + type: "carousel", + ...options, + }).mount(); } - }, [isMobile]); - useEffect(() => { - const slider = new Glide(".glide", { - type: "carousel", - perView: size, - }); - - slider.mount(); - - // @ts-ignore TODO: This method is missing in types - return () => slider.destroy(); - }, [size]); + return () => slider.current?.destroy(); + }, [options]); return ( -
+
-
+
-
-

{t("trending_apps")}

-
+ {title && ( +
+

{title}

+
+ )}
    - {items.map((app) => { + {items.map((item) => { + if (typeof renderItem !== "function") return null; return ( - app.trending && ( -
  • - -
  • - ) +
  • + {renderItem(item)} +
  • ); })}
diff --git a/apps/web/components/apps/TrendingAppsSlider.tsx b/apps/web/components/apps/TrendingAppsSlider.tsx new file mode 100644 index 0000000000..1d938e9520 --- /dev/null +++ b/apps/web/components/apps/TrendingAppsSlider.tsx @@ -0,0 +1,39 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { App } from "@calcom/types/App"; + +import AppCard from "./AppCard"; +import Slider from "./Slider"; + +const TrendingAppsSlider = ({ items }: { items: T[] }) => { + const { t } = useLocale(); + + return ( + + className="mb-16" + title={t("trending_apps")} + items={items.filter((app) => !!app.trending)} + itemKey={(app) => app.name} + options={{ + perView: 3, + breakpoints: { + 768 /* and below */: { + perView: 1, + }, + }, + }} + renderItem={(app) => ( + + )} + /> + ); +}; + +export default TrendingAppsSlider; diff --git a/apps/web/components/availability/Schedule.tsx b/apps/web/components/availability/Schedule.tsx index 7c13a8ca56..3e2fc28ecb 100644 --- a/apps/web/components/availability/Schedule.tsx +++ b/apps/web/components/availability/Schedule.tsx @@ -1,15 +1,19 @@ import { PlusIcon, TrashIcon } from "@heroicons/react/outline"; +import { DuplicateIcon } from "@heroicons/react/solid"; +import classNames from "classnames"; 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 React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { GroupBase, Props, SingleValue } from "react-select"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import Button from "@calcom/ui/Button"; +import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/Dropdown"; 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 { useMeQuery } from "@components/Shell"; @@ -20,84 +24,111 @@ 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().utc().endOf("day"); - let t: Dayjs = dayjs().utc().startOf("day"); - - const times: Dayjs[] = []; - 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) => { +/** + * 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 useOptions = () => { // Get user so we can determine 12/24 hour format preferences const query = useMeQuery(); - const user = query.data; + const { timeFormat } = query.data || { timeFormat: null }; - // Lazy-loaded options, otherwise adding a field has a noticable redraw delay. - const [options, setOptions] = useState([]); - const [selected, setSelected] = useState(); - // const { i18n } = useLocale(); + const [filteredOptions, setFilteredOptions] = useState([]); - const handleSelected = (value: number | undefined) => { - setSelected(value); - }; + const options = useMemo(() => { + const end = dayjs().utc().endOf("day"); + let t: Dayjs = dayjs().utc().startOf("day"); - const getOption = (time: ConfigType) => ({ - value: dayjs(time).toDate().valueOf(), - label: dayjs(time) - .utc() - .format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm"), - // .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }), - }); + const options: Option[] = []; + while (t.isBefore(end)) { + options.push({ + value: t.toDate().valueOf(), + label: dayjs(t) + .utc() + .format(timeFormat === 12 ? "h:mma" : "HH:mm"), + }); + t = t.add(increment, "minutes"); + } + return options; + }, []); - const timeOptions = useCallback( - (offsetOrLimitorSelected: { offset?: number; limit?: number; selected?: number } = {}) => { - const { limit, offset, selected } = offsetOrLimitorSelected; - return TIMES.filter( - (time) => - (!limit || time.isBefore(limit)) && - (!offset || time.isAfter(offset)) && - (!selected || time.isAfter(selected)) - ).map((t) => getOption(t)); + const filter = useCallback( + ({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => { + if (current) { + setFilteredOptions([options.find((option) => option.value === dayjs(current).toDate().valueOf())!]); + } else + setFilteredOptions( + options.filter((option) => { + const time = dayjs(option.value); + return (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset)); + }) + ); }, - [] + [options] ); + return { options: filteredOptions, filter }; +}; + +type TimeRangeFieldProps = { + name: string; + className?: string; +}; + +const LazySelect = ({ + value, + min, + max, + ...props +}: Omit>, "value"> & { + value: ConfigType; + min?: ConfigType; + max?: ConfigType; +}) => { + // Lazy-loaded options, otherwise adding a field has a noticable redraw delay. + const { options, filter } = useOptions(); + + useEffect(() => { + filter({ current: value }); + }, [filter, value]); + return ( - <> + setOptions(timeOptions())} - onBlur={() => setOptions([])} - defaultValue={getOption(value)} + { onChange(new Date(option?.value as number)); - handleSelected(option?.value); }} /> ); @@ -107,17 +138,17 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => { ( - { + if (e.target.checked && !selected.includes(num)) { + setSelected(selected.concat([num])); + } else if (!e.target.checked && selected.includes(num)) { + setSelected(selected.slice(selected.indexOf(num), 1)); + } + }} + type="checkbox" + className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500 disabled:text-neutral-400" + /> + + + ))} + +
+ +
+
+ ); +}; + +export const DayRanges = ({ + name, + defaultValue = [defaultDayRange], +}: { + name: string; + defaultValue?: TimeRange[]; +}) => { + const { setValue, watch } = useFormContext(); + // XXX: Hack to make copying times work; `fields` is out of date until save. + const watcher = watch(name); + + const { fields, replace, append, remove } = useFieldArray({ + name, }); + useEffect(() => { + if (defaultValue.length && !fields.length) { + replace(defaultValue); + } + }, [replace, defaultValue, fields.length]); + 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); @@ -147,24 +231,11 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => { }; return ( -
-
- -
-
- {fields.map((field, index) => ( -
-
- -
+
+ {fields.map((field, index) => ( +
+
+
- ))} - {!fields.length && t("no_availability")} -
-
-
+ {index === 0 && ( +
+
+ )} +
+ ))} +
+ ); +}; + +const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => { + const { t } = useLocale(); + + const form = useFormContext(); + const watchAvailable = form.watch(`${name}.${day}`, []); + + return ( +
+ + {!!watchAvailable.length && ( +
+ +
+ )}
); }; diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index 327fdb1b35..bedb05a633 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -5,6 +5,8 @@ import Link from "next/link"; import { useRouter } from "next/router"; import React, { FC, useEffect, useState } from "react"; +import { nameOfDay } from "@calcom/lib/weekday"; + import classNames from "@lib/classNames"; import { useLocale } from "@lib/hooks/useLocale"; import { useSlots } from "@lib/hooks/useSlots"; @@ -18,6 +20,7 @@ type AvailableTimesProps = { afterBufferTime: number; eventTypeId: number; eventLength: number; + eventTypeSlug: string; slotInterval: number | null; date: Dayjs; users: { @@ -30,6 +33,7 @@ const AvailableTimes: FC = ({ date, eventLength, eventTypeId, + eventTypeSlug, slotInterval, minimumBookingNotice, timeFormat, @@ -41,7 +45,6 @@ const AvailableTimes: FC = ({ const { t, i18n } = useLocale(); const router = useRouter(); const { rescheduleUid } = router.query; - const { slots, loading, error } = useSlots({ date, slotInterval, @@ -63,9 +66,9 @@ const AvailableTimes: FC = ({ return (
- - {date.toDate().toLocaleString(i18n.language, { weekday: "long" })} - + + {nameOfDay(i18n.language, Number(date.format("d")))} + {date.format(", D ")} {date.toDate().toLocaleString(i18n.language, { month: "long" })} @@ -85,6 +88,7 @@ const AvailableTimes: FC = ({ ...router.query, date: slot.time.format(), type: eventTypeId, + slug: eventTypeSlug, }, }; @@ -101,7 +105,7 @@ const AvailableTimes: FC = ({ diff --git a/apps/web/components/booking/DatePicker.tsx b/apps/web/components/booking/DatePicker.tsx index 488e93236a..8cfd5bd9bc 100644 --- a/apps/web/components/booking/DatePicker.tsx +++ b/apps/web/components/booking/DatePicker.tsx @@ -5,13 +5,15 @@ import dayjsBusinessTime from "dayjs-business-time"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { memoize } from "lodash"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; + +import { useEmbedStyles } from "@calcom/embed-core"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import classNames from "@lib/classNames"; import { timeZone } from "@lib/clock"; import { weekdayNames } from "@lib/core/i18n/weekday"; import { doWorkAsync } from "@lib/doWorkAsync"; -import { useLocale } from "@lib/hooks/useLocale"; import getSlots from "@lib/slots"; import { WorkingHours } from "@lib/types/schedule"; @@ -85,7 +87,8 @@ function DatePicker({ }: DatePickerProps): JSX.Element { const { i18n } = useLocale(); const [browsingDate, setBrowsingDate] = useState(date); - + const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton"); + const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton"); const [month, setMonth] = useState(""); const [year, setYear] = useState(""); const [isFirstMonth, setIsFirstMonth] = useState(false); @@ -123,6 +126,8 @@ function DatePicker({ eventLength, minimumBookingNotice, workingHours, + }: Omit & { + browsingDate: Dayjs; } ) => { const date = browsingDate.startOf("day").date(day); @@ -185,7 +190,7 @@ function DatePicker({ batch: 1, name: "DatePicker", length: daysInMonth, - callback: (i: number, isLast) => { + callback: (i: number) => { let day = i + 1; days[daysInitialOffset + i] = { disabled: isDisabledMemoized(day, { @@ -232,17 +237,17 @@ function DatePicker({ ? "w-full sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 " : "w-full sm:pl-4") }> -
- - {month}{" "} - {year} +
+ + {month}{" "} + {year} -
+
-
+
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => ( -
+
{weekDay}
))} @@ -274,10 +279,15 @@ function DatePicker({
diff --git a/apps/web/lib/emails/templates/attendee-rescheduled-email.ts b/apps/web/lib/emails/templates/attendee-rescheduled-email.ts index 46bf6364cc..d232cf9906 100644 --- a/apps/web/lib/emails/templates/attendee-rescheduled-email.ts +++ b/apps/web/lib/emails/templates/attendee-rescheduled-email.ts @@ -45,7 +45,6 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail { text: this.getTextBody(), }; } - protected getTextBody(): string { // Only the original attendee can make changes to the event // Guests cannot @@ -56,6 +55,7 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail { ${this.getWhat()} ${this.getWhen()} ${this.getLocation()} + ${this.getDescription()} ${this.getAdditionalNotes()} ${this.attendee.language.translate("need_to_reschedule_or_cancel")} ${getCancelLink(this.calEvent)} @@ -114,6 +114,7 @@ ${this.getAdditionalNotes()} ${this.getWhen()} ${this.getWho()} ${this.getLocation()} + ${this.getDescription()} ${this.getAdditionalNotes()}
diff --git a/apps/web/lib/emails/templates/attendee-scheduled-email.ts b/apps/web/lib/emails/templates/attendee-scheduled-email.ts index 67b8a144eb..22730dc067 100644 --- a/apps/web/lib/emails/templates/attendee-scheduled-email.ts +++ b/apps/web/lib/emails/templates/attendee-scheduled-email.ts @@ -112,7 +112,18 @@ export default class AttendeeScheduledEmail { from: serverConfig.from, }; } - + protected getDescription(): string { + if (!this.calEvent.description) return ""; + return ` +

+
+

${this.calEvent.organizer.language.translate("description")}

+

${ + this.calEvent.description + }

+
+ `; + } protected getTextBody(): string { return ` ${this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled")} @@ -168,6 +179,7 @@ ${getRichDescription(this.calEvent)} ${this.getWhen()} ${this.getWho()} ${this.getLocation()} + ${this.getDescription()} ${this.getAdditionalNotes()}
@@ -286,13 +298,13 @@ ${getRichDescription(this.calEvent)} } protected getAdditionalNotes(): string { - if (!this.calEvent.description) return ""; + if (!this.calEvent.additionalNotes) return ""; return `

${this.calEvent.attendees[0].language.translate("additional_notes")}

${ - this.calEvent.description + this.calEvent.additionalNotes }

`; @@ -310,12 +322,16 @@ ${getRichDescription(this.calEvent)} protected getLocation(): string { let providerName = this.calEvent.location ? getAppName(this.calEvent.location) : ""; - if (this.calEvent.location && this.calEvent.location.includes("integrations:")) { const location = this.calEvent.location.split(":")[1]; providerName = location[0].toUpperCase() + location.slice(1); } + // If location its a url, probably we should be validating it with a custom library + if (this.calEvent.location && /^https?:\/\//.test(this.calEvent.location)) { + providerName = this.calEvent.location; + } + if (this.calEvent.videoCallData) { const meetingId = this.calEvent.videoCallData.id; const meetingPassword = this.calEvent.videoCallData.password; diff --git a/apps/web/lib/emails/templates/organizer-cancelled-email.ts b/apps/web/lib/emails/templates/organizer-cancelled-email.ts index 639e4a8fa2..7f7c6bb5ad 100644 --- a/apps/web/lib/emails/templates/organizer-cancelled-email.ts +++ b/apps/web/lib/emails/templates/organizer-cancelled-email.ts @@ -56,6 +56,7 @@ ${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendee ${this.getWhat()} ${this.getWhen()} ${this.getLocation()} +${this.getDescription()} ${this.getAdditionalNotes()} ${this.calEvent.cancellationReason && this.getCancellationReason()} `.replace(/(<([^>]+)>)/gi, ""); @@ -103,6 +104,7 @@ ${this.calEvent.cancellationReason && this.getCancellationReason()} ${this.getWhen()} ${this.getWho()} ${this.getLocation()} + ${this.getDescription()} ${this.getAdditionalNotes()} ${this.calEvent.cancellationReason && this.getCancellationReason()}
diff --git a/apps/web/lib/emails/templates/organizer-payment-refund-failed-email.ts b/apps/web/lib/emails/templates/organizer-payment-refund-failed-email.ts index 6b254980e7..40f4b524e7 100644 --- a/apps/web/lib/emails/templates/organizer-payment-refund-failed-email.ts +++ b/apps/web/lib/emails/templates/organizer-payment-refund-failed-email.ts @@ -58,6 +58,7 @@ ${ ${this.getWhat()} ${this.getWhen()} ${this.getLocation()} +${this.getDescription()} ${this.getAdditionalNotes()} `.replace(/(<([^>]+)>)/gi, ""); } @@ -136,6 +137,7 @@ ${this.getAdditionalNotes()} ${this.getWhen()} ${this.getWho()} ${this.getLocation()} + ${this.getDescription()} ${this.getAdditionalNotes()}
diff --git a/apps/web/lib/emails/templates/organizer-request-email.ts b/apps/web/lib/emails/templates/organizer-request-email.ts index 36c2817a87..b945932919 100644 --- a/apps/web/lib/emails/templates/organizer-request-email.ts +++ b/apps/web/lib/emails/templates/organizer-request-email.ts @@ -57,9 +57,10 @@ ${this.calEvent.organizer.language.translate("someone_requested_an_event")} ${this.getWhat()} ${this.getWhen()} ${this.getLocation()} +${this.getDescription()} ${this.getAdditionalNotes()} ${this.calEvent.organizer.language.translate("confirm_or_reject_request")} -${process.env.BASE_URL} + "/bookings/upcoming" +${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming" `.replace(/(<([^>]+)>)/gi, ""); } @@ -167,7 +168,7 @@ ${process.env.BASE_URL} + "/bookings/upcoming" protected getManageLink(): string { const manageText = this.calEvent.organizer.language.translate("confirm_or_reject_request"); - const manageLink = process.env.BASE_URL + "/bookings/upcoming"; + const manageLink = process.env.NEXT_PUBLIC_WEBAPP_URL + "/bookings/upcoming"; return `
${manageText} `; } } diff --git a/apps/web/lib/emails/templates/organizer-request-reminder-email.ts b/apps/web/lib/emails/templates/organizer-request-reminder-email.ts index 3d7c8bafd7..bc3acc7c08 100644 --- a/apps/web/lib/emails/templates/organizer-request-reminder-email.ts +++ b/apps/web/lib/emails/templates/organizer-request-reminder-email.ts @@ -57,9 +57,10 @@ ${this.calEvent.organizer.language.translate("someone_requested_an_event")} ${this.getWhat()} ${this.getWhen()} ${this.getLocation()} +${this.getDescription()} ${this.getAdditionalNotes()} ${this.calEvent.organizer.language.translate("confirm_or_reject_request")} -${process.env.BASE_URL} + "/bookings/upcoming" +${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming" `.replace(/(<([^>]+)>)/gi, ""); } @@ -105,6 +106,7 @@ ${process.env.BASE_URL} + "/bookings/upcoming" ${this.getWhen()} ${this.getWho()} ${this.getLocation()} + ${this.getDescription()} ${this.getAdditionalNotes()}
@@ -166,7 +168,7 @@ ${process.env.BASE_URL} + "/bookings/upcoming" protected getManageLink(): string { const manageText = this.calEvent.organizer.language.translate("confirm_or_reject_request"); - const manageLink = process.env.BASE_URL + "/bookings/upcoming"; + const manageLink = process.env.NEXT_PUBLIC_WEBAPP_URL + "/bookings/upcoming"; return `${manageText} `; } } diff --git a/apps/web/lib/emails/templates/organizer-rescheduled-email.ts b/apps/web/lib/emails/templates/organizer-rescheduled-email.ts index 5c9a7e5c4d..dd371c8cd4 100644 --- a/apps/web/lib/emails/templates/organizer-rescheduled-email.ts +++ b/apps/web/lib/emails/templates/organizer-rescheduled-email.ts @@ -62,6 +62,7 @@ ${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendee ${this.getWhat()} ${this.getWhen()} ${this.getLocation()} +${this.getDescription()} ${this.getAdditionalNotes()} ${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")} ${getCancelLink(this.calEvent)} @@ -110,6 +111,7 @@ ${getCancelLink(this.calEvent)} ${this.getWhen()} ${this.getWho()} ${this.getLocation()} + ${this.getDescription()} ${this.getAdditionalNotes()}
diff --git a/apps/web/lib/emails/templates/organizer-scheduled-email.ts b/apps/web/lib/emails/templates/organizer-scheduled-email.ts index fe53820f24..3582836050 100644 --- a/apps/web/lib/emails/templates/organizer-scheduled-email.ts +++ b/apps/web/lib/emails/templates/organizer-scheduled-email.ts @@ -175,6 +175,7 @@ ${getRichDescription(this.calEvent)} ${this.getWhen()} ${this.getWho()} ${this.getLocation()} + ${this.getDescription()} ${this.getAdditionalNotes()}
@@ -287,11 +288,24 @@ ${getRichDescription(this.calEvent)} } protected getAdditionalNotes(): string { - if (!this.calEvent.description) return ""; + if (!this.calEvent.additionalNotes) return ""; return `

${this.calEvent.organizer.language.translate("additional_notes")}

+

${ + this.calEvent.additionalNotes + }

+
+ `; + } + + protected getDescription(): string { + if (!this.calEvent.description) return ""; + return ` +

+
+

${this.calEvent.organizer.language.translate("description")}

${ this.calEvent.description }

@@ -307,6 +321,11 @@ ${getRichDescription(this.calEvent)} providerName = location[0].toUpperCase() + location.slice(1); } + // If location its a url, probably we should be validating it with a custom library + if (this.calEvent.location && /^https?:\/\//.test(this.calEvent.location)) { + providerName = this.calEvent.location; + } + if (this.calEvent.videoCallData) { const meetingId = this.calEvent.videoCallData.id; const meetingPassword = this.calEvent.videoCallData.password; diff --git a/apps/web/lib/hooks/useExposePlanGlobally.ts b/apps/web/lib/hooks/useExposePlanGlobally.ts new file mode 100644 index 0000000000..2e4132f4e4 --- /dev/null +++ b/apps/web/lib/hooks/useExposePlanGlobally.ts @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +import { UserPlan } from "@calcom/prisma/client"; + +/** + * TODO: It should be exposed at a single place. + */ +export function useExposePlanGlobally(plan: UserPlan) { + // Don't wait for component to mount. Do it ASAP. Delaying it would delay UI Configuration. + if (typeof window !== "undefined") { + // This variable is used by embed-iframe to determine if we should allow UI configuration + window.CalComPlan = plan; + } +} diff --git a/apps/web/lib/hooks/useSlots.ts b/apps/web/lib/hooks/useSlots.ts index ce400e42ad..a54e31e599 100644 --- a/apps/web/lib/hooks/useSlots.ts +++ b/apps/web/lib/hooks/useSlots.ts @@ -44,7 +44,7 @@ type getFilteredTimesProps = { export const getFilteredTimes = (props: getFilteredTimesProps) => { const { times, busy, eventLength, beforeBufferTime, afterBufferTime } = props; - const finalizationTime = times[times.length - 1].add(eventLength, "minutes"); + const finalizationTime = times[times.length - 1]?.add(eventLength, "minutes"); // Check for conflicts for (let i = times.length - 1; i >= 0; i -= 1) { // const totalSlotLength = eventLength + beforeBufferTime + afterBufferTime; diff --git a/apps/web/lib/hooks/useTheme.tsx b/apps/web/lib/hooks/useTheme.tsx index 2fab3f02b0..fbcec07820 100644 --- a/apps/web/lib/hooks/useTheme.tsx +++ b/apps/web/lib/hooks/useTheme.tsx @@ -1,6 +1,9 @@ import Head from "next/head"; +import { useRouter } from "next/router"; import { useEffect, useState } from "react"; +import { useEmbedTheme } from "@calcom/embed-core"; + import { Maybe } from "@trpc/server"; // This method is stringified and executed only on client. So, @@ -27,6 +30,9 @@ function applyThemeAndAddListener(theme: string) { // makes sure the ui doesn't flash export default function useTheme(theme?: Maybe) { const [isReady, setIsReady] = useState(false); + const embedTheme = useEmbedTheme(); + // Embed UI configuration takes more precedence over App Configuration + theme = embedTheme || theme; useEffect(() => { // TODO: isReady doesn't seem required now. This is also impacting PSI Score for pages which are using isReady. diff --git a/apps/web/lib/isSuccessRedirectAvailable.tsx b/apps/web/lib/isSuccessRedirectAvailable.tsx new file mode 100644 index 0000000000..cca3745224 --- /dev/null +++ b/apps/web/lib/isSuccessRedirectAvailable.tsx @@ -0,0 +1,14 @@ +import { Team, User } from ".prisma/client"; + +export function isSuccessRedirectAvailable( + eventType: { + users: { + plan: User["plan"]; + }[]; + } & { + team: Partial | null; + } +) { + // As Team Event is available in PRO plan only, just check if it's a team event. + return eventType.users[0]?.plan !== "FREE" || eventType.team; +} diff --git a/apps/web/lib/location.ts b/apps/web/lib/location.ts index 16e4265982..1f632382b7 100644 --- a/apps/web/lib/location.ts +++ b/apps/web/lib/location.ts @@ -1 +1 @@ -export * from "@calcom/lib/location"; +export * from "@calcom/core/location"; diff --git a/apps/web/lib/types/booking.ts b/apps/web/lib/types/booking.ts index 165c061e98..061226ea2c 100644 --- a/apps/web/lib/types/booking.ts +++ b/apps/web/lib/types/booking.ts @@ -13,6 +13,7 @@ export type BookingCreateBody = { userSignature: unknown; }; eventTypeId: number; + eventTypeSlug: string; guests?: string[]; location: string; name: string; diff --git a/apps/web/next-i18next.config.js b/apps/web/next-i18next.config.js index b813d8175f..336d25a896 100644 --- a/apps/web/next-i18next.config.js +++ b/apps/web/next-i18next.config.js @@ -25,6 +25,7 @@ module.exports = { "cs", "sr", "sv", + "vi", ], }, localePath: path.resolve("./public/static/locales"), diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 56430fc635..02ebea1769 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,3 +1,5 @@ +require("dotenv").config({ path: "../../.env" }); + const withTM = require("next-transpile-modules")([ "@calcom/app-store", "@calcom/core", @@ -6,20 +8,20 @@ const withTM = require("next-transpile-modules")([ "@calcom/prisma", "@calcom/stripe", "@calcom/ui", + "@calcom/embed-core", ]); const { i18n } = require("./next-i18next.config"); // So we can test deploy previews preview -if (process.env.VERCEL_URL && !process.env.BASE_URL) { - process.env.BASE_URL = "https://" + process.env.VERCEL_URL; +if (process.env.VERCEL_URL && !process.env.NEXT_PUBLIC_WEBAPP_URL) { + process.env.NEXT_PUBLIC_WEBAPP_URL = "https://" + process.env.VERCEL_URL; } -if (process.env.BASE_URL) { - process.env.NEXTAUTH_URL = process.env.BASE_URL + "/api/auth"; +if (process.env.NEXT_PUBLIC_WEBAPP_URL) { + process.env.NEXTAUTH_URL = process.env.NEXT_PUBLIC_WEBAPP_URL + "/api/auth"; } -if (!process.env.NEXT_PUBLIC_APP_URL) { - process.env.NEXT_PUBLIC_APP_URL = process.env.BASE_URL; +if (!process.env.NEXT_PUBLIC_WEBSITE_URL) { + process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL; } -process.env.NEXT_PUBLIC_BASE_URL = process.env.BASE_URL; if (!process.env.EMAIL_FROM) { console.warn( @@ -60,16 +62,9 @@ if (process.env.ANALYZE === "true") { plugins.push(withTM); -// prettier-ignore -module.exports = () => plugins.reduce((acc, next) => next(acc), { +/** @type {import("next").NextConfig} */ +const nextConfig = { i18n, - eslint: { - // This allows production builds to successfully complete even if the project has ESLint errors. - ignoreDuringBuilds: true, - }, - typescript: { - ignoreBuildErrors: true, - }, webpack: (config) => { config.resolve.fallback = { ...config.resolve.fallback, // if you miss it, all the other options in fallback, specified @@ -85,7 +80,7 @@ module.exports = () => plugins.reduce((acc, next) => next(acc), { source: "/:user/avatar.png", destination: "/api/user/avatar?username=:user", }, - ] + ]; }, async redirects() { return [ @@ -100,10 +95,12 @@ module.exports = () => plugins.reduce((acc, next) => next(acc), { permanent: true, }, { - source: '/call/:path*', - destination: '/video/:path*', - permanent: false - } + source: "/call/:path*", + destination: "/video/:path*", + permanent: false, + }, ]; }, -}); +}; + +module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json index 2e64d0dd12..075c222fe8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -54,10 +54,10 @@ "@radix-ui/react-tooltip": "^0.1.0", "@stripe/react-stripe-js": "^1.4.1", "@stripe/stripe-js": "^1.16.0", - "@trpc/client": "^9.16.0", - "@trpc/next": "^9.16.0", - "@trpc/react": "^9.16.0", - "@trpc/server": "^9.16.0", + "@trpc/client": "^9.22.0", + "@trpc/next": "^9.22.0", + "@trpc/react": "^9.22.0", + "@trpc/server": "^9.22.0", "@vercel/edge-functions-ui": "^0.2.1", "@wojtekmaj/react-daterange-picker": "^3.3.1", "accept-language-parser": "^1.5.0", @@ -67,15 +67,18 @@ "dayjs": "^1.10.4", "dayjs-business-time": "^1.0.4", "googleapis": "^84.0.0", + "gray-matter": "^4.0.3", "handlebars": "^4.7.7", "ical.js": "^1.4.0", "ics": "^2.31.0", "jimp": "^0.16.1", "lodash": "^4.17.21", "micro": "^9.3.4", + "mime-types": "^2.1.35", "next": "^12.1.0", "next-auth": "^4.0.6", "next-i18next": "^8.9.0", + "next-mdx-remote": "^4.0.2", "next-seo": "^4.26.0", "next-transpile-modules": "^9.0.0", "nodemailer": "^6.7.2", @@ -87,7 +90,7 @@ "react-digit-input": "^2.1.0", "react-dom": "^17.0.2", "react-easy-crop": "^3.5.2", - "react-hook-form": "^7.20.4", + "react-hook-form": "^7.29.0", "react-hot-toast": "^2.1.0", "react-intl": "^5.22.0", "react-live-chat-loader": "^2.7.3", @@ -105,9 +108,10 @@ "superjson": "1.8.1", "uuid": "^8.3.2", "web3": "^1.6.1", - "zod": "^3.8.2" + "zod": "^3.14.4" }, "devDependencies": { + "@babel/core": "^7.17.8", "@calcom/config": "*", "@calcom/types": "*", "@microsoft/microsoft-graph-types-beta": "0.15.0-preview", @@ -119,6 +123,7 @@ "@types/jest": "^27.0.3", "@types/lodash": "^4.14.177", "@types/micro": "^7.3.6", + "@types/mime-types": "^2.1.1", "@types/module-alias": "^2.0.1", "@types/node": "^16.11.24", "@types/nodemailer": "^6.4.4", diff --git a/apps/web/pages/404.tsx b/apps/web/pages/404.tsx index 10f2496698..45ca57c24f 100644 --- a/apps/web/pages/404.tsx +++ b/apps/web/pages/404.tsx @@ -39,7 +39,7 @@ export default function Custom404() { const isSubpage = router.asPath.includes("/", 2); const isSignup = router.asPath.includes("/signup"); - const isCalcom = process.env.NEXT_PUBLIC_BASE_URL === "https://app.cal.com"; + const isCalcom = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com"; return ( <> @@ -53,7 +53,7 @@ export default function Custom404() { />
- {isSignup && process.env.NEXT_PUBLIC_BASE_URL !== "https://app.cal.com" ? ( + {isSignup && process.env.NEXT_PUBLIC_WEBAPP_URL !== "https://app.cal.com" ? (

diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 288dac2c6c..672841db1d 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -1,24 +1,34 @@ import { ArrowRightIcon } from "@heroicons/react/outline"; import { BadgeCheckIcon } from "@heroicons/react/solid"; +import { UserPlan } from "@prisma/client"; import { GetServerSidePropsContext } from "next"; import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter } from "next/router"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Toaster } from "react-hot-toast"; import { JSONObject } from "superjson/dist/types"; -import { useLocale } from "@lib/hooks/useLocale"; +import { sdkActionManager, useEmbedStyles, useIsEmbed } from "@calcom/embed-core"; +import defaultEvents, { + getDynamicEventDescription, + getUsernameList, + getUsernameSlugLink, +} from "@calcom/lib/defaultEvents"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally"; import useTheme from "@lib/hooks/useTheme"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; +import AvatarGroup from "@components/ui/AvatarGroup"; import { AvatarSSR } from "@components/ui/AvatarSSR"; import { ssrInit } from "@server/lib/ssr"; const EventTypeDescription = dynamic(() => import("@components/eventtype/EventTypeDescription")); -const HeadSeo = dynamic(() => import("@components/seo/head-seo").then((mod) => mod.HeadSeo)); +const HeadSeo = dynamic(() => import("@components/seo/head-seo")); const CryptoSection = dynamic(() => import("../ee/components/web3/CryptoSection")); interface EvtsToVerify { @@ -26,37 +36,104 @@ interface EvtsToVerify { } export default function User(props: inferSSRProps) { - const { Theme } = useTheme(props.user.theme); - const { user, eventTypes } = props; + const { users } = props; + const [user] = users; //To be used when we only have a single user, not dynamic group + const { Theme } = useTheme(user.theme); const { t } = useLocale(); const router = useRouter(); + const isSingleUser = props.users.length === 1; + const isDynamicGroup = props.users.length > 1; + const dynamicNames = isDynamicGroup + ? props.users.map((user) => { + return user.name || ""; + }) + : []; + const dynamicUsernames = isDynamicGroup + ? props.users.map((user) => { + return user.username || ""; + }) + : []; + const eventTypes = isDynamicGroup + ? defaultEvents.map((event) => { + event.description = getDynamicEventDescription(dynamicUsernames, event.slug); + return event; + }) + : props.eventTypes; + const groupEventTypes = props.users.some((user) => { + return !user.allowDynamicBooking; + }) ? ( +

+
+
+

{" " + t("unavailable")}

+

{t("user_dynamic_booking_disabled")}

+
+
+
+ ) : ( + + ); + const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem"); const query = { ...router.query }; delete query.user; // So it doesn't display in the Link (and make tests fail) - + useExposePlanGlobally("PRO"); const nameOrUsername = user.name || user.username || ""; const [evtsToVerify, setEvtsToVerify] = useState({}); + const isEmbed = useIsEmbed(); return ( <> -
+
-
- -

- {nameOrUsername} - {user.verified && ( - - )} -

-

{user.bio}

-
+ {isSingleUser && ( // When we deal with a single user, not dynamic group +
+ +

+ {nameOrUsername} + {user.verified && ( + + )} +

+

{user.bio}

+
+ )}
{user.away ? (
@@ -67,11 +144,13 @@ export default function User(props: inferSSRProps) {

{t("user_away_description")}

+ ) : isDynamicGroup ? ( //When we deal with dynamic group (users > 1) + groupEventTypes ) : ( eventTypes.map((type) => (
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */} @@ -91,6 +170,10 @@ export default function User(props: inferSSRProps) { "You must verify a wallet with a token belonging to the specified smart contract first", "error" ); + } else { + sdkActionManager?.fire("eventTypeSelected", { + eventType: type, + }); } }} className="block w-full px-6 py-4" @@ -128,50 +211,8 @@ export default function User(props: inferSSRProps) { ); } -export const getServerSideProps = async (context: GetServerSidePropsContext) => { - const ssr = await ssrInit(context); - const crypto = require("crypto"); - - const username = (context.query.user as string).toLowerCase(); - const dataFetchStart = Date.now(); - const user = await prisma.user.findUnique({ - where: { - username: username.toLowerCase(), - }, - select: { - id: true, - username: true, - email: true, - name: true, - bio: true, - avatar: true, - theme: true, - plan: true, - away: true, - verified: true, - }, - }); - - if (!user) { - return { - notFound: true, - }; - } - - const credentials = await prisma.credential.findMany({ - where: { - userId: user.id, - }, - select: { - id: true, - type: true, - key: true, - }, - }); - - const web3Credentials = credentials.find((credential) => credential.type.includes("_web3")); - - const eventTypesWithHidden = await prisma.eventType.findMany({ +const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) => { + return await prisma.eventType.findMany({ where: { AND: [ { @@ -180,12 +221,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => { OR: [ { - userId: user.id, + userId, }, { users: { some: { - id: user.id, + id: userId, }, }, }, @@ -213,8 +254,63 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => currency: true, metadata: true, }, - take: user.plan === "FREE" ? 1 : undefined, + take: plan === UserPlan.FREE ? 1 : undefined, }); +}; + +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const ssr = await ssrInit(context); + const crypto = require("crypto"); + + const usernameList = getUsernameList(context.query.user as string); + const dataFetchStart = Date.now(); + const users = await prisma.user.findMany({ + where: { + username: { + in: usernameList, + }, + }, + select: { + id: true, + username: true, + email: true, + name: true, + bio: true, + avatar: true, + theme: true, + plan: true, + away: true, + verified: true, + allowDynamicBooking: true, + }, + }); + + if (!users.length) { + return { + notFound: true, + }; + } + + const isDynamicGroup = users.length > 1; + + const [user] = users; //to be used when dealing with single user, not dynamic group + const usersIds = users.map((user) => user.id); + const credentials = await prisma.credential.findMany({ + where: { + userId: { + in: usersIds, + }, + }, + select: { + id: true, + type: true, + key: true, + }, + }); + + const web3Credentials = credentials.find((credential) => credential.type.includes("_web3")); + + const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id, user.plan); const dataFetchEnd = Date.now(); if (context.query.log === "1") { context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`); @@ -232,8 +328,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { props: { + users, user: { - ...user, emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), }, eventTypes, diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index d991b81f3f..557b5139d8 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -1,7 +1,11 @@ import { Prisma } from "@prisma/client"; +import { UserPlan } from "@prisma/client"; import { GetServerSidePropsContext } from "next"; import { JSONObject } from "superjson/dist/types"; +import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + import { asStringOrNull } from "@lib/asStringOrNull"; import { getWorkingHours } from "@lib/availability"; import prisma from "@lib/prisma"; @@ -14,13 +18,33 @@ import { ssrInit } from "@server/lib/ssr"; export type AvailabilityPageProps = inferSSRProps; export default function Type(props: AvailabilityPageProps) { - return ; + const { t } = useLocale(); + return props.isDynamicGroup && !props.profile.allowDynamicBooking ? ( +
+
+
+
+
+

+ {" " + t("unavailable")} +

+

{t("user_dynamic_booking_disabled")}

+
+
+
+
+
+ ) : ( + + ); } export const getServerSideProps = async (context: GetServerSidePropsContext) => { const ssr = await ssrInit(context); // get query params and typecast them to string // (would be even better to assert them instead of typecasting) + const usernameList = getUsernameList(context.query.user as string); + const userParam = asStringOrNull(context.query.user); const typeParam = asStringOrNull(context.query.type); const dateParam = asStringOrNull(context.query.date); @@ -49,6 +73,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => timeZone: true, }, }, + hidden: true, + slug: true, minimumBookingNotice: true, beforeEventBuffer: true, afterEventBuffer: true, @@ -67,9 +93,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, }); - const user = await prisma.user.findUnique({ + const users = await prisma.user.findMany({ where: { - username: userParam.toLowerCase(), + username: { + in: usernameList, + }, }, select: { id: true, @@ -87,6 +115,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => brandColor: true, darkBrandColor: true, defaultScheduleId: true, + allowDynamicBooking: true, schedules: { select: { availability: true, @@ -112,13 +141,16 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, }); - if (!user) { + if (!users || !users.length) { return { notFound: true, }; } + const [user] = users; //to be used when dealing with single user, not dynamic group + const isSingleUser = users.length === 1; + const isDynamicGroup = users.length > 1; - if (user.eventTypes.length !== 1) { + if (isSingleUser && user.eventTypes.length !== 1) { const eventTypeBackwardsCompat = await prisma.eventType.findFirst({ where: { AND: [ @@ -150,10 +182,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => user.eventTypes.push(eventTypeBackwardsCompat); } - const [eventType] = user.eventTypes; + let [eventType] = user.eventTypes; - // check this is the first event - if (user.plan === "FREE") { + if (isDynamicGroup) { + eventType = getDefaultEvent(typeParam); + eventType["users"] = users.map((user) => { + return { + avatar: user.avatar as string, + name: user.name as string, + username: user.username as string, + hideBranding: user.hideBranding, + plan: user.plan, + timeZone: user.timeZone as string, + }; + }); + } + + // check this is the first event for free user + if (isSingleUser && user.plan === UserPlan.FREE) { const firstEventType = await prisma.eventType.findFirst({ where: { OR: [ @@ -169,6 +215,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, ], }, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", + }, + ], select: { id: true, }, @@ -194,21 +248,41 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => )[0], }; - const timeZone = schedule.timeZone || eventType.timeZone || user.timeZone; + const timeZone = isDynamicGroup ? undefined : schedule.timeZone || eventType.timeZone || user.timeZone; const workingHours = getWorkingHours( { timeZone, }, - schedule.availability || (eventType.availability.length ? eventType.availability : user.availability) + isDynamicGroup + ? eventType.availability || undefined + : schedule.availability || (eventType.availability.length ? eventType.availability : user.availability) ); - eventTypeObject.schedule = null; eventTypeObject.availability = []; - return { - props: { - profile: { + const dynamicNames = isDynamicGroup + ? users.map((user) => { + return user.name || ""; + }) + : []; + + const profile = isDynamicGroup + ? { + name: getGroupName(dynamicNames), + image: null, + slug: typeParam, + theme: null, + weekStart: "Sunday", + brandColor: "", + darkBrandColor: "", + allowDynamicBooking: users.some((user) => { + return !user.allowDynamicBooking; + }) + ? false + : true, + } + : { name: user.name || user.username, image: user.avatar, slug: user.username, @@ -216,7 +290,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => weekStart: user.weekStart, brandColor: user.brandColor, darkBrandColor: user.darkBrandColor, - }, + }; + + return { + props: { + isDynamicGroup, + profile, + plan: user.plan, date: dateParam, eventType: eventTypeObject, workingHours, diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx index 3c3b297809..3966bcd984 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -5,12 +5,22 @@ import utc from "dayjs/plugin/utc"; import { GetServerSidePropsContext } from "next"; import { JSONObject } from "superjson/dist/types"; +import { getLocationLabels } from "@calcom/app-store/utils"; +import { + getDefaultEvent, + getDynamicEventName, + getGroupName, + getUsernameList, +} from "@calcom/lib/defaultEvents"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + import { asStringOrThrow } from "@lib/asStringOrNull"; import prisma from "@lib/prisma"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import BookingPage from "@components/booking/pages/BookingPage"; +import { getTranslation } from "@server/lib/i18n"; import { ssrInit } from "@server/lib/ssr"; dayjs.extend(utc); @@ -19,14 +29,36 @@ dayjs.extend(timezone); export type BookPageProps = inferSSRProps; export default function Book(props: BookPageProps) { - return ; + const { t } = useLocale(); + return props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? ( +
+
+
+
+
+

+ {" " + t("unavailable")} +

+

{t("user_dynamic_booking_disabled")}

+
+
+
+
+
+ ) : ( + + ); } export async function getServerSideProps(context: GetServerSidePropsContext) { const ssr = await ssrInit(context); - const user = await prisma.user.findUnique({ + const usernameList = getUsernameList(asStringOrThrow(context.query.user as string)); + const eventTypeSlug = context.query.slug as string; + const users = await prisma.user.findMany({ where: { - username: asStringOrThrow(context.query.user).toLowerCase(), + username: { + in: usernameList, + }, }, select: { id: true, @@ -38,50 +70,56 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { theme: true, brandColor: true, darkBrandColor: true, + allowDynamicBooking: true, }, }); - if (!user) return { notFound: true }; - - const eventTypeRaw = await prisma.eventType.findUnique({ - where: { - id: parseInt(asStringOrThrow(context.query.type)), - }, - select: { - id: true, - title: true, - slug: true, - description: true, - length: true, - locations: true, - customInputs: true, - periodType: true, - periodDays: true, - periodStartDate: true, - periodEndDate: true, - metadata: true, - periodCountCalendarDays: true, - price: true, - currency: true, - disableGuests: true, - users: { - select: { - username: true, - name: true, - email: true, - bio: true, - avatar: true, - theme: true, - }, - }, - }, - }); + if (!users.length) return { notFound: true }; + const [user] = users; + const eventTypeRaw = + usernameList.length > 1 + ? getDefaultEvent(eventTypeSlug) + : await prisma.eventType.findUnique({ + where: { + id: parseInt(asStringOrThrow(context.query.type)), + }, + select: { + id: true, + title: true, + slug: true, + description: true, + length: true, + locations: true, + customInputs: true, + periodType: true, + periodDays: true, + periodStartDate: true, + periodEndDate: true, + metadata: true, + periodCountCalendarDays: true, + price: true, + currency: true, + disableGuests: true, + users: { + select: { + username: true, + name: true, + email: true, + bio: true, + avatar: true, + theme: true, + }, + }, + }, + }); if (!eventTypeRaw) return { notFound: true }; const credentials = await prisma.credential.findMany({ where: { - userId: user.id, + userId: { + in: users.map((user) => user.id), + }, }, select: { id: true, @@ -133,19 +171,49 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { booking = await getBooking(); } - return { - props: { - profile: { - slug: user.username, - name: user.name, + const isDynamicGroupBooking = users.length > 1; + + const dynamicNames = isDynamicGroupBooking + ? users.map((user) => { + return user.name || ""; + }) + : []; + + const profile = isDynamicGroupBooking + ? { + name: getGroupName(dynamicNames), + image: null, + slug: eventTypeSlug, + theme: null, + brandColor: "", + darkBrandColor: "", + allowDynamicBooking: users.some((user) => { + return !user.allowDynamicBooking; + }) + ? false + : true, + eventName: getDynamicEventName(dynamicNames, eventTypeSlug), + } + : { + name: user.name || user.username, image: user.avatar, + slug: user.username, theme: user.theme, brandColor: user.brandColor, darkBrandColor: user.darkBrandColor, - }, + eventName: null, + }; + + const t = await getTranslation(context.locale ?? "en", "common"); + + return { + props: { + locationLabels: getLocationLabels(t), + profile, eventType: eventTypeObject, booking, trpcState: ssr.dehydrate(), + isDynamicGroupBooking, }, }; } diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 46c575fd82..77b399824c 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -1,8 +1,11 @@ import { DefaultSeo } from "next-seo"; import Head from "next/head"; +import { useEffect } from "react"; // import { ReactQueryDevtools } from "react-query/devtools"; import superjson from "superjson"; +import "@calcom/embed-core/src/embed-iframe"; + import AppProviders, { AppProps } from "@lib/app-providers"; import { seoConfig } from "@lib/config/next-seo.config"; @@ -20,13 +23,20 @@ import "../styles/fonts.css"; import "../styles/globals.css"; function MyApp(props: AppProps) { - const { Component, pageProps, err } = props; + const { Component, pageProps, err, router } = props; + let pageStatus = "200"; + if (router.pathname === "/404") { + pageStatus = "404"; + } else if (router.pathname === "/500") { + pageStatus = "500"; + } return ( + diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx index e105342684..9360d3aa3e 100644 --- a/apps/web/pages/_document.tsx +++ b/apps/web/pages/_document.tsx @@ -5,10 +5,12 @@ type Props = Record & DocumentProps; class MyDocument extends Document { static async getInitialProps(ctx: DocumentContext) { const initialProps = await Document.getInitialProps(ctx); - return { ...initialProps }; + const isEmbed = ctx.req?.url?.includes("embed"); + return { ...initialProps, isEmbed }; } render() { + const props = this.props; const { locale } = this.props.__NEXT_DATA__; const dir = locale === "ar" || locale === "he" ? "rtl" : "ltr"; @@ -23,7 +25,9 @@ class MyDocument extends Document { - + + {/* Keep the embed hidden till parent initializes and gives it the appropriate styles */} +
diff --git a/apps/web/pages/api/app-store/[...static].ts b/apps/web/pages/api/app-store/[...static].ts new file mode 100644 index 0000000000..3c6cac8195 --- /dev/null +++ b/apps/web/pages/api/app-store/[...static].ts @@ -0,0 +1,30 @@ +import fs from "fs"; +import mime from "mime-types"; +import type { NextApiRequest, NextApiResponse } from "next"; +import path from "path"; + +/** + * This endpoint should allow us to access to the private files in the static + * folder of each individual app in the App Store. + * @example + * ```text + * Requesting: `/api/app-store/zoomvideo/icon.svg` from a public URL should + * serve us the file located at: `/packages/app-store/zoomvideo/static/icon.svg` + * ``` + * This will allow us to keep all app-specific static assets in the same directory. + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const [appName, fileName] = Array.isArray(req.query.static) ? req.query.static : [req.query.static]; + const fileNameParts = fileName.split("."); + const { [fileNameParts.length - 1]: fileExtension } = fileNameParts; + const STATIC_PATH = path.join(process.cwd(), "..", "..", "packages/app-store", appName, "static", fileName); + + try { + const imageBuffer = fs.readFileSync(STATIC_PATH); + const mimeType = mime.lookup(fileExtension); + if (mimeType) res.setHeader("Content-Type", mimeType); + res.send(imageBuffer); + } catch (e) { + res.status(400).json({ error: true, message: "Resource not found" }); + } +} diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index e415e528de..c9ec0f63d2 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -55,7 +55,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) passwordRequest = createdResetPasswordRequest; } - const resetLink = `${process.env.BASE_URL}/auth/forgot-password/${passwordRequest.id}`; + const resetLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/forgot-password/${passwordRequest.id}`; const passwordEmail: PasswordReset = { language: t, user: maybeUser, @@ -67,9 +67,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) /** So we can test the password reset flow on CI */ if (process.env.NEXT_PUBLIC_IS_E2E) { return res.status(201).json({ message: "Reset Requested", resetLink }); + } else { + return res.status(201).json({ message: "Reset Requested" }); } - - return res.status(201).json({ message: "Reset Requested" }); } catch (reason) { // console.error(reason); return res.status(500).json({ message: "Unable to create password reset request" }); diff --git a/apps/web/pages/api/availability/[user].ts b/apps/web/pages/api/availability/[user].ts index aa001df945..96ce33f5ef 100644 --- a/apps/web/pages/api/availability/[user].ts +++ b/apps/web/pages/api/availability/[user].ts @@ -87,8 +87,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // busyTimes.push(...await getBusyVideoTimes(currentUser.credentials, dateFrom.format(), dateTo.format())); const bufferedBusyTimes = busyTimes.map((a) => ({ - start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(), - end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(), + start: dayjs(a.start).subtract(currentUser.bufferTime, "minute"), + end: dayjs(a.end).add(currentUser.bufferTime, "minute"), })); const schedule = eventType?.schedule diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index b9b933d348..ec99dfcac2 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -12,6 +12,7 @@ import { v5 as uuidv5 } from "uuid"; import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; import EventManager from "@calcom/core/EventManager"; import { getBusyVideoTimes } from "@calcom/core/videoClient"; +import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; import notEmpty from "@calcom/lib/notEmpty"; @@ -181,10 +182,49 @@ const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNam return userNamesWithBookingCounts; }; +const getEventTypesFromDB = async (eventTypeId: number) => { + return await prisma.eventType.findUnique({ + rejectOnNotFound: true, + where: { + id: eventTypeId, + }, + select: { + users: userSelect, + team: { + select: { + id: true, + name: true, + }, + }, + title: true, + length: true, + eventName: true, + schedulingType: true, + description: true, + periodType: true, + periodStartDate: true, + periodEndDate: true, + periodDays: true, + periodCountCalendarDays: true, + requiresConfirmation: true, + userId: true, + price: true, + currency: true, + metadata: true, + destinationCalendar: true, + hideCalendarNotes: true, + }, + }); +}; + type User = Prisma.UserGetPayload; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const reqBody = req.body as BookingCreateBody; + + // handle dynamic user + const dynamicUserList = getUsernameList(reqBody?.user); + const eventTypeSlug = reqBody.eventTypeSlug; const eventTypeId = reqBody.eventTypeId; const tAttendees = await getTranslation(reqBody.language ?? "en", "common"); const tGuests = await getTranslation("en", "common"); @@ -204,40 +244,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json(error); } - const eventType = await prisma.eventType.findUnique({ - rejectOnNotFound: true, - where: { - id: eventTypeId, - }, - select: { - users: userSelect, - team: { - select: { - id: true, - name: true, - }, - }, - title: true, - length: true, - eventName: true, - schedulingType: true, - periodType: true, - periodStartDate: true, - periodEndDate: true, - periodDays: true, - periodCountCalendarDays: true, - requiresConfirmation: true, - userId: true, - price: true, - currency: true, - metadata: true, - destinationCalendar: true, - }, - }); - + const eventType = !eventTypeId ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId); if (!eventType) return res.status(404).json({ message: "eventType.notFound" }); - let users = eventType.users; + let users = !eventTypeId + ? await prisma.user.findMany({ + where: { + username: { + in: dynamicUserList, + }, + }, + ...userSelect, + }) + : eventType.users; /* If this event was pre-relationship migration */ if (!users.length && eventType.userId) { @@ -319,17 +338,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) t: tOrganizer, }; - const description = + const additionalNotes = reqBody.notes + reqBody.customInputs.reduce( (str, input) => str + "

" + input.label + ":
" + input.value, "" ); - const evt: CalendarEvent = { type: eventType.title, title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately - description, + description: eventType.description, + additionalNotes, startTime: reqBody.start, endTime: reqBody.end, organizer: { @@ -340,8 +359,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, attendees: attendeesList, location: reqBody.location, // Will be processed by the EventManager later. - /** For team events, we will need to handle each member destinationCalendar eventually */ + /** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */ destinationCalendar: eventType.destinationCalendar || users[0].destinationCalendar, + hideCalendarNotes: eventType.hideCalendarNotes, }; if (eventType.schedulingType === SchedulingType.COLLECTIVE) { @@ -361,6 +381,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await verifyAccount(web3Details.userSignature, web3Details.userWallet); } + const eventTypeRel = !eventTypeId + ? {} + : { + connect: { + id: eventTypeId, + }, + }; + + const dynamicEventSlugRef = !eventTypeId ? eventTypeSlug : null; + const dynamicGroupSlugRef = !eventTypeId ? (reqBody.user as string).toLowerCase() : null; + return prisma.booking.create({ include: { user: { @@ -373,14 +404,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) title: evt.title, startTime: dayjs(evt.startTime).toDate(), endTime: dayjs(evt.endTime).toDate(), - description: evt.description, + description: evt.additionalNotes, confirmed: (!eventType.requiresConfirmation && !eventType.price) || !!rescheduleUid, location: evt.location, - eventType: { - connect: { - id: eventTypeId, - }, - }, + eventType: eventTypeRel, attendees: { createMany: { data: evt.attendees.map((attendee) => { @@ -396,6 +423,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }), }, }, + dynamicEventSlugRef, + dynamicGroupSlugRef, user: { connect: { id: users[0].id, @@ -528,6 +557,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (rescheduleUid) { // Use EventManager to conditionally use all needed integrations. const updateManager = await eventManager.update(evt, rescheduleUid); + // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back + // to the default description when we are sending the emails. + evt.description = eventType.description; results = updateManager.results; referencesToCreate = updateManager.referencesToCreate; @@ -562,6 +594,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Use EventManager to conditionally use all needed integrations. const createManager = await eventManager.create(evt); + // This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back + // to the default description when we are sending the emails. + evt.description = eventType.description; + results = createManager.results; referencesToCreate = createManager.referencesToCreate; @@ -636,7 +672,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }) ); await Promise.all(promises); - + // Avoid passing referencesToCreate with id unique constrain values await prisma.booking.update({ where: { uid: booking.uid, diff --git a/apps/web/pages/api/cancel.ts b/apps/web/pages/api/cancel.ts index c0977be385..d5dc45e6e5 100644 --- a/apps/web/pages/api/cancel.ts +++ b/apps/web/pages/api/cancel.ts @@ -112,7 +112,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const evt: CalendarEvent = { title: bookingToDelete?.title, - type: bookingToDelete?.eventType?.title as string, + type: (bookingToDelete?.eventType?.title as string) || bookingToDelete?.title, description: bookingToDelete?.description || "", startTime: bookingToDelete?.startTime ? dayjs(bookingToDelete.startTime).format() : "", endTime: bookingToDelete?.endTime ? dayjs(bookingToDelete.endTime).format() : "", @@ -128,7 +128,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar, cancellationReason: cancellationReason, }; - // Hook up the webhook logic here const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED"; // Send Webhook call if hooked to BOOKING.CANCELLED diff --git a/apps/web/pages/api/integrations/[...args].ts b/apps/web/pages/api/integrations/[...args].ts index 8edfec1286..ec1fcd6113 100644 --- a/apps/web/pages/api/integrations/[...args].ts +++ b/apps/web/pages/api/integrations/[...args].ts @@ -1,4 +1,4 @@ -import { NextApiRequest, NextApiResponse } from "next"; +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import appStore from "@calcom/app-store"; @@ -9,11 +9,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { // Check that user is authenticated req.session = await getSession({ req }); - if (!req.session?.user?.id) { - res.status(401).json({ message: "You must be logged in to do this" }); - return; - } - const { args } = req.query; if (!Array.isArray(args)) { @@ -26,14 +21,19 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { try { // TODO: Find a way to dynamically import these modules // const app = (await import(`@calcom/${appName}`)).default; - const handler = appStore[appName].api[apiEndpoint]; + const app = appStore[appName as keyof typeof appStore]; + if (!(app && "api" in app && apiEndpoint in app.api)) + throw new HttpError({ statusCode: 404, message: `API handler not found` }); + + const handler = app.api[apiEndpoint as keyof typeof app.api] as NextApiHandler; + if (typeof handler !== "function") throw new HttpError({ statusCode: 404, message: `API handler not found` }); const response = await handler(req, res); console.log("response", response); - res.status(200); + return res.status(200); } catch (error) { console.error(error); if (error instanceof HttpError) { diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index ce10d5646e..7a85a1af50 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -8,7 +8,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // const username = req.url?.substring(1, req.url.lastIndexOf("/")); const username = req.query.username as string; const user = await prisma.user.findUnique({ - rejectOnNotFound: true, where: { username: username, }, @@ -20,9 +19,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const emailMd5 = crypto .createHash("md5") - .update(user.email as string) + .update((user?.email as string) || "guest@example.com") .digest("hex"); - const img = user.avatar; + const img = user?.avatar; if (!img) { res.writeHead(302, { Location: defaultAvatarSrc({ md5: emailMd5 }), diff --git a/apps/web/pages/apps/[slug].tsx b/apps/web/pages/apps/[slug].tsx index 70fb094e5b..bf244935ac 100644 --- a/apps/web/pages/apps/[slug].tsx +++ b/apps/web/pages/apps/[slug].tsx @@ -1,12 +1,51 @@ +import fs from "fs"; +import matter from "gray-matter"; import { GetStaticPaths, GetStaticPathsResult, GetStaticPropsContext } from "next"; +import { MDXRemote } from "next-mdx-remote"; +import { serialize } from "next-mdx-remote/serialize"; +import Image from "next/image"; +import Link from "next/link"; +import path from "path"; import { getAppRegistry } from "@calcom/app-store/_appRegistry"; +import useMediaQuery from "@lib/hooks/useMediaQuery"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import App from "@components/App"; +import Slider from "@components/apps/Slider"; -function SingleAppPage({ data }: inferSSRProps) { +const components = { + a: ({ href = "", ...otherProps }: JSX.IntrinsicElements["a"]) => ( + + + + ), + img: ({ src = "", alt = "", placeholder, ...rest }: JSX.IntrinsicElements["img"]) => ( + {alt} + ), + Slider: ({ items }: { items: string[] }) => { + const isTabletAndUp = useMediaQuery("(min-width: 960px)"); + return ( + + items={items} + title="Screenshots" + options={{ + perView: 1, + }} + renderItem={(item) => + isTabletAndUp ? ( + + ) : ( + + ) + } + /> + ); + }, +}; + +function SingleAppPage({ data, source }: inferSSRProps) { return ( ) { email={data.email} // tos="https://zoom.us/terms" // privacy="https://zoom.us/privacy" - body={data.description} + body={} /> ); } @@ -58,8 +97,25 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => { }; } + const appDirname = singleApp.type.replace("_", ""); + const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/README.mdx`); + const postFilePath = path.join(README_PATH); + let source = ""; + + try { + /* If the app doesn't have a README we fallback to the packagfe description */ + source = fs.readFileSync(postFilePath).toString(); + } catch (error) { + console.log(`No README.mdx provided for: ${appDirname}`); + source = singleApp.description; + } + + const { content, data } = matter(source); + const mdxSource = await serialize(content, { scope: data }); + return { props: { + source: mdxSource, data: singleApp, }, }; diff --git a/apps/web/pages/apps/categories/[category].tsx b/apps/web/pages/apps/categories/[category].tsx index 0e0b0dc406..89e7fcdb53 100644 --- a/apps/web/pages/apps/categories/[category].tsx +++ b/apps/web/pages/apps/categories/[category].tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/router"; import { getAppRegistry } from "@calcom/app-store/_appRegistry"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import Button from "@calcom/ui/Button"; import Shell from "@components/Shell"; import AppCard from "@components/apps/AppCard"; @@ -16,9 +15,9 @@ export default function Apps({ appStore }: InferGetStaticPropsType - -
-
+ +
+
{t("browse_apps")} @@ -28,7 +27,7 @@ export default function Apps({ appStore }: InferGetStaticPropsType

All {router.query.category} apps

-
+
{appStore.map((app) => { return ( app.category === router.query.category && ( diff --git a/apps/web/pages/apps/categories/index.tsx b/apps/web/pages/apps/categories/index.tsx new file mode 100644 index 0000000000..68444b497c --- /dev/null +++ b/apps/web/pages/apps/categories/index.tsx @@ -0,0 +1,44 @@ +import { ChevronLeftIcon } from "@heroicons/react/outline"; +import { InferGetStaticPropsType } from "next"; +import Link from "next/link"; + +import { getAppRegistry } from "@calcom/app-store/_appRegistry"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import Shell from "@components/Shell"; +import AppStoreCategories from "@components/apps/Categories"; + +export default function Apps({ categories }: InferGetStaticPropsType) { + const { t } = useLocale(); + + return ( + + +
+ +
+ + ); +} + +export const getStaticProps = async () => { + const appStore = getAppRegistry(); + const categories = appStore.reduce((c, app) => { + c[app.category] = c[app.category] ? c[app.category] + 1 : 1; + return c; + }, {} as Record); + + return { + props: { + categories: Object.entries(categories).map(([name, count]) => ({ name, count })), + }, + }; +}; diff --git a/apps/web/pages/apps/index.tsx b/apps/web/pages/apps/index.tsx index 74cc08bb5a..c1eb729c55 100644 --- a/apps/web/pages/apps/index.tsx +++ b/apps/web/pages/apps/index.tsx @@ -1,23 +1,22 @@ import { InferGetStaticPropsType } from "next"; import { getAppRegistry } from "@calcom/app-store/_appRegistry"; - -import { useLocale } from "@lib/hooks/useLocale"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import AppsShell from "@components/AppsShell"; import Shell from "@components/Shell"; import AllApps from "@components/apps/AllApps"; import AppStoreCategories from "@components/apps/Categories"; -import Slider from "@components/apps/Slider"; +import TrendingAppsSlider from "@components/apps/TrendingAppsSlider"; export default function Apps({ appStore, categories }: InferGetStaticPropsType) { const { t } = useLocale(); return ( - + - + diff --git a/apps/web/pages/apps/installed.tsx b/apps/web/pages/apps/installed.tsx index 54ef5aa0cf..c5e2261f42 100644 --- a/apps/web/pages/apps/installed.tsx +++ b/apps/web/pages/apps/installed.tsx @@ -15,6 +15,7 @@ import { HttpError } from "@lib/core/http/error"; import { useLocale } from "@lib/hooks/useLocale"; import { trpc } from "@lib/trpc"; +import AppsShell from "@components/AppsShell"; import { ClientSuspense } from "@components/ClientSuspense"; import { List, ListItem, ListItemText, ListItemTitle } from "@components/List"; import Loader from "@components/Loader"; @@ -30,7 +31,7 @@ function IframeEmbedContainer() { // doesn't need suspense as it should already be loaded const user = trpc.useQuery(["viewer.me"]).data; - const iframeTemplate = ``; + const iframeTemplate = ``; const htmlTemplate = `${t( "schedule_a_meeting" )}${iframeTemplate}`; @@ -47,7 +48,7 @@ function IframeEmbedContainer() { {t("standard_iframe")} {t("embed_your_calendar")}
-
+
- }> - - - - - - + + + }> + + + + + + + ); } diff --git a/apps/web/pages/auth/forgot-password/index.tsx b/apps/web/pages/auth/forgot-password/index.tsx index 3b32aa30b4..9c7fc6916b 100644 --- a/apps/web/pages/auth/forgot-password/index.tsx +++ b/apps/web/pages/auth/forgot-password/index.tsx @@ -2,6 +2,7 @@ import debounce from "lodash/debounce"; import { GetServerSidePropsContext } from "next"; import { getCsrfToken } from "next-auth/react"; import Link from "next/link"; +import { useRouter } from "next/router"; import React, { SyntheticEvent } from "react"; import Button from "@calcom/ui/Button"; @@ -18,6 +19,7 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { const [error, setError] = React.useState<{ message: string } | null>(null); const [success, setSuccess] = React.useState(false); const [email, setEmail] = React.useState(""); + const router = useRouter(); const handleChange = (e: SyntheticEvent) => { const target = e.target as typeof e.target & { value: string }; @@ -38,7 +40,7 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { if (!res.ok) { setError(json); } else if ("resetLink" in json) { - window.location = json.resetLink; + router.push(json.resetLink); } else { setSuccess(true); } diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 6724788103..51ee2e606b 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -9,12 +9,12 @@ import { useForm } from "react-hook-form"; import { Alert } from "@calcom/ui/Alert"; import Button from "@calcom/ui/Button"; -import { EmailField, PasswordField, Form } from "@calcom/ui/form/fields"; +import { EmailField, Form, PasswordField } from "@calcom/ui/form/fields"; import { ErrorCode, getSession } from "@lib/auth"; -import { WEBSITE_URL } from "@lib/config/constants"; +import { WEBAPP_URL, WEBSITE_URL } from "@lib/config/constants"; import { useLocale } from "@lib/hooks/useLocale"; -import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml"; +import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@lib/saml"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -64,7 +64,7 @@ export default function Login({ // If not absolute URL, make it absolute if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1); if (!/^https?:\/\//.test(callbackUrl)) { - callbackUrl = `${WEBSITE_URL}/${callbackUrl}`; + callbackUrl = `${WEBAPP_URL}/${callbackUrl}`; } const LoginFooter = ( @@ -112,7 +112,8 @@ export default function Login({ else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); }) .catch(() => setErrorMessage(errorMessages[ErrorCode.InternalServerError])); - }}> + }} + data-testid="login-form">
diff --git a/apps/web/pages/auth/logout.tsx b/apps/web/pages/auth/logout.tsx index eb7b326a7c..a57a02c6a1 100644 --- a/apps/web/pages/auth/logout.tsx +++ b/apps/web/pages/auth/logout.tsx @@ -1,5 +1,6 @@ import { CheckIcon } from "@heroicons/react/outline"; import { GetServerSidePropsContext } from "next"; +import { useSession, signOut } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -16,6 +17,8 @@ import { ssrInit } from "@server/lib/ssr"; type Props = inferSSRProps; export default function Logout(props: Props) { + const { data: session, status } = useSession(); + if (status === "authenticated") signOut({ redirect: false }); const router = useRouter(); useEffect(() => { if (props.query?.survey === "true") { diff --git a/apps/web/pages/auth/signup.tsx b/apps/web/pages/auth/signup.tsx index 0285b3959e..57adc51c71 100644 --- a/apps/web/pages/auth/signup.tsx +++ b/apps/web/pages/auth/signup.tsx @@ -91,7 +91,7 @@ export default function Signup({ email }: Props) { - {process.env.NEXT_PUBLIC_APP_URL}/ + {process.env.NEXT_PUBLIC_WEBSITE_URL}/ } labelProps={{ className: "block text-sm font-medium text-gray-700" }} diff --git a/apps/web/pages/auth/sso/[provider].tsx b/apps/web/pages/auth/sso/[provider].tsx index f72a8304f1..8627edd9a5 100644 --- a/apps/web/pages/auth/sso/[provider].tsx +++ b/apps/web/pages/auth/sso/[provider].tsx @@ -158,8 +158,8 @@ const getStripePremiumUsernameUrl = async ({ quantity: 1, }, ], - success_url: `${process.env.NEXT_PUBLIC_APP_BASE_URL}${successDestination}&session_id={CHECKOUT_SESSION_ID}`, - cancel_url: process.env.NEXT_PUBLIC_APP_BASE_URL || "https://app.cal.com", + success_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}${successDestination}&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com", allow_promotion_codes: true, }); diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index c8ae197249..e7cab3a7eb 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -23,13 +23,14 @@ import utc from "dayjs/plugin/utc"; import { GetServerSidePropsContext } from "next"; import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, Noop, useForm, UseFormReturn } from "react-hook-form"; import { FormattedNumber, IntlProvider } from "react-intl"; import Select, { Props as SelectProps } from "react-select"; import { JSONObject } from "superjson/dist/types"; import { z } from "zod"; import getApps, { getLocationOptions, hasIntegration } from "@calcom/app-store/utils"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; import { StripeData } from "@calcom/stripe/server"; import Button from "@calcom/ui/Button"; @@ -41,7 +42,7 @@ import { QueryCell } from "@lib/QueryCell"; import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull"; import { getSession } from "@lib/auth"; import { HttpError } from "@lib/core/http/error"; -import { useLocale } from "@lib/hooks/useLocale"; +import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable"; import { LocationType } from "@lib/location"; import prisma from "@lib/prisma"; import { slugify } from "@lib/slugify"; @@ -52,8 +53,10 @@ import { ClientSuspense } from "@components/ClientSuspense"; import DestinationCalendarSelector from "@components/DestinationCalendarSelector"; import Loader from "@components/Loader"; import Shell from "@components/Shell"; +import { UpgradeToProDialog } from "@components/UpgradeToProDialog"; import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm"; +import Badge from "@components/ui/Badge"; import InfoBadge from "@components/ui/InfoBadge"; import CheckboxField from "@components/ui/form/CheckboxField"; import CheckedSelect from "@components/ui/form/CheckedSelect"; @@ -62,6 +65,8 @@ import MinutesField from "@components/ui/form/MinutesField"; import * as RadioArea from "@components/ui/form/radio-area"; import WebhookListContainer from "@components/webhook/WebhookListContainer"; +import { getTranslation } from "@server/lib/i18n"; + import bloxyApi from "../../web3/dummyResps/bloxyApi"; dayjs.extend(utc); @@ -84,20 +89,68 @@ type OptionTypeBase = { disabled?: boolean; }; -const addDefaultLocationOptions = ( - defaultLocations: OptionTypeBase[], - locationOptions: OptionTypeBase[] -): void => { - const existingLocationOptions = locationOptions.flatMap((locationOptionItem) => [locationOptionItem.value]); - - defaultLocations.map((item) => { - if (!existingLocationOptions.includes(item.value)) { - locationOptions.push(item); - } - }); +const SuccessRedirectEdit = >({ + eventType, + formMethods, +}: { + eventType: inferSSRProps["eventType"]; + formMethods: T; +}) => { + const { t } = useLocale(); + const proUpgradeRequired = !isSuccessRedirectAvailable(eventType); + const [modalOpen, setModalOpen] = useState(false); + return ( + <> +
+
+
+ +
+
+ { + if (proUpgradeRequired) { + e.preventDefault(); + setModalOpen(true); + } + }} + readOnly={proUpgradeRequired} + type="url" + className="focus:border-primary-500 focus:ring-primary-500 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm" + placeholder={t("external_redirect_url")} + defaultValue={eventType.successRedirectUrl || ""} + {...formMethods.register("successRedirectUrl")} + /> +
+ + {t("redirect_url_upgrade_description")} + +
+ + ); }; -const AvailabilitySelect = ({ className, ...props }: SelectProps) => { +type AvailabilityOption = { + label: string; + value: number; +}; + +const AvailabilitySelect = ({ + className = "", + ...props +}: { + className?: string; + name: string; + value: number; + onBlur: Noop; + onChange: (value: AvailabilityOption | null) => void; +}) => { const query = trpc.useQuery(["viewer.availability.list"]); return ( @@ -116,9 +169,9 @@ const AvailabilitySelect = ({ className, ...props }: SelectProps) => { ); return ( ) => { control={formMethods.control} render={({ field }) => ( - field.onChange(selected.value) - } + value={field.value} + onBlur={field.onBlur} + name={field.name} + onChange={(selected) => field.onChange(selected?.value || null)} /> )} /> @@ -1222,6 +1283,24 @@ const EventTypePage = (props: inferSSRProps) => {
+ ( + { + formMethods.setValue("hideCalendarNotes", e?.target.checked); + }} + /> + )} + /> + ) => {
- + + formMethods={formMethods} + eventType={eventType}> {hasPaymentIntegration && ( <>
@@ -1815,6 +1896,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => id: true, avatar: true, email: true, + plan: true, + locale: true, }); const rawEventType = await prisma.eventType.findFirst({ @@ -1867,11 +1950,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => periodEndDate: true, periodCountCalendarDays: true, requiresConfirmation: true, + hideCalendarNotes: true, disableGuests: true, minimumBookingNotice: true, beforeEventBuffer: true, afterEventBuffer: true, slotInterval: true, + successRedirectUrl: true, team: { select: { slug: true, @@ -1946,26 +2031,16 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => if (!fallbackUser) throw Error("The event type doesn't have user and no fallback user was found"); eventType.users.push(fallbackUser); } - + const currentUser = eventType.users.find((u) => u.id === session.user.id); + const t = await getTranslation(currentUser?.locale ?? "en", "common"); const integrations = getApps(credentials); - const locationOptions = getLocationOptions(integrations); + const locationOptions = getLocationOptions(integrations, t); const hasPaymentIntegration = hasIntegration(integrations, "stripe_payment"); - if (hasIntegration(integrations, "google_calendar")) { - locationOptions.push({ - value: LocationType.GoogleMeet, - label: "Google Meet", - }); - } const currency = (credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData) ?.default_currency || "usd"; - if (hasIntegration(integrations, "office365_calendar")) { - // TODO: Add default meeting option of the office integration. - // Assuming it's Microsoft Teams. - } - type Availability = typeof eventType["availability"]; const getAvailability = (availability: Availability) => availability?.length @@ -1988,7 +2063,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const teamMembers = eventTypeObject.team ? eventTypeObject.team.members.map((member) => { const user = member.user; - user.avatar = `${process.env.NEXT_PUBLIC_APP_URL}/${user.username}/avatar.png`; + user.avatar = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`; return user; }) : []; diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 9fa4685752..cbc04b83c5 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -223,13 +223,13 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J truncateAfter={4} items={type.users.map((organizer) => ({ alt: organizer.name || "", - image: `${process.env.NEXT_PUBLIC_APP_URL}/${organizer.username}/avatar.png`, + image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${organizer.username}/avatar.png`, }))} /> )} @@ -242,7 +242,7 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J onClick={() => { showToast(t("link_copied"), "success"); navigator.clipboard.writeText( - `${process.env.NEXT_PUBLIC_APP_URL}/${group.profile.slug}/${type.slug}` + `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}` ); }} className="btn-icon"> @@ -320,7 +320,8 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J - + - -
- - + + {t("remove_cal_branding_description")} + ); } @@ -127,7 +98,7 @@ function SettingsView(props: ComponentProps & { localeProp: str }).catch((e) => { console.error(`Error Removing user: ${props.user.id}, email: ${props.user.email} :`, e); }); - if (process.env.NEXT_PUBLIC_BASE_URL === "https://app.cal.com") { + if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") { signOut({ callbackUrl: "/auth/logout?survey=true" }); } else { signOut({ callbackUrl: "/auth/logout" }); @@ -156,6 +127,7 @@ function SettingsView(props: ComponentProps & { localeProp: str const descriptionRef = useRef(null!); const avatarRef = useRef(null!); const hideBrandingRef = useRef(null!); + const allowDynamicGroupBookingRef = useRef(null!); const [selectedTheme, setSelectedTheme] = useState(); const [selectedTimeFormat, setSelectedTimeFormat] = useState({ value: props.user.timeFormat || 12, @@ -198,6 +170,7 @@ function SettingsView(props: ComponentProps & { localeProp: str const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value; const enteredWeekStartDay = selectedWeekStartDay.value; const enteredHideBranding = hideBrandingRef.current.checked; + const enteredAllowDynamicGroupBooking = allowDynamicGroupBookingRef.current.checked; const enteredLanguage = selectedLanguage.value; const enteredTimeFormat = selectedTimeFormat.value; @@ -212,6 +185,7 @@ function SettingsView(props: ComponentProps & { localeProp: str timeZone: enteredTimeZone, weekStart: asStringOrUndefined(enteredWeekStartDay), hideBranding: enteredHideBranding, + allowDynamicBooking: enteredAllowDynamicGroupBooking, theme: asStringOrNull(selectedTheme?.value), brandColor: enteredBrandColor, darkBrandColor: enteredDarkBrandColor, @@ -232,7 +206,7 @@ function SettingsView(props: ComponentProps & { localeProp: str name="username" addOnLeading={ - {process.env.NEXT_PUBLIC_APP_URL}/ + {process.env.NEXT_PUBLIC_WEBSITE_URL}/ } ref={usernameRef} @@ -393,6 +367,25 @@ function SettingsView(props: ComponentProps & { localeProp: str />
+
+
+ +
+
+ +
+