pull/2277/head
Agusti Fernandez Pardo 2022-04-13 02:58:48 +02:00
commit cdf3954abc
332 changed files with 10753 additions and 2835 deletions

View File

@ -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='<office365_emailAddress>'
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
EMAIL_SERVER_PASSWORD='<office365_password>'
# The following configuration for Gmail has been verified to work.
# EMAIL_SERVER_HOST='smtp.gmail.com'
# EMAIL_SERVER_PORT=465
# EMAIL_SERVER_USER='<gmail_emailAddress>'
## You will need to provision an App Password.
## @see https://support.google.com/accounts/answer/185833
# EMAIL_SERVER_PASSWORD='<gmail_app_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=
# *********************************************************************************************************

View File

@ -1,2 +0,0 @@
node_modules
packages/prisma/zod

View File

@ -6,7 +6,6 @@ on:
jobs:
types:
name: Check types
strategy:
matrix:
node: ["14.x"]

View File

@ -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

View File

@ -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'

21
.github/workflows/submodule-sync.yml vendored Normal file
View File

@ -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"

10
.gitignore vendored
View File

@ -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/

6
.gitmodules vendored
View File

@ -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

View File

@ -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"]

View File

@ -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.
<details>
<summary>App Manifest</summary>
```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
```
</details>
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.

View File

@ -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"

View File

@ -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 <Component {...pageProps} />;
}

View File

@ -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)

View File

@ -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 `<Cal>` 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.
<details>
<summary>_Vanilla JS_</summary>
```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
},
});
```
</details>
####
<details>
<summary>_React_</summary>
```jsx
import Cal from "@calcom/embed-react";
const MyComponent = () => (
<Cal
calLink="pro"
config={{
name: "John Doe",
email: "johndoe@gmail.com",
notes: "Test Meeting",
guests: ["janedoe@gmail.com"],
theme: "dark",
}}
/>
);
```
</details>
### Popup on any existing element
To show the embed as a popup on clicking an element, add `data-cal-link` attribute to the element.
<details>
<summary>Vanilla JS</summary>
To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element.
<button data-cal-link="jane" data-cal-config="A valid config JSON"></button>
</details>
<details>
<summary>React</summary>
```jsx
import "@calcom/embed-react";
const MyComponent = ()=> {
return <button data-cal-link="jane" data-cal-config='A valid config JSON'></button>
}
````
</details>
### 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 <br/><br/>eventType: "Object for Event Type that has been booked"; <br/><br/>date: string; // Date of Event <br/><br/>duration: number; //Duration of booked Event <br/><br/>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 <br/><br/>msg: string; //Human Readable msg <br/><br/>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._

View File

@ -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.

View File

@ -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
}
}

View File

@ -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='<office365_emailAddress>'
# Keep in mind that if you have 2FA enabled, you will need to provision an App Password.
EMAIL_SERVER_PASSWORD='<office365_password>'
# The following configuration for Gmail has been verified to work.
# EMAIL_SERVER_HOST='smtp.gmail.com'
# EMAIL_SERVER_PORT=465
# EMAIL_SERVER_USER='<gmail_emailAddress>'
## You will need to provision an App Password.
## @see https://support.google.com/accounts/answer/185833
# EMAIL_SERVER_PASSWORD='<gmail_app_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=

View File

@ -64,14 +64,14 @@ export default function App({
return (
<>
<Shell large>
<div className="-mx-8">
<div className="bg-gray-50 px-10">
<div className="-mx-4 md:-mx-8">
<div className="bg-gray-50 px-4">
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
</a>
</Link>
<div className="flex items-center justify-between py-8">
<div className="items-center justify-between py-4 sm:flex sm:py-8">
<div className="flex">
<img className="h-16 w-16" src={logo} alt={name} />
<header className="px-4 py-2">
@ -82,7 +82,7 @@ export default function App({
</header>
</div>
<div className="text-right">
<div className="mt-4 sm:mt-0 sm:text-right">
{isGlobal ? (
<Button color="secondary" disabled title="This app is globally installed">
{t("installed")}
@ -107,7 +107,7 @@ export default function App({
<NavTabs tabs={tabs} linkProps={{ shallow: true }} /> */}
</div>
<div className="justify-between px-10 py-10 md:flex">
<div className="justify-between px-4 py-10 md:flex">
<div className="prose-sm prose">{body}</div>
<div className="md:max-w-80 flex-1 md:ml-8">
<h4 className="font-medium text-gray-900 ">{t("categories")}</h4>

View File

@ -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";

View File

@ -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);

View File

@ -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;

View File

@ -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 (
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-50">
@ -209,6 +209,9 @@ export default function Shell(props: {
</div>
);
}
if (!session && !props.isPublic) return null;
return (
<>
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
@ -288,7 +291,9 @@ export default function Shell(props: {
</nav>
</div>
<TrialBanner />
<div className="rounded-sm pb-2 pl-3 pt-2 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2">
<div
className="rounded-sm pb-2 pl-3 pt-2 pr-2 hover:bg-gray-100 lg:mx-2 lg:pl-2"
data-testid="user-dropdown-trigger">
<span className="hidden lg:inline">
<UserDropdown />
</span>
@ -298,8 +303,10 @@ export default function Shell(props: {
</div>
<small style={{ fontSize: "0.5rem" }} className="mx-3 mt-1 mb-2 hidden opacity-50 lg:block">
&copy; {new Date().getFullYear()} Cal.com, Inc. v.{pkg.version + "-"}
{process.env.NEXT_PUBLIC_APP_URL === "https://cal.com" ? "h" : "sh"}
<span className="lowercase">-{user && user.plan}</span>
{process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? "h" : "sh"}
<span className="lowercase" data-testid={`plan-${user?.plan.toLowerCase()}`}>
-{user && user.plan}
</span>
</small>
</div>
</div>
@ -350,7 +357,7 @@ export default function Shell(props: {
</Button>
</div>
)}
{props.heading && props.subtitle && (
{props.heading && (
<div
className={classNames(
props.large && "bg-gray-100 py-8 lg:mb-8 lg:pt-16 lg:pb-7",
@ -361,7 +368,7 @@ export default function Shell(props: {
<h1 className="font-cal mb-1 text-xl font-bold capitalize tracking-wide text-gray-900">
{props.heading}
</h1>
<p className="text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
<p className="min-h-10 text-sm text-neutral-500 ltr:mr-4 rtl:ml-4">{props.subtitle}</p>
</div>
{props.CTA && <div className="mb-4 flex-shrink-0">{props.CTA}</div>}
</div>
@ -437,12 +444,7 @@ function UserDropdown({ small }: { small?: boolean }) {
)}>
<img
className="rounded-full"
src={
(process.env.NEXT_PUBLIC_APP_URL || process.env.NEXT_PUBLIC_BASE_URL) +
"/" +
user?.username +
"/avatar.png"
}
src={process.env.NEXT_PUBLIC_WEBSITE_URL + "/" + user?.username + "/avatar.png"}
alt={user?.username || "Nameless User"}
/>
{!user?.away && (
@ -496,7 +498,7 @@ function UserDropdown({ small }: { small?: boolean }) {
<a
target="_blank"
rel="noopener noreferrer"
href={`${process.env.NEXT_PUBLIC_APP_URL}/${user.username}`}
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}`}
className="flex items-center px-4 py-2 text-sm text-gray-700">
<ExternalLinkIcon className="h-5 w-5 text-gray-500 ltr:mr-3 rtl:ml-3" /> {t("view_public_page")}
</a>

View File

@ -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 (
<Dialog open={modalOpen}>
<DialogContent>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
<InformationCircleIcon className="h-6 w-6 text-yellow-400" aria-hidden="true" />
</div>
<div className="mb-4 sm:flex sm:items-start">
<div className="mt-3 sm:mt-0 sm:text-left">
<h3 className="font-cal text-lg font-bold leading-6 text-gray-900" id="modal-title">
{t("only_available_on_pro_plan")}
</h3>
</div>
</div>
<div className="flex flex-col space-y-3">
<p>{children}</p>
<p>
<Trans i18nKey="plan_upgrade_instructions">
You can
<a href="/api/upgrade" className="underline">
upgrade here
</a>
.
</Trans>
</p>
</div>
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose asChild>
<Button className="btn-wide table-cell text-center" onClick={() => setModalOpen(false)}>
{t("dismiss")}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -9,7 +9,7 @@ export default function AllApps({ apps }: { apps: App[] }) {
return (
<div className="mb-16">
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("all_apps")}</h2>
<div className="grid-col-1 grid gap-3 md:grid-cols-3">
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{apps.map((app) => (
<AppCard
key={app.name}

View File

@ -1,21 +1,34 @@
import { CreditCardIcon } from "@heroicons/react/outline";
import Image from "next/image";
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export default function AppStoreCategories(props: any) {
export default function AppStoreCategories({
categories,
}: {
categories: {
name: string;
count: number;
}[];
}) {
const { t } = useLocale();
return (
<div className="mb-16">
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("popular_categories")}</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{props.categories.map((category: any) => (
<div className="grid-col-1 grid gap-3 md:grid-flow-col">
{categories.map((category) => (
<Link key={category.name} href={"/apps/categories/" + category.name}>
<a className="flex rounded-sm bg-gray-100 px-6 py-4">
<div className="mr-4 flex h-12 w-12 rounded-sm bg-white">
<CreditCardIcon className="mx-auto h-6 w-6 self-center" />
<a className="relative flex rounded-sm bg-gray-100 px-6 py-4 sm:block">
<div className="min-w-24 -ml-5 text-center sm:ml-0">
<Image
alt={category.name}
width="352"
height="252"
layout="responsive"
src={"/app-store/" + category.name + ".svg"}
/>
</div>
<div>
<div className="self-center">
<h3 className="font-medium capitalize">{category.name}</h3>
<p className="text-sm text-gray-500">{category.count} apps</p>
</div>

View File

@ -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 = <T extends App>({ items }: { items: T[] }) => {
const { t } = useLocale();
const isMobile = useMediaQuery("(max-width: 767px)");
const [size, setSize] = useState(3);
const Slider = <T extends unknown>({
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<Glide.Properties | null>(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 (
<div className="mb-16">
<div className={`mb-2 ${className}`}>
<style jsx global>
{`
.glide__slide {
@ -45,11 +42,13 @@ const Slider = <T extends App>({ items }: { items: T[] }) => {
}
`}
</style>
<div className="glide">
<div className="glide" ref={glide}>
<div className="flex cursor-default">
<div>
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t("trending_apps")}</h2>
</div>
{title && (
<div>
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">{title}</h2>
</div>
)}
<div className="glide__arrows ml-auto" data-glide-el="controls">
<button data-glide-dir="<" className="mr-4">
<ArrowLeftIcon className="h-5 w-5 text-gray-600 hover:text-black" />
@ -61,21 +60,12 @@ const Slider = <T extends App>({ items }: { items: T[] }) => {
</div>
<div className="glide__track" data-glide-el="track">
<ul className="glide__slides">
{items.map((app) => {
{items.map((item) => {
if (typeof renderItem !== "function") return null;
return (
app.trending && (
<li key={app.name} className="glide__slide h-auto">
<AppCard
key={app.name}
name={app.name}
slug={app.slug}
description={app.description}
logo={app.logo}
rating={app.rating}
reviews={app.reviews}
/>
</li>
)
<li key={itemKey(item)} className="glide__slide h-auto pl-0">
{renderItem(item)}
</li>
);
})}
</ul>

View File

@ -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 = <T extends App>({ items }: { items: T[] }) => {
const { t } = useLocale();
return (
<Slider<T>
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) => (
<AppCard
key={app.name}
name={app.name}
slug={app.slug}
description={app.description}
logo={app.logo}
rating={app.rating}
reviews={app.reviews}
/>
)}
/>
);
};
export default TrendingAppsSlider;

View File

@ -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<Option[]>([]);
const [selected, setSelected] = useState<number | undefined>();
// const { i18n } = useLocale();
const [filteredOptions, setFilteredOptions] = useState<Option[]>([]);
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<Props<Option, false, GroupBase<Option>>, "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 (
<>
<Select
options={options}
onMenuOpen={() => {
if (min) filter({ offset: min });
if (max) filter({ limit: max });
}}
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
onMenuClose={() => filter({ current: value })}
{...props}
/>
);
};
const TimeRangeField = ({ name, className }: TimeRangeFieldProps) => {
const { watch } = useFormContext();
const minEnd = watch(`${name}.start`);
const maxStart = watch(`${name}.end`);
return (
<div className={classNames("flex flex-grow items-center space-x-3", className)}>
<Controller
name={`${name}.start`}
render={({ field: { onChange, value } }) => {
handleSelected(value);
return (
<Select
className="w-30"
options={options}
onFocus={() => setOptions(timeOptions())}
onBlur={() => setOptions([])}
defaultValue={getOption(value)}
<LazySelect
className="w-[120px]"
value={value}
max={maxStart}
onChange={(option) => {
onChange(new Date(option?.value as number));
handleSelected(option?.value);
}}
/>
);
@ -107,17 +138,17 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
<Controller
name={`${name}.end`}
render={({ field: { onChange, value } }) => (
<Select
className="w-30"
options={options}
onFocus={() => setOptions(timeOptions({ selected }))}
onBlur={() => setOptions([])}
defaultValue={getOption(value)}
onChange={(option) => onChange(new Date(option?.value as number))}
<LazySelect
className="flex-grow sm:w-[120px]"
value={value}
min={minEnd}
onChange={(option) => {
onChange(new Date(option?.value as number));
}}
/>
)}
/>
</>
</div>
);
};
@ -127,12 +158,65 @@ type ScheduleBlockProps = {
name: string;
};
const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
const { t } = useLocale();
const { fields, append, remove, replace } = useFieldArray({
name: `${name}.${day}`,
const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (selected: number[]) => void }) => {
const [selected, setSelected] = useState<number[]>([]);
const { i18n, t } = useLocale();
return (
<div className="m-4 space-y-2 py-4">
<p className="h6 text-xs font-medium uppercase text-neutral-400">Copy times to</p>
<ol className="space-y-2">
{weekdayNames(i18n.language).map((weekday, num) => (
<li key={weekday}>
<label className="flex w-full items-center justify-between">
<span>{weekday}</span>
<input
value={num}
defaultChecked={disabled.includes(num)}
disabled={disabled.includes(num)}
onChange={(e) => {
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"
/>
</label>
</li>
))}
</ol>
<div className="pt-2">
<Button className="w-full justify-center" color="primary" onClick={() => onApply(selected)}>
{t("apply")}
</Button>
</div>
</div>
);
};
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 (
<fieldset className="flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
<div className="w-1/3">
<label className="flex items-center space-x-2 rtl:space-x-reverse">
<input
type="checkbox"
checked={fields.length > 0}
onChange={(e) => (e.target.checked ? replace([defaultDayRange]) : replace([]))}
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
/>
<span className="inline-block text-sm capitalize">{weekday}</span>
</label>
</div>
<div className="flex-grow">
{fields.map((field, index) => (
<div key={field.id} className="mb-1 flex justify-between">
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<TimeRangeField name={`${name}.${day}.${index}`} />
</div>
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="flex items-center rtl:space-x-reverse">
<div className="flex flex-grow sm:flex-grow-0">
<TimeRangeField name={`${name}.${index}`} />
<Button
size="icon"
color="minimal"
@ -173,19 +244,82 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
onClick={() => remove(index)}
/>
</div>
))}
<span className="block text-sm text-gray-500">{!fields.length && t("no_availability")}</span>
</div>
<div>
<Button
type="button"
color="minimal"
size="icon"
className={fields.length > 0 ? "visible" : "invisible"}
StartIcon={PlusIcon}
onClick={handleAppend}
/>
</div>
{index === 0 && (
<div className="absolute top-2 right-0 text-right sm:relative sm:top-0 sm:flex-grow">
<Button
className="text-neutral-400"
type="button"
color="minimal"
size="icon"
StartIcon={PlusIcon}
onClick={handleAppend}
/>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
type="button"
color="minimal"
size="icon"
StartIcon={DuplicateIcon}
onClick={handleAppend}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<CopyTimes
disabled={[parseInt(name.substring(name.lastIndexOf(".") + 1), 10)]}
onApply={(selected) =>
selected.forEach((day) => {
// TODO: Figure out why this is different?
// console.log(watcher, fields);
setValue(name.substring(0, name.lastIndexOf(".") + 1) + day, watcher);
})
}
/>
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
))}
</div>
);
};
const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
const { t } = useLocale();
const form = useFormContext();
const watchAvailable = form.watch(`${name}.${day}`, []);
return (
<fieldset className="relative flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
<label
className={classNames(
"flex space-x-2 rtl:space-x-reverse",
!watchAvailable.length ? "w-full" : "w-1/3"
)}>
<div className={classNames(!watchAvailable.length ? "w-1/3" : "w-full")}>
<input
type="checkbox"
checked={watchAvailable.length}
onChange={(e) => {
form.setValue(`${name}.${day}`, e.target.checked ? [defaultDayRange] : []);
}}
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
/>
<span className="ml-2 inline-block text-sm capitalize">{weekday}</span>
</div>
{!watchAvailable.length && (
<div className="flex-grow text-right text-sm text-gray-500 sm:flex-shrink">
{t("no_availability")}
</div>
)}
</label>
{!!watchAvailable.length && (
<div className="flex-grow">
<DayRanges name={`${name}.${day}`} defaultValue={[]} />
</div>
)}
</fieldset>
);
};

View File

@ -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<AvailableTimesProps> = ({
date,
eventLength,
eventTypeId,
eventTypeSlug,
slotInterval,
minimumBookingNotice,
timeFormat,
@ -41,7 +45,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
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<AvailableTimesProps> = ({
return (
<div className="mt-8 flex flex-col text-center sm:mt-0 sm:w-1/3 sm:pl-4 md:-mb-5">
<div className="mb-4 text-left text-lg font-light text-gray-600">
<span className="w-1/2 text-gray-600 dark:text-white">
<strong>{date.toDate().toLocaleString(i18n.language, { weekday: "long" })}</strong>
<span className="text-gray-500">
<span className="text-bookingdarker w-1/2 dark:text-white">
<strong>{nameOfDay(i18n.language, Number(date.format("d")))}</strong>
<span className="text-bookinglight">
{date.format(", D ")}
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
</span>
@ -85,6 +88,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
...router.query,
date: slot.time.format(),
type: eventTypeId,
slug: eventTypeSlug,
},
};
@ -101,7 +105,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<Link href={bookingUrl}>
<a
className={classNames(
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
"text-bookingdarker hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}
data-testid="time">

View File

@ -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<Dayjs | null>(date);
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
const [month, setMonth] = useState<string>("");
const [year, setYear] = useState<string>("");
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
@ -123,6 +126,8 @@ function DatePicker({
eventLength,
minimumBookingNotice,
workingHours,
}: Omit<DatePickerProps, "weekStart" | "onDatePicked" | "date"> & {
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")
}>
<div className="mb-4 flex text-xl font-light text-gray-600">
<span className="w-1/2 text-gray-600 dark:text-white">
<strong className="text-gray-900 dark:text-white">{month}</strong>{" "}
<span className="text-gray-500">{year}</span>
<div className="mb-4 flex text-xl font-light">
<span className="w-1/2 dark:text-white">
<strong className="text-bookingdarker dark:text-white">{month}</strong>{" "}
<span className="text-bookinglight">{year}</span>
</span>
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
<div className="w-1/2 text-right dark:text-gray-400">
<button
onClick={decrementMonth}
className={classNames(
"group p-1 ltr:mr-2 rtl:ml-2",
isFirstMonth && "text-gray-400 dark:text-gray-600"
isFirstMonth && "text-bookinglighter dark:text-gray-600"
)}
disabled={isFirstMonth}
data-testid="decrementMonth">
@ -253,9 +258,9 @@ function DatePicker({
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
<div className="border-bookinglightest grid grid-cols-7 gap-4 border-t border-b text-center dark:border-gray-800 sm:border-0">
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
<div key={weekDay} className="my-4 text-xs uppercase tracking-widest text-gray-500">
<div key={weekDay} className="text-bookinglight my-4 text-xs uppercase tracking-widest">
{weekDay}
</div>
))}
@ -274,10 +279,15 @@ function DatePicker({
<button
onClick={() => onDatePicked(browsingDate.date(day.date))}
disabled={day.disabled}
style={
day.disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }
}
className={classNames(
"absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm text-center",
"hover:border-brand hover:border dark:hover:border-white",
day.disabled ? "cursor-default font-light text-gray-400 hover:border-0" : "font-medium",
day.disabled
? "text-bookinglighter cursor-default font-light hover:border-0"
: "font-medium",
date && date.isSame(browsingDate.date(day.date), "day")
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
: !day.disabled

View File

@ -16,9 +16,13 @@ import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { useEmbedStyles, useIsEmbed, useIsBackgroundTransparent } from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { BASE_URL } from "@lib/config/constants";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
@ -41,13 +45,16 @@ dayjs.extend(customParseFormat);
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Props) => {
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage }: Props) => {
const router = useRouter();
const isEmbed = useIsEmbed();
const { rescheduleUid } = router.query;
const { isReady, Theme } = useTheme(profile.theme);
const { t } = useLocale();
const { contracts } = useContracts();
const availabilityDatePickerEmbedStyles = useEmbedStyles("availabilityDatePicker");
let isBackgroundTransparent = useIsBackgroundTransparent();
useExposePlanGlobally(plan);
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
const eventOwner = eventType.users[0];
@ -59,12 +66,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
const selectedDate = useMemo(() => {
const dateString = asStringOrNull(router.query.date);
if (dateString) {
// todo some extra validation maybe.
const utcOffsetAsDate = dayjs(dateString.substr(11, 14), "Hmm");
const utcOffset = parseInt(
dateString.substr(10, 1) + (utcOffsetAsDate.hour() * 60 + utcOffsetAsDate.minute())
);
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffset, true);
const offsetString = dateString.substr(11, 14); // hhmm
const offsetSign = dateString.substr(10, 1); // + or -
const offsetHour = offsetString.slice(0, -2);
const offsetMinute = offsetString.slice(-2);
const utcOffsetInMinutes =
(offsetSign === "-" ? -1 : 1) *
(60 * (offsetHour !== "" ? parseInt(offsetHour) : 0) +
(offsetMinute !== "" ? parseInt(offsetMinute) : 0));
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true);
return date.isValid() ? date : null;
}
return null;
@ -122,14 +135,22 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
<div>
<main
className={
"transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
isEmbed
? ""
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
(selectedDate ? "max-w-5xl" : "max-w-3xl")
}>
{isReady && (
<div className="rounded-sm border-gray-200 bg-white dark:bg-gray-800 sm:dark:border-gray-600 md:border">
<div
style={availabilityDatePickerEmbedStyles}
className={classNames(
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
"border-bookinglightest rounded-sm md:border",
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
)}>
{/* mobile: details */}
<div className="block p-4 sm:p-8 md:hidden">
<div className="flex items-center">
<div className="block items-center sm:flex sm:space-x-4">
<AvatarGroup
border="border-2 dark:border-gray-800 border-white"
items={
@ -139,7 +160,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
.filter((user) => user.name !== profile.name)
.map((user) => ({
title: user.name,
image: `${process.env.NEXT_PUBLIC_APP_URL}/${user.username}/avatar.png`,
image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`,
alt: user.name || undefined,
})),
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
@ -147,9 +168,9 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
size={9}
truncateAfter={5}
/>
<div className="ltr:ml-3 rtl:mr-3">
<p className="text-sm font-medium text-black dark:text-gray-300">{profile.name}</p>
<div className="flex gap-2 text-xs font-medium text-gray-600">
<div className="mt-4 sm:-mt-2">
<p className="text-sm font-medium text-black dark:text-white">{profile.name}</p>
<div className="text-bookingmedian flex gap-2 text-xs font-medium dark:text-gray-100">
{eventType.title}
<div>
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
@ -189,23 +210,23 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
.map((user) => ({
title: user.name,
alt: user.name,
image: `${process.env.NEXT_PUBLIC_APP_URL}/${user.username}/avatar.png`,
image: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user.username}/avatar.png`,
})),
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
}
size={10}
truncateAfter={3}
/>
<h2 className="mt-3 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="font-cal mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
<h2 className="dark:text-bookinglight mt-3 font-medium text-gray-500">{profile.name}</h2>
<h1 className="font-cal text-bookingdark mb-4 text-3xl font-semibold dark:text-white">
{eventType.title}
</h1>
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
@ -253,6 +274,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
timeFormat={timeFormat}
minimumBookingNotice={eventType.minimumBookingNotice}
eventTypeId={eventType.id}
eventTypeSlug={eventType.slug}
slotInterval={eventType.slotInterval}
eventLength={eventType.length}
date={selectedDate}
@ -265,7 +287,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
</div>
</div>
)}
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && <PoweredByCal />}
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
</main>
</div>
</>
@ -274,7 +296,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours, previousPage }: Pr
function TimezoneDropdown() {
return (
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
<Collapsible.Trigger className="min-w-32 mb-1 -ml-2 px-2 py-1 text-left text-gray-500">
<Collapsible.Trigger className="min-w-32 text-bookinglight mb-1 -ml-2 px-2 py-1 text-left">
<GlobeIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{timeZone()}
{isTimeOptionsOpen ? (

View File

@ -12,6 +12,9 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { ReactMultiEmail } from "react-multi-email";
import { useMutation } from "react-query";
import { useIsEmbed, useEmbedStyles, useIsBackgroundTransparent } from "@calcom/embed-core";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { createPaymentLink } from "@calcom/stripe/client";
import { Button } from "@calcom/ui/Button";
@ -20,7 +23,6 @@ import { EmailInput, Form } from "@calcom/ui/form/fields";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { ensureArray } from "@lib/ensureArray";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
@ -31,12 +33,15 @@ import { detectBrowserTimeFormat } from "@lib/timeFormat";
import CustomBranding from "@components/CustomBranding";
import AvatarGroup from "@components/ui/AvatarGroup";
import type PhoneInputType from "@components/ui/form/PhoneInput";
import { BookPageProps } from "../../../pages/[user]/book";
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
/** These are like 40kb that not every user needs */
const PhoneInput = dynamic(() => import("@components/ui/form/PhoneInput"));
const PhoneInput = dynamic(
() => import("@components/ui/form/PhoneInput")
) as unknown as typeof PhoneInputType;
type BookingPageProps = BookPageProps | TeamBookingPageProps;
@ -52,11 +57,20 @@ type BookingFormValues = {
};
};
const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
const BookingPage = ({
eventType,
booking,
profile,
isDynamicGroupBooking,
locationLabels,
}: BookingPageProps) => {
const { t, i18n } = useLocale();
const isEmbed = useIsEmbed();
const router = useRouter();
const { contracts } = useContracts();
const { data: session } = useSession();
const isBackgroundTransparent = useIsBackgroundTransparent();
useEffect(() => {
if (eventType.metadata.smartContractAddress) {
const eventOwner = eventType.users[0];
@ -96,11 +110,13 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
query: {
date,
type: eventType.id,
eventSlug: eventType.slug,
user: profile.slug,
reschedule: !!rescheduleUid,
name: attendees[0].name,
email: attendees[0].email,
location,
eventName: profile.eventName || "",
},
});
},
@ -114,7 +130,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
const eventTypeDetail = { isWeb3Active: false, ...eventType };
type Location = { type: LocationType; address?: string };
type Location = { type: LocationType; address?: string; link?: string };
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
const locations: Location[] = useMemo(
() => (eventType.locations as Location[]) || [],
@ -130,20 +146,6 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
const telemetry = useTelemetry();
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
// TODO: Move to translations
// Also TODO: Get these dynamically from App Store
const locationLabels = {
[LocationType.InPerson]: t("in_person_meeting"),
[LocationType.Phone]: t("phone_call"),
[LocationType.GoogleMeet]: "Google Meet",
[LocationType.Zoom]: "Zoom Video",
[LocationType.Jitsi]: "Jitsi Meet",
[LocationType.Daily]: "Daily.co Video",
[LocationType.Huddle01]: "Huddle01 Video",
[LocationType.Tandem]: "Tandem Video",
[LocationType.Teams]: "MS Teams",
};
const loggedInIsOwner = eventType?.users[0]?.name === session?.user?.name;
const defaultValues = () => {
if (!rescheduleUid) {
@ -171,7 +173,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
return {
name: primaryAttendee.name || "",
email: primaryAttendee.email || "",
guests: booking.attendees.slice(1).map((attendee) => attendee.email),
guests: !isDynamicGroupBooking ? booking.attendees.slice(1).map((attendee) => attendee.email) : [],
};
};
@ -201,6 +203,9 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
case LocationType.InPerson: {
return locationInfo(locationType)?.address || "";
}
case LocationType.Link: {
return locationInfo(locationType)?.link || "";
}
// Catches all other location types, such as Google Meet, Zoom etc.
default:
return selectedLocation || "";
@ -249,6 +254,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
start: dayjs(date).format(),
end: dayjs(date).add(eventType.length, "minute").format(),
eventTypeId: eventType.id,
eventTypeSlug: eventType.slug,
timeZone: timeZone(),
language: i18n.language,
rescheduleUid,
@ -283,11 +289,20 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
<link rel="icon" href="/favicon.ico" />
</Head>
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<main className="mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
<main
className={
isEmbed ? "mx-auto" : "mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600"
}>
{isReady && (
<div className="overflow-hidden border border-gray-200 bg-white dark:border-0 dark:bg-neutral-900 sm:rounded-sm">
<div
className={classNames(
"overflow-hidden",
isEmbed ? "" : "border border-gray-200",
isBackgroundTransparent ? "" : "bg-white dark:border-0 dark:bg-gray-800",
"sm:rounded-sm"
)}>
<div className="px-4 py-5 sm:flex sm:p-4">
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
<AvatarGroup
border="border-2 border-white dark:border-gray-800"
size={14}
@ -300,16 +315,18 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
}))
)}
/>
<h2 className="font-cal mt-2 font-medium text-gray-500 dark:text-gray-300">{profile.name}</h2>
<h1 className="mb-4 text-3xl font-semibold text-gray-800 dark:text-white">
<h2 className="font-cal text-bookinglight mt-2 font-medium dark:text-gray-300">
{profile.name}
</h2>
<h1 className="text-bookingdark mb-4 text-3xl font-semibold dark:text-white">
{eventType.title}
</h1>
<p className="mb-2 text-gray-500">
<p className="text-bookinglight mb-2">
<ClockIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
</p>
{eventType.price > 0 && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
<CreditCardIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
@ -320,12 +337,12 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
</IntlProvider>
</p>
)}
<p className="mb-4 text-green-500">
<p className="text-bookinghighlight mb-4">
<CalendarIcon className="mr-1 -mt-1 inline-block h-4 w-4" />
{parseDate(date)}
</p>
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
<p className="mb-1 -ml-2 px-2 py-1 text-gray-500">
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
</p>
)}
@ -344,7 +361,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
name="name"
id="name"
required
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={t("example_name")}
/>
</div>
@ -359,7 +376,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
<EmailInput
{...bookingForm.register("email")}
required
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder="you@example.com"
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
/>
@ -394,8 +411,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
{t("phone_number")}
</label>
<div className="mt-1">
<PhoneInput
// @ts-expect-error
<PhoneInput<BookingFormValues>
control={bookingForm.control}
name="phone"
placeholder={t("enter_phone_number")}
@ -423,7 +439,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
})}
id={"custom_" + input.id}
rows={3}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={input.placeholder}
/>
)}
@ -434,7 +450,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
required: input.required,
})}
id={"custom_" + input.id}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={input.placeholder}
/>
)}
@ -445,7 +461,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
required: input.required,
})}
id={"custom_" + input.id}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder=""
/>
)}
@ -527,7 +543,7 @@ const BookingPage = ({ eventType, booking, profile }: BookingPageProps) => {
{...bookingForm.register("notes")}
id="notes"
rows={3}
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white dark:selection:bg-green-500 sm:text-sm"
className="focus:border-brand block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm"
placeholder={t("share_additional_notes")}
/>
</div>

View File

@ -191,7 +191,7 @@ export default function CreateEventTypeButton(props: Props) {
required
addOnLeading={
<InputLeading>
{process.env.NEXT_PUBLIC_APP_URL}/{pageSlug}/
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
</InputLeading>
}
{...register("slug")}

View File

@ -67,14 +67,14 @@ const constructImage = (name: string, description: string, username: string): st
return (
encodeURIComponent("Meet **" + name + "** <br>" + description).replace(/'/g, "%27") +
".png?md=1&images=https%3A%2F%2Fcal.com%2Flogo-white.svg&images=" +
(process.env.NEXT_PUBLIC_APP_URL || process.env.BASE_URL) +
(process.env.NEXT_PUBLIC_WEBSITE_URL || process.env.NEXT_PUBLIC_WEBAPP_URL) +
"/" +
username +
"/avatar.png"
);
};
export const HeadSeo: React.FC<HeadSeoProps & { children?: never }> = (props) => {
export const HeadSeo = (props: HeadSeoProps): JSX.Element => {
const defaultUrl = getBrowserInfo()?.url;
const image = getSeoImage("default");
@ -113,3 +113,5 @@ export const HeadSeo: React.FC<HeadSeoProps & { children?: never }> = (props) =>
return <NextSeo {...seoProps} />;
};
export default HeadSeo;

View File

@ -72,7 +72,7 @@ export default function TeamListItem(props: Props) {
<div className="ml-3 inline-block">
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
<span className="block text-xs text-gray-400">
{process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
{process.env.NEXT_PUBLIC_WEBSITE_URL}/team/{team.slug}
</span>
</div>
</div>
@ -112,7 +112,7 @@ export default function TeamListItem(props: Props) {
<Tooltip content={t("copy_link_team")}>
<Button
onClick={() => {
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug);
showToast(t("link_copied"), "success");
}}
className="h-10 w-10 transition-none"
@ -143,7 +143,7 @@ export default function TeamListItem(props: Props) {
</DropdownMenuItem>
)}
<DropdownMenuItem>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`} passHref={true}>
<a target="_blank">
<Button
color="minimal"

View File

@ -1,12 +1,13 @@
import { HashtagIcon, InformationCircleIcon, LinkIcon, PhotographIcon } from "@heroicons/react/solid";
import React, { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { objectKeys } from "@calcom/lib/objectKeys";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { TextField } from "@calcom/ui/form/fields";
import { useLocale } from "@lib/hooks/useLocale";
import { TeamWithMembers } from "@lib/queries/teams";
import { trpc } from "@lib/trpc";
@ -54,9 +55,9 @@ export default function TeamSettings(props: Props) {
hideBranding: hideBrandingRef.current?.checked,
};
// remove unchanged variables
for (const key in variables) {
if (variables[key] === team?.[key]) delete variables[key];
}
objectKeys(variables).forEach((key) => {
if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key];
});
mutation.mutate({ id: team.id, ...variables });
}
@ -91,7 +92,7 @@ export default function TeamSettings(props: Props) {
id="team-url"
addOnLeading={
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{"team/"}
</span>
}
ref={teamUrlRef}

View File

@ -20,7 +20,7 @@ export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers;
const utils = trpc.useContext();
const router = useRouter();
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`;
const permalink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${props.team?.slug}`;
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
async onSuccess() {

View File

@ -11,7 +11,7 @@ export type AvatarProps = {
};
// defaultAvatarSrc from profile.tsx can't be used as it imports crypto
function defaultAvatarSrc({ md5 }) {
function defaultAvatarSrc(md5: string) {
return `https://www.gravatar.com/avatar/${md5}?s=160&d=identicon&r=PG`;
}
@ -26,7 +26,7 @@ export function AvatarSSR(props: AvatarProps) {
if (user.avatar) {
imgSrc = user.avatar;
} else if (user.emailMd5) {
imgSrc = defaultAvatarSrc({ md5: user.emailMd5 });
imgSrc = defaultAvatarSrc(user.emailMd5);
}
return imgSrc ? <img alt={alt} className={className} src={imgSrc}></img> : null;
}

View File

@ -1,13 +1,16 @@
import Link from "next/link";
import { useIsEmbed } from "@calcom/embed-core";
import { useLocale } from "@lib/hooks/useLocale";
const PoweredByCal = () => {
const { t } = useLocale();
const isEmbed = useIsEmbed();
return (
<div className="p-1 text-center text-xs sm:text-right">
<div className={"p-1 text-center text-xs sm:text-right" + (isEmbed ? " max-w-3xl" : "")}>
<Link href={`https://cal.com?utm_source=embed&utm_medium=powered-by-button`}>
<a target="_blank" className="text-gray-500 opacity-50 hover:opacity-100 dark:text-white">
<a target="_blank" className="text-bookinglight opacity-50 hover:opacity-100 dark:text-white">
{t("powered_by")}{" "}
<img
className="relative -mt-px inline h-[10px] w-auto dark:hidden"

View File

@ -1,29 +1,29 @@
import React from "react";
import { Control } from "react-hook-form";
import BasePhoneInput, { Props } from "react-phone-number-input/react-hook-form";
import "react-phone-number-input/style.css";
import classNames from "@lib/classNames";
type PhoneInputProps = {
value: string;
id: string;
placeholder: string;
required: boolean;
};
export type PhoneInputProps<FormValues> = Props<
{
value: string;
id: string;
placeholder: string;
required: boolean;
},
FormValues
>;
export const PhoneInput = ({ control, name, ...rest }: Props<PhoneInputProps>) => (
<BasePhoneInput
{...rest}
name={name}
control={control}
className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
)}
onChange={() => {
/* DO NOT REMOVE: Callback required by PhoneInput, comment added to satisfy eslint:no-empty-function */
}}
/>
);
function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<FormValues>) {
return (
<BasePhoneInput
{...rest}
name={name}
control={control}
className={classNames(
"border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px px-3 shadow-sm ring-black focus-within:ring-1 dark:border-black dark:bg-black dark:text-white"
)}
/>
);
}
export default PhoneInput;

View File

@ -1,13 +1,13 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/Button";
import { DialogFooter } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
import { FieldsetLegend, Form, InputGroupBox, TextArea, TextField } from "@calcom/ui/form/fields";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import { WEBHOOK_TRIGGER_EVENTS } from "@lib/webhooks/constants";
import customTemplate, { hasTemplateIntegration } from "@lib/webhooks/integrationTemplate";
@ -22,13 +22,6 @@ export default function WebhookDialogForm(props: {
}) {
const { t } = useLocale();
const utils = trpc.useContext();
const handleSubscriberUrlChange = (e) => {
form.setValue("subscriberUrl", e.target.value);
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomPayloadTemplate(true);
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
}
};
const {
defaultValues = {
id: "",
@ -88,7 +81,13 @@ export default function WebhookDialogForm(props: {
{...form.register("subscriberUrl")}
required
type="url"
onChange={handleSubscriberUrlChange}
onChange={(e) => {
form.setValue("subscriberUrl", e.target.value);
if (hasTemplateIntegration({ url: e.target.value })) {
setUseCustomPayloadTemplate(true);
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
}
}}
/>
<fieldset className="space-y-2">

View File

@ -23,7 +23,7 @@ Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, s
and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
the AGPLv3 license. Any part of this Software distributed under the AGPLv3 license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall

View File

@ -60,7 +60,7 @@ export default function TeamAvailabilityTimes(props: Props) {
{times.map((time) => (
<div key={time.format()} className="flex flex-row items-center">
<a
className="min-w-48 border-brand text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 mr-3 block flex-grow rounded-sm border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black dark:hover:bg-black dark:hover:text-white"
className="min-w-48 border-brand text-bookingdarker hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 mr-3 block flex-grow rounded-sm border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black dark:hover:bg-black dark:hover:text-white"
data-testid="time">
{time.format("HH:mm")}
</a>

View File

@ -1,3 +1,4 @@
import { Prisma } from "@prisma/client";
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
@ -10,6 +11,7 @@ import { CalendarEvent } from "@calcom/types/Calendar";
import { IS_PRODUCTION } from "@lib/config/constants";
import { HttpError as HttpCode } from "@lib/core/http/error";
import { sendScheduledEmails } from "@lib/emails/email-manager";
import { getTranslation } from "@server/lib/i18n";
@ -21,55 +23,49 @@ export const config = {
async function handlePaymentSuccess(event: Stripe.Event) {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const payment = await prisma.payment.update({
const payment = await prisma.payment.findFirst({
where: {
externalId: paymentIntent.id,
},
data: {
success: true,
booking: {
update: {
paid: true,
confirmed: true,
},
},
select: {
id: true,
bookingId: true,
},
});
if (!payment?.bookingId) throw new Error("Payment not found");
const booking = await prisma.booking.findUnique({
where: {
id: payment.bookingId,
},
select: {
bookingId: true,
booking: {
title: true,
description: true,
startTime: true,
endTime: true,
confirmed: true,
attendees: true,
location: true,
userId: true,
id: true,
uid: true,
paid: true,
destinationCalendar: true,
user: {
select: {
title: true,
description: true,
startTime: true,
endTime: true,
confirmed: true,
attendees: true,
location: true,
userId: true,
id: true,
uid: true,
paid: true,
credentials: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
user: {
select: {
id: true,
credentials: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
},
},
});
if (!payment) throw new Error("No payment found");
const { booking } = payment;
if (!booking) throw new Error("No booking found");
const { user } = booking;
@ -110,22 +106,37 @@ async function handlePaymentSuccess(event: Stripe.Event) {
if (booking.location) evt.location = booking.location;
let bookingData: Prisma.BookingUpdateInput = {
paid: true,
confirmed: true,
};
if (booking.confirmed) {
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
await prisma.booking.update({
where: {
id: booking.id,
},
data: {
references: {
create: scheduleResult.referencesToCreate,
},
},
});
bookingData.references = { create: scheduleResult.referencesToCreate };
}
const paymentUpdate = prisma.payment.update({
where: {
id: payment.id,
},
data: {
success: true,
},
});
const bookingUpdate = prisma.booking.update({
where: {
id: booking.id,
},
data: bookingData,
});
await prisma.$transaction([paymentUpdate, bookingUpdate]);
await sendScheduledEmails({ ...evt });
throw new HttpCode({
statusCode: 200,
message: `Booking with id '${booking.id}' was paid and confirmed.`,

View File

@ -16,6 +16,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
await upgradeTeam(session.user.id, Number(req.query.team));
// redirect to team screen
res.redirect(302, `${process.env.NEXT_PUBLIC_APP_URL}/settings/teams/${req.query.team}?upgraded=true`);
res.redirect(302, `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/teams/${req.query.team}?upgraded=true`);
}
}

View File

@ -1,10 +1,7 @@
import { IdProvider } from "@radix-ui/react-id";
import { SessionProvider } from "next-auth/react";
import { appWithTranslation } from "next-i18next";
import type { AppProps as NextAppProps } from "next/app";
import React, { ComponentProps, ReactNode } from "react";
import { LiveChatLoaderProvider } from "react-live-chat-loader";
import { HelpScout } from "react-live-chat-loader";
import { ComponentProps, ReactNode } from "react";
import DynamicHelpscoutProvider from "@ee/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@ee/lib/intercom/providerDynamic";
@ -54,15 +51,13 @@ const AppProviders = (props: AppPropsWithChildren) => {
return (
<TelemetryProvider value={createTelemetryClient()}>
<IdProvider>
{isPublicPage ? (
RemainingProviders
) : (
<DynamicHelpscoutProvider>
<DynamicIntercomProvider>{RemainingProviders}</DynamicIntercomProvider>
</DynamicHelpscoutProvider>
)}
</IdProvider>
{isPublicPage ? (
RemainingProviders
) : (
<DynamicHelpscoutProvider>
<DynamicIntercomProvider>{RemainingProviders}</DynamicIntercomProvider>
</DynamicHelpscoutProvider>
)}
</TelemetryProvider>
);
};

View File

@ -1,4 +1,4 @@
const data = {};
const data: Record<string, number> = {};
/**
* Starts an iteration from `0` to `length - 1` with batch size `batch`
*

View File

@ -48,6 +48,7 @@ ${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
@ -94,6 +95,7 @@ ${this.getAdditionalNotes()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
</div>
</td>

View File

@ -47,6 +47,7 @@ ${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
${this.calEvent.cancellationReason && this.getCancellationReason()}
`.replace(/(<([^>]+)>)/gi, "");
@ -95,6 +96,7 @@ ${this.calEvent.cancellationReason && this.getCancellationReason()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
${this.calEvent.cancellationReason && this.getCancellationReason()}
</div>

View File

@ -47,6 +47,7 @@ ${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
${this.getRejectionReason()}
`.replace(/(<([^>]+)>)/gi, "");
@ -95,6 +96,7 @@ ${this.getRejectionReason()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
${this.getRejectionReason()}
</div>

View File

@ -60,6 +60,7 @@ ${this.calEvent.attendees[0].language.translate("user_needs_to_confirm_or_reject
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
`.replace(/(<([^>]+)>)/gi, "");
}
@ -109,6 +110,7 @@ ${this.getAdditionalNotes()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
</div>
</td>

View File

@ -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()}
</div>
</td>

View File

@ -112,7 +112,18 @@ export default class AttendeeScheduledEmail {
from: serverConfig.from,
};
}
protected getDescription(): string {
if (!this.calEvent.description) return "";
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.organizer.language.translate("description")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px; white-space: pre-wrap;">${
this.calEvent.description
}</p>
</div>
`;
}
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()}
</div>
</td>
@ -286,13 +298,13 @@ ${getRichDescription(this.calEvent)}
}
protected getAdditionalNotes(): string {
if (!this.calEvent.description) return "";
if (!this.calEvent.additionalNotes) return "";
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("additional_notes")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px; white-space: pre-wrap;">${
this.calEvent.description
this.calEvent.additionalNotes
}</p>
</div>
`;
@ -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;

View File

@ -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()}
</div>

View File

@ -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()}
</div>
</td>

View File

@ -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 `<a style="color: #FFFFFF; text-decoration: none;" href="${manageLink}" target="_blank">${manageText} <img src="${linkIcon()}" width="12px"></img></a>`;
}
}

View File

@ -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()}
</div>
</td>
@ -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 `<a style="color: #FFFFFF; text-decoration: none;" href="${manageLink}" target="_blank">${manageText} <img src="${linkIcon()}" width="12px"></img></a>`;
}
}

View File

@ -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()}
</div>
</td>

View File

@ -175,6 +175,7 @@ ${getRichDescription(this.calEvent)}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getDescription()}
${this.getAdditionalNotes()}
</div>
</td>
@ -287,11 +288,24 @@ ${getRichDescription(this.calEvent)}
}
protected getAdditionalNotes(): string {
if (!this.calEvent.description) return "";
if (!this.calEvent.additionalNotes) return "";
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.organizer.language.translate("additional_notes")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px; white-space: pre-wrap;">${
this.calEvent.additionalNotes
}</p>
</div>
`;
}
protected getDescription(): string {
if (!this.calEvent.description) return "";
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.organizer.language.translate("description")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px; white-space: pre-wrap;">${
this.calEvent.description
}</p>
@ -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;

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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<string>) {
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.

View File

@ -0,0 +1,14 @@
import { Team, User } from ".prisma/client";
export function isSuccessRedirectAvailable(
eventType: {
users: {
plan: User["plan"];
}[];
} & {
team: Partial<Team> | 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;
}

View File

@ -1 +1 @@
export * from "@calcom/lib/location";
export * from "@calcom/core/location";

View File

@ -13,6 +13,7 @@ export type BookingCreateBody = {
userSignature: unknown;
};
eventTypeId: number;
eventTypeSlug: string;
guests?: string[];
location: string;
name: string;

View File

@ -25,6 +25,7 @@ module.exports = {
"cs",
"sr",
"sv",
"vi",
],
},
localePath: path.resolve("./public/static/locales"),

View File

@ -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);

View File

@ -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",

View File

@ -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() {
/>
<div className="min-h-screen bg-white px-4" data-testid="404-page">
<main className="mx-auto max-w-xl pt-16 pb-6 sm:pt-24">
{isSignup && process.env.NEXT_PUBLIC_BASE_URL !== "https://app.cal.com" ? (
{isSignup && process.env.NEXT_PUBLIC_WEBAPP_URL !== "https://app.cal.com" ? (
<div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-black">

View File

@ -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<typeof getServerSideProps>) {
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;
}) ? (
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">{" " + t("unavailable")}</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
</div>
</div>
</div>
) : (
<ul className="space-y-3">
{eventTypes.map((type, index) => (
<li
key={index}
className="hover:border-brand group relative rounded-sm border border-neutral-200 bg-white hover:bg-gray-50 dark:border-0 dark:bg-neutral-900 dark:hover:border-neutral-600">
<ArrowRightIcon className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
<Link href={getUsernameSlugLink({ users: props.users, slug: type.slug })}>
<a className="flex justify-between px-6 py-4" data-testid="event-type-link">
<div className="flex-shrink">
<h2 className="font-cal font-semibold text-neutral-900 dark:text-white">{type.title}</h2>
<EventTypeDescription className="text-sm" eventType={type} />
</div>
<div className="mt-1">
<AvatarGroup
border="border-2 border-white"
truncateAfter={4}
className="flex-shrink-0"
size={10}
items={props.users.map((user) => ({
alt: user.name || "",
image: user.avatar || "",
}))}
/>
</div>
</a>
</Link>
</li>
))}
</ul>
);
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<EvtsToVerify>({});
const isEmbed = useIsEmbed();
return (
<>
<Theme />
<HeadSeo
title={nameOrUsername}
description={(user.bio as string) || ""}
name={nameOrUsername}
username={(user.username as string) || ""}
title={isDynamicGroup ? dynamicNames.join(", ") : nameOrUsername}
description={
isDynamicGroup ? `Book events with ${dynamicUsernames.join(", ")}` : (user.bio as string) || ""
}
name={isDynamicGroup ? dynamicNames.join(", ") : nameOrUsername}
username={isDynamicGroup ? dynamicUsernames.join(", ") : (user.username as string) || ""}
// avatar={user.avatar || undefined}
/>
<div className="h-screen dark:bg-neutral-900">
<div className={"h-screen dark:bg-neutral-900" + isEmbed ? " bg:white m-auto max-w-3xl" : ""}>
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="mb-8 text-center">
<AvatarSSR user={user} className="mx-auto mb-4 h-24 w-24" alt={nameOrUsername}></AvatarSSR>
<h1 className="font-cal mb-1 text-3xl text-neutral-900 dark:text-white">
{nameOrUsername}
{user.verified && (
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
)}
</h1>
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
</div>
{isSingleUser && ( // When we deal with a single user, not dynamic group
<div className="mb-8 text-center">
<AvatarSSR user={user} className="mx-auto mb-4 h-24 w-24" alt={nameOrUsername}></AvatarSSR>
<h1 className="font-cal mb-1 text-3xl text-neutral-900 dark:text-white">
{nameOrUsername}
{user.verified && (
<BadgeCheckIcon className="mx-1 -mt-1 inline h-6 w-6 text-blue-500 dark:text-white" />
)}
</h1>
<p className="text-neutral-500 dark:text-white">{user.bio}</p>
</div>
)}
<div className="space-y-6" data-testid="event-types">
{user.away ? (
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
@ -67,11 +144,13 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
<p className="mx-auto max-w-md">{t("user_away_description")}</p>
</div>
</div>
) : isDynamicGroup ? ( //When we deal with dynamic group (users > 1)
groupEventTypes
) : (
eventTypes.map((type) => (
<div
key={type.id}
style={{ display: "flex" }}
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
className="hover:border-brand group relative rounded-sm border border-neutral-200 bg-white hover:bg-gray-50 dark:border-neutral-700 dark:bg-gray-800 dark:hover:border-neutral-600">
<ArrowRightIcon className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
@ -91,6 +170,10 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
"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<typeof getServerSideProps>) {
);
}
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,

View File

@ -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<typeof getServerSideProps>;
export default function Type(props: AvailabilityPageProps) {
return <AvailabilityPage {...props} />;
const { t } = useLocale();
return props.isDynamicGroup && !props.profile.allowDynamicBooking ? (
<div className="h-screen dark:bg-neutral-900">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">
{" " + t("unavailable")}
</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
</div>
</div>
</div>
</main>
</div>
) : (
<AvailabilityPage {...props} />
);
}
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,

View File

@ -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<typeof getServerSideProps>;
export default function Book(props: BookPageProps) {
return <BookingPage {...props} />;
const { t } = useLocale();
return props.isDynamicGroupBooking && !props.profile.allowDynamicBooking ? (
<div className="h-screen dark:bg-neutral-900">
<main className="mx-auto max-w-3xl px-4 py-24">
<div className="space-y-6" data-testid="event-types">
<div className="overflow-hidden rounded-sm border dark:border-gray-900">
<div className="p-8 text-center text-gray-400 dark:text-white">
<h2 className="font-cal mb-2 text-3xl text-gray-600 dark:text-white">
{" " + t("unavailable")}
</h2>
<p className="mx-auto max-w-md">{t("user_dynamic_booking_disabled")}</p>
</div>
</div>
</div>
</main>
</div>
) : (
<BookingPage {...props} />
);
}
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,
},
};
}

View File

@ -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 (
<ContractsProvider>
<AppProviders {...props}>
<DefaultSeo {...seoConfig.defaultNextSeo} />
<I18nLanguageHandler />
<Head>
<script dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}></script>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
</Head>
<Component {...pageProps} err={err} />

View File

@ -5,10 +5,12 @@ type Props = Record<string, unknown> & DocumentProps;
class MyDocument extends Document<Props> {
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<Props> {
<meta name="msapplication-TileColor" content="#ff0000" />
<meta name="theme-color" content="#ffffff" />
</Head>
<body className="bg-gray-100 dark:bg-neutral-900">
{/* Keep the embed hidden till parent initializes and gives it the appropriate styles */}
<body className="bg-gray-100 dark:bg-neutral-900" style={props.isEmbed ? { display: "none" } : {}}>
<Main />
<NextScript />
</body>

View File

@ -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" });
}
}

View File

@ -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" });

View File

@ -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

View File

@ -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<typeof userSelect>;
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 + "<br /><br />" + input.label + ":<br />" + 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,

View File

@ -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

View File

@ -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) {

View File

@ -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 }),

View File

@ -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<typeof getStaticProps>) {
const components = {
a: ({ href = "", ...otherProps }: JSX.IntrinsicElements["a"]) => (
<Link href={href}>
<a {...otherProps} />
</Link>
),
img: ({ src = "", alt = "", placeholder, ...rest }: JSX.IntrinsicElements["img"]) => (
<Image src={src} alt={alt} {...rest} />
),
Slider: ({ items }: { items: string[] }) => {
const isTabletAndUp = useMediaQuery("(min-width: 960px)");
return (
<Slider<string>
items={items}
title="Screenshots"
options={{
perView: 1,
}}
renderItem={(item) =>
isTabletAndUp ? (
<Image src={item} alt="" loading="eager" layout="fixed" width={573} height={382} />
) : (
<Image src={item} alt="" layout="responsive" width={573} height={382} />
)
}
/>
);
},
};
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
return (
<App
name={data.name}
@ -23,7 +62,7 @@ function SingleAppPage({ data }: inferSSRProps<typeof getStaticProps>) {
email={data.email}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={data.description}
body={<MDXRemote {...source} components={components} />}
/>
);
}
@ -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,
},
};

View File

@ -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<typeof getSta
return (
<>
<Shell large>
<div className="-mx-8">
<div className="mb-10 bg-gray-50 px-10 pb-2">
<Shell isPublic large>
<div className="-mx-4 md:-mx-8">
<div className="mb-10 bg-gray-50 px-4 pb-2">
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
@ -28,7 +27,7 @@ export default function Apps({ appStore }: InferGetStaticPropsType<typeof getSta
</div>
<div className="mb-16">
<h2 className="mb-2 text-lg font-semibold text-gray-900">All {router.query.category} apps</h2>
<div className="grid grid-cols-3 gap-3">
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{appStore.map((app) => {
return (
app.category === router.query.category && (

View File

@ -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<typeof getStaticProps>) {
const { t } = useLocale();
return (
<Shell isPublic large>
<div className="-mx-4 md:-mx-8">
<div className="mb-10 bg-gray-50 px-4 pb-2">
<Link href="/apps">
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
<ChevronLeftIcon className="h-5 w-5" /> {t("browse_apps")}
</a>
</Link>
</div>
</div>
<div className="mb-16">
<AppStoreCategories categories={categories} />
</div>
</Shell>
);
}
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<string, number>);
return {
props: {
categories: Object.entries(categories).map(([name, count]) => ({ name, count })),
},
};
};

View File

@ -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<typeof getStaticProps>) {
const { t } = useLocale();
return (
<Shell heading={t("app_store")} subtitle={t("app_store_description")} large>
<Shell heading={t("app_store")} subtitle={t("app_store_description")} large isPublic>
<AppsShell>
<AppStoreCategories categories={categories} />
<Slider items={appStore} />
<TrendingAppsSlider items={appStore} />
<AllApps apps={appStore} />
</AppsShell>
</Shell>

View File

@ -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 = `<iframe src="${process.env.NEXT_PUBLIC_BASE_URL}/${user?.username}" frameborder="0" allowfullscreen></iframe>`;
const iframeTemplate = `<iframe src="${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user?.username}" frameborder="0" allowfullscreen></iframe>`;
const htmlTemplate = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${t(
"schedule_a_meeting"
)}</title><style>body {margin: 0;}iframe {height: calc(100vh - 4px);width: calc(100vw - 4px);box-sizing: border-box;}</style></head><body>${iframeTemplate}</body></html>`;
@ -47,7 +48,7 @@ function IframeEmbedContainer() {
<ListItemTitle component="h3">{t("standard_iframe")}</ListItemTitle>
<ListItemText component="p">{t("embed_your_calendar")}</ListItemText>
</div>
<div>
<div className="text-right">
<input
id="iframe"
className="focus:border-brand px-2 py-1 text-sm text-gray-500 focus:ring-black"
@ -306,14 +307,16 @@ export default function IntegrationsPage() {
const { t } = useLocale();
return (
<Shell heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
<ClientSuspense fallback={<Loader />}>
<IntegrationsContainer />
<CalendarListContainer />
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
<IframeEmbedContainer />
<Web3Container />
</ClientSuspense>
<Shell heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")} large>
<AppsShell>
<ClientSuspense fallback={<Loader />}>
<IntegrationsContainer />
<CalendarListContainer />
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
<IframeEmbedContainer />
<Web3Container />
</ClientSuspense>
</AppsShell>
</Shell>
);
}

View File

@ -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);
}

View File

@ -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">
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...form.register("csrfToken")} />
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>

View File

@ -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<typeof getServerSideProps>;
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") {

View File

@ -91,7 +91,7 @@ export default function Signup({ email }: Props) {
<TextField
addOnLeading={
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_APP_URL}/
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
}
labelProps={{ className: "block text-sm font-medium text-gray-700" }}

View File

@ -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,
});

View File

@ -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 = <T extends UseFormReturn<any, any>>({
eventType,
formMethods,
}: {
eventType: inferSSRProps<typeof getServerSideProps>["eventType"];
formMethods: T;
}) => {
const { t } = useLocale();
const proUpgradeRequired = !isSuccessRedirectAvailable(eventType);
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<hr className="border-neutral-200" />
<div className="block sm:flex">
<div className="min-w-48 sm:mb-0">
<label
htmlFor="successRedirectUrl"
className="flex h-full items-center text-sm font-medium text-neutral-700">
{t("redirect_success_booking")}
<span className="ml-1">{proUpgradeRequired && <Badge variant="default">PRO</Badge>}</span>
</label>
</div>
<div className="w-full">
<input
id="successRedirectUrl"
onClick={(e) => {
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")}
/>
</div>
<UpgradeToProDialog modalOpen={modalOpen} setModalOpen={setModalOpen}>
{t("redirect_url_upgrade_description")}
</UpgradeToProDialog>
</div>
</>
);
};
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 (
<Select
{...props}
options={options}
isSearchable={false}
onChange={props.onChange}
classNamePrefix="react-select"
className={classNames(
"react-select-container focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm",
@ -148,19 +201,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
prefix: t("indefinitely_into_future"),
},
];
const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
props;
/** Appending default locations */
const defaultLocations = [
{ value: LocationType.InPerson, label: t("in_person_meeting") },
{ value: LocationType.Link, label: t("link_meeting") },
{ value: LocationType.Jitsi, label: "Jitsi Meet" },
{ value: LocationType.Phone, label: t("phone_call") },
];
addDefaultLocationOptions(defaultLocations, locationOptions);
const { eventType, locationOptions, team, teamMembers, hasPaymentIntegration, currency } = props;
const router = useRouter();
@ -175,13 +216,21 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
);
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not able to update this event`;
message = `${err.data.code}: You are not able to update this event`;
}
if (err.data?.code === "PARSE_ERROR") {
message = `${err.data.code}: ${err.message}`;
}
if (message) {
showToast(message, "error");
}
},
@ -393,7 +442,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
endDate: new Date(eventType.periodEndDate || Date.now()),
});
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/${
const permalink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${
team ? `team/${team.slug}` : eventType.users[0].username
}/${eventType.slug}`;
@ -408,7 +457,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
}) => ({
value: `${id || ""}`,
label: `${name || ""}`,
avatar: `${process.env.NEXT_PUBLIC_APP_URL}/${username}/avatar.png`,
avatar: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${username}/avatar.png`,
});
const formMethods = useForm<{
@ -423,7 +472,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
requiresConfirmation: boolean;
schedulingType: SchedulingType | null;
price: number;
currency: string;
hidden: boolean;
hideCalendarNotes: boolean;
locations: { type: LocationType; address?: string; link?: string }[];
customInputs: EventTypeCustomInput[];
users: string[];
@ -440,6 +491,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
integration: string;
externalId: string;
};
successRedirectUrl: string;
}>({
defaultValues: {
locations: eventType.locations || [],
@ -784,10 +836,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
locationFormMethods.unregister("locationAddress");
openLocationModal(location.type);
}}
aria-label={t("edit")}
className="mr-1 p-1 text-gray-500 hover:text-gray-900">
<PencilIcon className="h-4 w-4" />
</button>
<button type="button" onClick={() => removeLocation(location)}>
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
<XIcon className="border-l-1 h-6 w-6 pl-1 text-gray-500 hover:text-gray-900 " />
</button>
</div>
@ -863,6 +916,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
locations,
...input
} = values;
if (requirePayment) input.currency = currency;
updateMutation.mutate({
...input,
locations,
@ -883,7 +939,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="space-y-3">
<div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label htmlFor="slug" className="flex text-sm font-medium text-neutral-700">
<label
id="slug-label"
htmlFor="slug"
className="flex text-sm font-medium text-neutral-700">
<LinkIcon className="mt-0.5 h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
{t("url")}
</label>
@ -891,11 +950,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<div className="w-full">
<div className="flex rounded-sm shadow-sm">
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_APP_URL?.replace(/^(https?:|)\/\//, "")}/
{process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}/
{team ? "team/" + team.slug : eventType.users[0].username}/
</span>
<input
type="text"
id="slug"
aria-labelledby="slug-label"
required
className="focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-1 rounded-none rounded-r-sm border-gray-300 sm:text-sm"
defaultValue={eventType.slug}
@ -988,10 +1049,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
control={formMethods.control}
render={({ field }) => (
<AvailabilitySelect
{...field}
onChange={(selected: { label: string; value: number }) =>
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<typeof getServerSideProps>) => {
</div>
</div>
<Controller
name="hideCalendarNotes"
control={formMethods.control}
defaultValue={eventType.hideCalendarNotes}
render={() => (
<CheckboxField
id="hideCalendarNotes"
name="hideCalendarNotes"
label={t("disable_notes")}
description={t("disable_notes_description")}
defaultChecked={eventType.hideCalendarNotes}
onChange={(e) => {
formMethods.setValue("hideCalendarNotes", e?.target.checked);
}}
/>
)}
/>
<Controller
name="requiresConfirmation"
control={formMethods.control}
@ -1492,7 +1571,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
</div>
</div>
<SuccessRedirectEdit<typeof formMethods>
formMethods={formMethods}
eventType={eventType}></SuccessRedirectEdit>
{hasPaymentIntegration && (
<>
<hr className="border-neutral-200" />
@ -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;
})
: [];

View File

@ -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`,
}))}
/>
)}
<Tooltip content={t("preview")}>
<a
href={`${process.env.NEXT_PUBLIC_APP_URL}/${group.profile.slug}/${type.slug}`}
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`}
target="_blank"
rel="noreferrer"
className="btn-icon appearance-none">
@ -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
</DropdownMenuTrigger>
<DropdownMenuContent portalled>
<DropdownMenuItem>
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/${group.profile.slug}/${type.slug}`}>
<Link
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`}>
<a target="_blank">
<Button
color="minimal"
@ -342,7 +343,7 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J
StartIcon={ClipboardCopyIcon}
onClick={() => {
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}`
);
showToast(t("link_copied"), "success");
}}>
@ -363,7 +364,7 @@ export const EventTypeList = ({ group, readOnly, types }: EventTypeListProps): J
.share({
title: t("share"),
text: t("share_event"),
url: `${process.env.NEXT_PUBLIC_APP_URL}/${group.profile.slug}/${type.slug}`,
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${group.profile.slug}/${type.slug}`,
})
.then(() => showToast(t("link_shared"), "success"))
.catch(() => showToast(t("failed"), "error"));
@ -463,8 +464,8 @@ const EventTypeListHeading = ({ profile, membershipCount }: EventTypeListHeading
</span>
)}
{profile?.slug && (
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}`}>
<a className="block text-xs text-neutral-500">{`${process.env.NEXT_PUBLIC_APP_URL?.replace(
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/${profile.slug}`}>
<a className="block text-xs text-neutral-500">{`${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(
"https://",
""
)}/${profile.slug}`}</a>

View File

@ -253,7 +253,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
}>({ resolver: zodResolver(schema), mode: "onSubmit" });
const fetchUsername = async (username: string) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/username`, {
const response = await fetch(`${process.env.NEXT_PUBLIC_WEBSITE_URL}/api/username`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
@ -268,7 +268,7 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
// Should update username on user when being redirected from sign up and doing google/saml
useEffect(() => {
async function validateAndSave(username) {
async function validateAndSave(username: string) {
const { data } = await fetchUsername(username);
// Only persist username if its available and not premium

View File

@ -1,5 +1,7 @@
import { GetServerSidePropsContext } from "next";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { asStringOrUndefined } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
@ -30,6 +32,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
},
},
dynamicEventSlugRef: true,
dynamicGroupSlugRef: true,
user: true,
title: true,
description: true,
@ -38,17 +42,19 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
attendees: true,
},
});
const dynamicEventSlugRef = booking?.dynamicEventSlugRef || "";
if (!booking?.eventType && !booking?.dynamicEventSlugRef) throw Error("This booking doesn't exists");
if (!booking?.eventType) throw Error("This booking doesn't exists");
const eventType = booking.eventType;
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
const eventPage =
(eventType.team
? "team/" + eventType.team.slug
: dynamicEventSlugRef
? booking.dynamicGroupSlugRef
: booking.user?.username || "rick") /* This shouldn't happen */ +
"/" +
booking.eventType.slug;
eventType?.slug;
return {
redirect: {

View File

@ -1,9 +1,7 @@
import { InformationCircleIcon } from "@heroicons/react/outline";
import { TrashIcon } from "@heroicons/react/solid";
import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { Trans } from "next-i18next";
import { useRouter } from "next/router";
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
import Select from "react-select";
@ -12,7 +10,7 @@ import TimezoneSelect, { ITimezone } from "react-timezone-select";
import showToast from "@calcom/lib/notification";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { TextField } from "@calcom/ui/form/fields";
import { QueryCell } from "@lib/QueryCell";
@ -31,13 +29,16 @@ import Shell from "@components/Shell";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import Avatar from "@components/ui/Avatar";
import Badge from "@components/ui/Badge";
import InfoBadge from "@components/ui/InfoBadge";
import ColorPicker from "@components/ui/colorpicker";
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
type Props = inferSSRProps<typeof getServerSideProps>;
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
const { t } = useLocale();
const [modelOpen, setModalOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
return (
<>
@ -61,39 +62,9 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
setModalOpen(true);
}}
/>
<Dialog open={modelOpen}>
<DialogContent>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
<InformationCircleIcon className="h-6 w-6 text-yellow-400" aria-hidden="true" />
</div>
<div className="mb-4 sm:flex sm:items-start">
<div className="mt-3 sm:mt-0 sm:text-left">
<h3 className="font-cal text-lg leading-6 text-gray-900" id="modal-title">
{t("only_available_on_pro_plan")}
</h3>
</div>
</div>
<div className="flex flex-col space-y-3">
<p>{t("remove_cal_branding_description")}</p>
<p>
<Trans i18nKey="plan_upgrade_instructions">
You can
<a href="/api/upgrade" className="underline">
upgrade here
</a>
.
</Trans>
</p>
</div>
<div className="mt-5 gap-x-2 sm:mt-4 sm:flex sm:flex-row-reverse">
<DialogClose asChild>
<Button className="btn-wide table-cell text-center" onClick={() => setModalOpen(false)}>
{t("dismiss")}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
<UpgradeToProDialog modalOpen={modalOpen} setModalOpen={setModalOpen}>
{t("remove_cal_branding_description")}
</UpgradeToProDialog>
</>
);
}
@ -127,7 +98,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { 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<typeof Settings> & { localeProp: str
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
const avatarRef = useRef<HTMLInputElement>(null!);
const hideBrandingRef = useRef<HTMLInputElement>(null!);
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
value: props.user.timeFormat || 12,
@ -198,6 +170,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { 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<typeof Settings> & { 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<typeof Settings> & { localeProp: str
name="username"
addOnLeading={
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
{process.env.NEXT_PUBLIC_APP_URL}/
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
}
ref={usernameRef}
@ -393,6 +367,25 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
/>
</div>
</div>
<div className="relative mt-8 flex items-start">
<div className="flex h-5 items-center">
<input
id="dynamic-group-booking"
name="dynamic-group-booking"
type="checkbox"
ref={allowDynamicGroupBookingRef}
defaultChecked={props.user.allowDynamicBooking || false}
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-800"
/>
</div>
<div className="text-sm ltr:ml-3 rtl:mr-3">
<label
htmlFor="dynamic-group-booking"
className="flex items-center font-medium text-gray-700">
{t("allow_dynamic_booking")} <InfoBadge content={t("allow_dynamic_booking_tooltip")} />
</label>
</div>
</div>
<div>
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
{t("single_theme")}
@ -537,6 +530,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
darkBrandColor: true,
metadata: true,
timeFormat: true,
allowDynamicBooking: true,
},
});

View File

@ -1,5 +1,6 @@
import { CheckIcon } from "@heroicons/react/outline";
import { ClockIcon } from "@heroicons/react/solid";
import { ClockIcon, XIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
@ -8,16 +9,20 @@ import { createEvent } from "ics";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useIsEmbed, useEmbedStyles, useIsBackgroundTransparent } from "@calcom/embed-core";
import { sdkActionManager } from "@calcom/embed-core";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { EmailInput } from "@calcom/ui/form/fields";
import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
import { getEventName } from "@lib/event";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable";
import prisma from "@lib/prisma";
import { isBrowserLocale24h } from "@lib/timeFormat";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -31,6 +36,97 @@ dayjs.extend(utc);
dayjs.extend(toArray);
dayjs.extend(timezone);
function redirectToExternalUrl(url: string) {
window.parent.location.href = url;
}
/**
* Redirects to external URL with query params from current URL.
* Query Params and Hash Fragment if present in external URL are kept intact.
*/
function RedirectionToast({ url }: { url: string }) {
const [timeRemaining, setTimeRemaining] = useState(10);
const [isToastVisible, setIsToastVisible] = useState(true);
const parsedSuccessUrl = new URL(document.URL);
const parsedExternalUrl = new URL(url);
/* @ts-ignore */ //https://stackoverflow.com/questions/49218765/typescript-and-iterator-type-iterableiteratort-is-not-an-array-type
for (let [name, value] of parsedExternalUrl.searchParams.entries()) {
parsedSuccessUrl.searchParams.set(name, value);
}
const urlWithSuccessParams =
parsedExternalUrl.origin +
parsedExternalUrl.pathname +
"?" +
parsedSuccessUrl.searchParams.toString() +
parsedExternalUrl.hash;
const { t } = useLocale();
const timerRef = useRef<number | null>(null);
useEffect(() => {
timerRef.current = window.setInterval(() => {
if (timeRemaining > 0) {
setTimeRemaining((timeRemaining) => {
return timeRemaining - 1;
});
} else {
redirectToExternalUrl(urlWithSuccessParams);
window.clearInterval(timerRef.current as number);
}
}, 1000);
return () => {
window.clearInterval(timerRef.current as number);
};
}, [timeRemaining, urlWithSuccessParams]);
if (!isToastVisible) {
return null;
}
return (
<>
<div className="relative inset-x-0 top-0 z-[60] pb-2 sm:fixed sm:top-2 sm:pb-5">
<div className="mx-auto w-full sm:max-w-7xl sm:px-2 lg:px-8">
<div className="border border-green-600 bg-green-500 p-2 sm:p-3">
<div className="flex flex-wrap items-center justify-between">
<div className="flex w-0 flex-1 items-center">
<p className="truncate font-medium text-white sm:mx-3">
<span className="md:hidden">Redirecting to {url} ...</span>
<span className="hidden md:inline">
{t("you_are_being_redirected", { url, seconds: timeRemaining })}
</span>
</p>
</div>
<div className="order-3 mt-2 w-full flex-shrink-0 sm:order-2 sm:mt-0 sm:w-auto">
<button
onClick={() => {
redirectToExternalUrl(urlWithSuccessParams);
}}
className="flex w-full items-center justify-center rounded-sm border border-transparent bg-white px-4 py-2 text-sm font-medium text-green-600 shadow-sm hover:bg-green-50">
{t("continue")}
</button>
</div>
<div className="order-2 flex-shrink-0 sm:order-3 sm:ml-2">
<button
type="button"
onClick={() => {
setIsToastVisible(false);
window.clearInterval(timerRef.current as number);
}}
className="-mr-1 flex rounded-md p-2 hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-white">
<XIcon className="h-6 w-6 text-white" />
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}
export default function Success(props: inferSSRProps<typeof getServerSideProps>) {
const { t } = useLocale();
const router = useRouter();
@ -40,23 +136,41 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
const [date, setDate] = useState(dayjs.utc(asStringOrThrow(router.query.date)));
const { isReady, Theme } = useTheme(props.profile.theme);
const { eventType } = props;
useEffect(() => {
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
}, []);
const isBackgroundTransparent = useIsBackgroundTransparent();
const isEmbed = useIsEmbed();
const attendeeName = typeof name === "string" ? name : "Nameless";
const eventNameObject = {
attendeeName,
eventType: props.eventType.title,
eventName: props.eventType.eventName,
eventName: (props.dynamicEventName as string) || props.eventType.eventName,
host: props.profile.name || "Nameless",
t,
};
const eventName = getEventName(eventNameObject);
const needsConfirmation = eventType.requiresConfirmation && reschedule != "true";
useEffect(() => {
const users = eventType.users;
// TODO: We should probably make it consistent with Webhook payload. Some data is not available here, as and when requirement comes we can add
sdkActionManager!.fire("bookingSuccessful", {
eventType,
date: date.toString(),
duration: eventType.length,
organizer: {
name: users[0].name || "Nameless",
email: users[0].email || "Email-less",
timeZone: users[0].timeZone,
},
confirmed: !needsConfirmation,
// TODO: Add payment details
});
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
}, [eventType, needsConfirmation]);
function eventLink(): string {
const optional: { location?: string } = {};
@ -87,26 +201,36 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
return encodeURIComponent(event.value ? event.value : false);
}
const needsConfirmation = props.eventType.requiresConfirmation && reschedule != "true";
return (
(isReady && (
<div className="h-screen bg-neutral-100 dark:bg-neutral-900" data-testid="success-page">
<div
className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"}
data-testid="success-page">
<Theme />
<HeadSeo
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
/>
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className="mx-auto max-w-3xl py-24">
<div className="fixed inset-0 z-50 overflow-y-auto">
<main className={classNames("mx-auto", isEmbed ? "" : "max-w-3xl py-24")}>
<div className={classNames("overflow-y-auto", isEmbed ? "" : "fixed inset-0 z-50 ")}>
{isSuccessRedirectAvailable(eventType) && eventType.successRedirectUrl ? (
<RedirectionToast url={eventType.successRedirectUrl}></RedirectionToast>
) : null}{" "}
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
<div
className={classNames("my-4 transition-opacity sm:my-0", isEmbed ? "" : "fixed inset-0")}
aria-hidden="true">
<span className="inline-block h-screen align-middle" aria-hidden="true">
&#8203;
</span>
<div
className="inline-block transform overflow-hidden rounded-sm border border-neutral-200 bg-white px-8 pt-5 pb-4 text-left align-bottom transition-all dark:border-neutral-700 dark:bg-gray-800 sm:my-8 sm:w-full sm:max-w-lg sm:py-6 sm:align-middle"
className={classNames(
"inline-block transform overflow-hidden rounded-sm",
isEmbed ? "" : "border sm:my-8 sm:max-w-lg ",
isBackgroundTransparent ? "" : "bg-white dark:border-neutral-700 dark:bg-gray-800",
"px-8 pt-5 pb-4 text-left align-bottom transition-all sm:w-full sm:py-6 sm:align-middle"
)}
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
@ -130,7 +254,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
: t("emailed_you_and_attendees")}
</p>
</div>
<div className="mt-4 grid grid-cols-3 border-t border-b py-4 text-left text-gray-700 dark:border-gray-900 dark:text-gray-300">
<div className="border-bookinglightest text-bookingdark mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:border-gray-900 dark:text-gray-300">
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">{t("when")}</div>
@ -138,7 +262,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
{date.format("dddd, DD MMMM YYYY")}
<br />
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
<span className="text-gray-500">
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
@ -160,7 +284,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</div>
</div>
{!needsConfirmation && (
<div className="mt-5 flex border-b pt-2 pb-4 text-center dark:border-gray-900 sm:mt-0 sm:pt-4">
<div className="border-bookinglightest mt-5 flex border-b pt-2 pb-4 text-center dark:border-gray-900 sm:mt-0 sm:pt-4">
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
{t("add_to_calendar")}
</span>
@ -259,7 +383,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
</div>
)}
{!props.hideBranding && (
<div className="pt-4 text-center text-xs text-gray-400 dark:border-gray-900 dark:text-white">
<div className="border-bookinglightest text-booking-lighter pt-4 text-center text-xs dark:border-gray-900 dark:text-white">
<a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a>
<form
@ -272,7 +396,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
name="email"
id="email"
defaultValue={router.query.email}
className="focus:border-brand mt-0 block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
className="focus:border-brand border-bookinglightest mt-0 block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:border-gray-900 dark:bg-black dark:text-white sm:text-sm"
placeholder="rick.astley@cal.com"
/>
<Button size="lg" type="submit" className="min-w-max" color="primary">
@ -292,17 +416,8 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
if (isNaN(typeId)) {
return {
notFound: true,
};
}
const eventType = await prisma.eventType.findUnique({
const getEventTypesFromDB = async (typeId: number) => {
return await prisma.eventType.findUnique({
where: {
id: typeId,
},
@ -314,6 +429,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventName: true,
requiresConfirmation: true,
userId: true,
successRedirectUrl: true,
users: {
select: {
name: true,
@ -322,6 +438,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
theme: true,
brandColor: true,
darkBrandColor: true,
email: true,
timeZone: true,
},
},
team: {
@ -332,6 +450,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
},
});
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
const dynamicEventName = asStringOrNull(context.query.eventName) ?? "";
if (isNaN(typeId)) {
return {
notFound: true,
};
}
const eventType = !typeId ? getDefaultEvent(typeSlug) : await getEventTypesFromDB(typeId);
if (!eventType) {
return {
@ -351,6 +484,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
theme: true,
brandColor: true,
darkBrandColor: true,
email: true,
timeZone: true,
},
});
if (user) {
@ -364,11 +499,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
// if (!typeId) eventType["eventName"] = getDynamicEventName(users, typeSlug);
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
email: eventType.team ? null : eventType.users[0].email || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor,
brandColor: eventType.team ? null : eventType.users[0].brandColor || null,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
};
return {
@ -377,6 +515,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
profile,
eventType,
trpcState: ssr.dehydrate(),
dynamicEventName,
},
};
}

View File

@ -1,12 +1,15 @@
import { ArrowRightIcon } from "@heroicons/react/solid";
import { UserPlan } from "@prisma/client";
import classNames from "classnames";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import React from "react";
import { useIsEmbed } from "@calcom/embed-core";
import Button from "@calcom/ui/Button";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
import { useLocale } from "@lib/hooks/useLocale";
import useTheme from "@lib/hooks/useTheme";
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
@ -27,13 +30,17 @@ function TeamPage({ team }: TeamPageProps) {
const { isReady, Theme } = useTheme();
const showMembers = useToggleQuery("members");
const { t } = useLocale();
useExposePlanGlobally("PRO");
const isEmbed = useIsEmbed();
const eventTypes = (
<ul className="space-y-3">
{team.eventTypes.map((type) => (
<li
key={type.id}
className="hover:border-brand group relative rounded-sm border border-neutral-200 bg-white hover:bg-gray-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600">
className={classNames(
"hover:border-brand group relative rounded-sm border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600",
isEmbed ? "" : "bg-white hover:bg-gray-50"
)}>
<ArrowRightIcon className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
<Link href={`${team.slug}/${type.slug}`}>
<a className="flex justify-between px-6 py-4">

View File

@ -1,6 +1,8 @@
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { UserPlan } from "@calcom/prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import prisma from "@lib/prisma";
@ -38,6 +40,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
select: {
id: true,
slug: true,
users: {
select: {
id: true,
@ -109,6 +112,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
// Team is always pro
plan: "PRO" as UserPlan,
profile: {
name: team.name || team.slug,
slug: team.slug,

View File

@ -2,12 +2,16 @@ import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils";
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";
export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
export default function TeamBookingPage(props: TeamBookingPageProps) {
@ -94,8 +98,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking = await getBooking();
}
const t = await getTranslation(context.locale ?? "en", "common");
return {
props: {
locationLabels: getLocationLabels(t),
profile: {
...eventTypeObject.team,
slug: "team/" + eventTypeObject.slug,
@ -103,9 +110,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
brandColor: null /* Teams don't have a brandColor, and `BookingPage` uses it */,
darkBrandColor: null /* Teams don't have a darkBrandColor, and `BookingPage` uses it */,
eventName: null,
},
eventType: eventTypeObject,
booking,
isDynamicGroupBooking: false,
},
};
}

Some files were not shown because too many files have changed in this diff Show More