84
.env.appStore.example
Normal file
84
.env.appStore.example
Normal file
@@ -0,0 +1,84 @@
|
||||
# ********** INDEX **********
|
||||
#
|
||||
# - APP STORE
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - HUBSPOT
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
# - TANDEM
|
||||
# - ZOOM
|
||||
# - GIPHY
|
||||
# - VITAL
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
# - 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
|
||||
|
||||
# - HUBSPOT
|
||||
# Used for the HubSpot integration
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-hubspot-client-id-and-secret
|
||||
HUBSPOT_CLIENT_ID=""
|
||||
HUBSPOT_CLIENT_SECRET=""
|
||||
|
||||
# - 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 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=
|
||||
|
||||
# - GIPHY
|
||||
# Used for the Giphy integration
|
||||
# @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key
|
||||
GIPHY_API_KEY=
|
||||
|
||||
# - VITAL
|
||||
# Used for the vital integration
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-vital-api-keys
|
||||
VITAL_API_KEY=
|
||||
VITAL_WEBHOOK_SECRET=
|
||||
# "sandbox" | "prod" | "production" | "development"
|
||||
VITAL_DEVELOPMENT_MODE="sandbox"
|
||||
# "us" | "eu"
|
||||
VITAL_REGION="us"
|
||||
# *********************************************************************************************************
|
||||
72
.env.example
72
.env.example
@@ -5,16 +5,6 @@
|
||||
# - SHARED
|
||||
# - NEXTAUTH
|
||||
# - E-MAIL SETTINGS
|
||||
# - APP STORE
|
||||
# - DAILY.CO VIDEO
|
||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||
# - HUBSPOT
|
||||
# - OFFICE 365
|
||||
# - SLACK
|
||||
# - STRIPE
|
||||
# - TANDEM
|
||||
# - ZOOM
|
||||
# - GIPHY
|
||||
|
||||
# - LICENSE *************************************************************************************************
|
||||
# Set this value to 'agree' to accept our license:
|
||||
@@ -35,6 +25,7 @@ NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000'
|
||||
# Change to 'http://localhost:3001' if running the website simultaneously
|
||||
NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000'
|
||||
NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js'
|
||||
|
||||
# To enable SAML login, set both these variables
|
||||
# @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login
|
||||
@@ -115,64 +106,3 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||
## @see https://support.google.com/accounts/answer/185833
|
||||
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
|
||||
# **********************************************************************************************************
|
||||
|
||||
# - APP STORE **********************************************************************************************
|
||||
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
|
||||
# - 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
|
||||
|
||||
# - HUBSPOT
|
||||
# Used for the HubSpot integration
|
||||
# @see https://github.com/calcom/cal.com/#obtaining-hubspot-client-id-and-secret
|
||||
HUBSPOT_CLIENT_ID=""
|
||||
HUBSPOT_CLIENT_SECRET=""
|
||||
|
||||
# - 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 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=
|
||||
|
||||
# - GIPHY
|
||||
# Used for the Giphy integration
|
||||
# @see https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key
|
||||
GIPHY_API_KEY=
|
||||
# *********************************************************************************************************
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
- public/static/locales/**
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
name: Testing ${{ matrix.node }} and ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,6 +39,7 @@ yarn-error.log*
|
||||
.env.production.local
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.appStore.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
29
README.md
29
README.md
@@ -33,7 +33,7 @@
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
<a href="https://twitter.com/calcom"><img src="https://img.shields.io/twitter/follow/calcom?style=social"></a>
|
||||
<a href="https://calendso.slack.com/archives/C02BY67GMMW"><img src="https://img.shields.io/badge/translations-contribute-brightgreen" /></a>
|
||||
|
||||
<a href="https://www.contributor-covenant.org/version/1/4/code-of-conduct/ "><img src="https://img.shields.io/badge/Contributor%20Covenant-1.4-purple" /></a>
|
||||
</p>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
@@ -107,7 +107,7 @@ Here is what you need to be able to run Cal.
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
|
||||
1. Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the .env file.
|
||||
|
||||
#### Quick start with `yarn dx`
|
||||
@@ -190,8 +190,10 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
||||
|
||||
### E2E-Testing
|
||||
|
||||
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
||||
|
||||
```sh
|
||||
# In a terminal. Just run:
|
||||
# In a terminal just run:
|
||||
yarn test-e2e
|
||||
|
||||
# To open last HTML report run:
|
||||
@@ -230,9 +232,9 @@ yarn workspace @calcom/web playwright-report
|
||||
|
||||
1. Check for `.env` variables changes
|
||||
|
||||
```sh
|
||||
yarn predev
|
||||
```
|
||||
```sh
|
||||
yarn predev
|
||||
```
|
||||
|
||||
1. Start the server. In a development environment, just do:
|
||||
|
||||
@@ -401,6 +403,17 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
### Obtaining Vital API Keys
|
||||
|
||||
1. Open [Vital](https://tryvital.io/) and click Get API Keys.
|
||||
1. Create a team with the team name you desire
|
||||
1. Head to the configuration section on the sidebar of the dashboard
|
||||
1. Click on API keys and you'll find your sandbox `api_key`.
|
||||
1. Copy your `api_key` to `VITAL_API_KEY` in the .env.appStore file.
|
||||
1. Open [Vital Webhooks](https://app.tryvital.io/team/{team_id}/webhooks) and add `<CALCOM BASE URL>/api/integrations/vital/webhook` as webhook for connected applications.
|
||||
1. Select all events for the webhook you interested, e.g. `sleep_created`
|
||||
1. Copy the webhook secret (`sec...`) to `VITAL_WEBHOOK_SECRET` in the .env.appStore file.
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
@@ -420,7 +433,7 @@ Special thanks to these amazing projects which help power Cal.com:
|
||||
- [Day.js](https://day.js.org/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Prisma](https://prisma.io/)
|
||||
|
||||
[<img src="https://jitsu.com/img/powered-by-jitsu.png?gh=true">](https://jitsu.com/?utm_source=cal.com-gihub)
|
||||
|
||||
<a href="https://jitsu.com/?utm_source=cal.com-gihub"><img height="40px" src="https://jitsu.com/img/powered-by-jitsu.png?gh=true" alt="Jitsu.com"></a>
|
||||
|
||||
Cal.com is an [open startup](https://jitsu.com) and [Jitsu](https://github.com/jitsucom/jitsu) (an open-source Segment alternative) helps us to track most of the usage metrics.
|
||||
|
||||
Submodule apps/admin updated: cf71a8b47e...943cd10de1
2
apps/api
2
apps/api
Submodule apps/api updated: 6124577bc2...be2d4338ee
21
apps/docs/components/Anchor.tsx
Normal file
21
apps/docs/components/Anchor.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
function getAnchor(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9 ]/g, "")
|
||||
.replace(/[ ]/g, "-")
|
||||
.replace(/ /g, "%20");
|
||||
}
|
||||
|
||||
export default function Anchor({ as, children }) {
|
||||
const anchor = getAnchor(children);
|
||||
const link = `#${anchor}`;
|
||||
const Component = as || "div";
|
||||
return (
|
||||
<Component id={anchor}>
|
||||
<a href={link} className="anchor-link">
|
||||
§
|
||||
</a>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
33
apps/docs/lib/useWindowSize.ts
Normal file
33
apps/docs/lib/useWindowSize.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Define general type for useWindowSize hook, which includes width and height
|
||||
export interface Size {
|
||||
width: number | undefined;
|
||||
height: number | undefined;
|
||||
}
|
||||
// Hook from: https://usehooks.com/useWindowSize/
|
||||
export function useWindowSize(): Size {
|
||||
// Initialize state with undefined width/height so server and client renders match
|
||||
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
|
||||
const [windowSize, setWindowSize] = useState<Size>({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
useEffect(() => {
|
||||
// Handler to call on window resize
|
||||
function handleResize() {
|
||||
// Set window width/height to state
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
// Add event listener
|
||||
window.addEventListener("resize", handleResize);
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
handleResize();
|
||||
// Remove event listener on cleanup
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []); // Empty array ensures that effect is only run on mount
|
||||
return windowSize;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"author": "Cal.com, Inc.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iframe-resizer-react": "^1.1.0",
|
||||
"next": "^12.1.0",
|
||||
"nextra": "^1.1.0",
|
||||
"nextra-theme-docs": "^1.2.2",
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
title: Embed
|
||||
---
|
||||
|
||||
import Anchor from "../../components/Anchor"
|
||||
|
||||
# Embed
|
||||
|
||||
The Embed allows your website visitors to book a meeting with you directly from your website.
|
||||
|
||||
## Install on any website
|
||||
|
||||
- _Step-1._ Install the Vanilla JS Snippet
|
||||
Install the following Vanilla JS Snippet to get embed to work on any website. After that you can <a href="#popular-ways-in-which-you-can-embed-on-your-website">choose any of the ways</a> to show your Cal Link embedded on your website.
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function (C, A, L) {
|
||||
@@ -57,7 +60,7 @@ yarn add @calcom/embed-react
|
||||
|
||||
You can use Vanilla JS Snippet to install
|
||||
|
||||
## Popular ways in which you can embed on your website
|
||||
<Anchor as="H2">Popular ways in which you can embed on your website</Anchor>
|
||||
|
||||
Assuming that you have followed the steps of installing and initializing the snippet, you can show the embed in following ways:
|
||||
|
||||
@@ -82,8 +85,15 @@ Show the embed inline inside a container element. It would take the width and he
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
*Sample sandbox*
|
||||
```
|
||||
<iframe src="https://codesandbox.io/embed/vanilla-js-inline-embed-r27n67?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
</details>
|
||||
|
||||
####
|
||||
@@ -108,6 +118,14 @@ const MyComponent = () => (
|
||||
);
|
||||
```
|
||||
|
||||
*Sample sandbox*
|
||||
|
||||
<iframe src="https://codesandbox.io/embed/cal-component-embed-inline-demo-react-typescript-d1zlcn?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
</details>
|
||||
|
||||
### Popup on any existing element
|
||||
@@ -120,9 +138,16 @@ To show the embed as a popup on clicking an element, add `data-cal-link` attribu
|
||||
|
||||
To show the embed as a popup on clicking an element, simply add `data-cal-link` attribute to the element.
|
||||
|
||||
*Sample sandbox*
|
||||
<iframe src="https://codesandbox.io/embed/popup-on-click-of-an-existing-element-y9lcuo?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
|
||||
<button data-cal-link="jane" data-cal-config="A valid config JSON"></button>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>React</summary>
|
||||
```jsx
|
||||
@@ -131,11 +156,37 @@ To show the embed as a popup on clicking an element, simply add `data-cal-link`
|
||||
const MyComponent = ()=> {
|
||||
return <button data-cal-link="jane" data-cal-config='A valid config JSON'></button>
|
||||
}
|
||||
```
|
||||
|
||||
````
|
||||
*Sample sandbox*
|
||||
<iframe src="https://codesandbox.io/embed/embed-popup-on-click-of-an-existing-element-demo-react-sc967e?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
|
||||
</details>
|
||||
|
||||
### Floating pop-up button
|
||||
|
||||
```html
|
||||
<script>
|
||||
Cal("floatingButton", {
|
||||
// The link that you want to embed. It would open https://cal.com/jane in embed
|
||||
calLink: "jane",
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
*Sample sandbox*
|
||||
<iframe src="https://codesandbox.io/embed/embed-floating-button-popup-all-websites-cg7pru?fontsize=14&hidenavigation=1&theme=dark"
|
||||
style={{width:"100%", height:"500px", border:0, borderRadius: "4px", overflow:"hidden"}}
|
||||
title="Cal Component - Embed Inline Demo[React][TypeScript]"
|
||||
allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking"
|
||||
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
></iframe>
|
||||
|
||||
## Supported Instructions
|
||||
|
||||
Consider an instruction as a function with that name and that would be called with the given arguments.
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import Bleed from 'nextra-theme-docs/bleed'
|
||||
import Head from "next/head";
|
||||
import IframeResizer from "iframe-resizer-react";
|
||||
import {useWindowSize} from "../lib/useWindowSize";
|
||||
|
||||
|
||||
<Bleed full>
|
||||
<Head><title>Public API | Cal.com</title></Head>
|
||||
<iframe src="https://developer.cal.com"
|
||||
width="100%"
|
||||
height="900px"
|
||||
title="Public API | Cal.com"
|
||||
></iframe>
|
||||
<Head><title>Public API | Cal.com</title></Head>
|
||||
<IframeResizer
|
||||
autoResize
|
||||
src={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://developer.cal.com"}
|
||||
frameBorder="0"
|
||||
style={{
|
||||
width: useWindowSize().width > 768 ? "calc(100vw - 16rem)": "100vw",
|
||||
minHeight: useWindowSize().width > 768 ? "100vh" : "200vh",
|
||||
height: "auto",
|
||||
border: 0,
|
||||
}}
|
||||
/>
|
||||
</Bleed>
|
||||
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/docs
|
||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs
|
||||
@@ -20,7 +20,7 @@ export const requestSnippets = {
|
||||
},
|
||||
},
|
||||
defaultExpanded: true,
|
||||
languages: ["node"],
|
||||
languages: ["node", "curl_bash"],
|
||||
};
|
||||
// Since swagger-ui-react was not configured to change the request snippets some workarounds required
|
||||
// configuration will be added programatically
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "PORT=4200 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "PORT=4200 next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.5.1",
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { SwaggerUI } from "swagger-ui-react";
|
||||
|
||||
import { SnippedGenerator, requestSnippets } from "@lib/snippets";
|
||||
|
||||
const SwaggerUI: any = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||
const SwaggerUIDynamic: SwaggerUI & { url: string } = dynamic(() => import("swagger-ui-react"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function APIDocs() {
|
||||
return (
|
||||
<SwaggerUI
|
||||
<SwaggerUIDynamic
|
||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/docs"}
|
||||
supportedSubmitMethods={["get", "post", "delete", "patch"]}
|
||||
persistAuthorization={true}
|
||||
supportedSubmitMethods={["get", "post", "delete", "put", "options", "patch"]}
|
||||
requestSnippetsEnabled={true}
|
||||
requestSnippets={requestSnippets}
|
||||
plugins={[SnippedGenerator]}
|
||||
tryItOutEnabled={true}
|
||||
syntaxHighlight={true}
|
||||
docExpansion="none"
|
||||
enableCORS={false} // Doesn't seem to work either
|
||||
docExpansion="list"
|
||||
filter={true}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,3 +14,89 @@ a {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.swagger-ui .opblock-tag {
|
||||
font-size: 90% !important;
|
||||
}
|
||||
.swagger-ui .opblock .opblock-summary {
|
||||
display: grid;
|
||||
flex-direction: column;
|
||||
}
|
||||
.opblock-summary-path {
|
||||
flex-shrink: 0;
|
||||
max-width: 100% !important;
|
||||
padding: 10px 5px !important;
|
||||
}
|
||||
.opblock-summary-description {
|
||||
font-size: 16px !important;
|
||||
padding: 0px 5px;
|
||||
}
|
||||
.swagger-ui .scheme-container .schemes {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.swagger-ui .info .title {
|
||||
color: #3b4151;
|
||||
font-family: sans-serif;
|
||||
font-size: 22px;
|
||||
}
|
||||
.swagger-ui .scheme-container {
|
||||
padding: 14px 0;
|
||||
}
|
||||
.swagger-ui .info {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.swagger-ui .auth-wrapper {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.swagger-ui .authorization__btn {
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui .opblock {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
button.opblock-summary-control > svg {
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui .filter .operation-filter-input {
|
||||
border: 2px solid #d8dde7;
|
||||
margin: 5px 5px;
|
||||
padding: 5px;
|
||||
width: 100vw;
|
||||
}
|
||||
.swagger-ui .wrapper {
|
||||
padding: 0 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.swagger-ui .info .title small {
|
||||
top: 5px;
|
||||
}
|
||||
.swagger-ui a.nostyle, .swagger-ui a.nostyle:visited {
|
||||
width: 100%;
|
||||
}
|
||||
div.request-snippets > div.curl-command > div:nth-child(1) {
|
||||
overscroll-behavior: contain;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.swagger-ui .opblock-body pre.microlight {
|
||||
font-size: 9px;
|
||||
}
|
||||
.swagger-ui table tbody tr td {
|
||||
padding: 0px 0 0;
|
||||
vertical-align: none;
|
||||
}
|
||||
td.response-col_description > div > div > p {
|
||||
font-size: 12px;
|
||||
}
|
||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
flex-direction: column;
|
||||
font-size: 60%;
|
||||
}
|
||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > thead > tr {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
3
apps/web/.gitignore
vendored
3
apps/web/.gitignore
vendored
@@ -61,3 +61,6 @@ yarn-error.log*
|
||||
|
||||
# Typescript
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Autogenerated embed content
|
||||
public/embed
|
||||
|
||||
@@ -11,6 +11,10 @@ export default function BookingsShell({ children }: { children: React.ReactNode
|
||||
name: t("upcoming"),
|
||||
href: "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: t("recurring"),
|
||||
href: "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: t("past"),
|
||||
href: "/bookings/past",
|
||||
|
||||
900
apps/web/components/Embed.tsx
Normal file
900
apps/web/components/Embed.tsx
Normal file
@@ -0,0 +1,900 @@
|
||||
import { CodeIcon, EyeIcon, SunIcon, ChevronRightIcon, ArrowLeftIcon } from "@heroicons/react/solid";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRef, useState } from "react";
|
||||
import { components, ControlProps, SingleValue } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
import { Button, Switch } from "@calcom/ui";
|
||||
import { Dialog, DialogContent, DialogClose } from "@calcom/ui/Dialog";
|
||||
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { WEBAPP_URL, EMBED_LIB_URL } from "@lib/config/constants";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import NavTabs from "@components/NavTabs";
|
||||
import ColorPicker from "@components/ui/colorpicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type EmbedType = "inline" | "floating-popup" | "element-click";
|
||||
const queryParamsForDialog = ["embedType", "tabName", "eventTypeId"];
|
||||
|
||||
const embeds: {
|
||||
illustration: React.ReactElement;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
type: EmbedType;
|
||||
}[] = [
|
||||
{
|
||||
title: "Inline Embed",
|
||||
subtitle: "Loads your Cal scheduling page directly inline with your other website content",
|
||||
type: "inline",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="48" y="74.5" width="80" height="8" rx="2" fill="#E1E1E1" />
|
||||
<rect x="48" y="86.5" width="48" height="4" rx="1" fill="#E1E1E1" />
|
||||
<rect x="49" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="99.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="73" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="99.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="113.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="125.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M61 124.5H67V122.5H61V124.5ZM68 125.5V131.5H70V125.5H68ZM67 132.5H61V134.5H67V132.5ZM60 131.5V125.5H58V131.5H60ZM61 132.5C60.4477 132.5 60 132.052 60 131.5H58C58 133.157 59.3431 134.5 61 134.5V132.5ZM68 131.5C68 132.052 67.5523 132.5 67 132.5V134.5C68.6569 134.5 70 133.157 70 131.5H68ZM67 124.5C67.5523 124.5 68 124.948 68 125.5H70C70 123.843 68.6569 122.5 67 122.5V124.5ZM61 122.5C59.3431 122.5 58 123.843 58 125.5H60C60 124.948 60.4477 124.5 61 124.5V122.5Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="73" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="125.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="137.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="137.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="149.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="121" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="149.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="49" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="61" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="73" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="85" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="97" y="161.5" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="109" y="161.5" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="24.5" y="51" width="139" height="163" rx="1.5" stroke="#292929" />
|
||||
<rect x="176" y="50.5" width="108" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Floating pop-up button",
|
||||
subtitle: "Adds a floating button on your site that launches Cal in a dialog.",
|
||||
type: "floating-popup",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="226" y="223.5" width="66" height="26" rx="2" fill="#292929" />
|
||||
<rect x="242" y="235.5" width="34" height="2" rx="1" fill="white" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Pop up via element click",
|
||||
subtitle: "Open your Cal dialog when someone clicks an element.",
|
||||
type: "element-click",
|
||||
illustration: (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-md"
|
||||
viewBox="0 0 308 265"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||
fill="white"
|
||||
/>
|
||||
<rect x="24" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="50.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="138.5" width="120" height="76" rx="2" fill="#E1E1E1" />
|
||||
<rect x="156" y="50.5" width="128" height="164" rx="2" fill="#E1E1E1" />
|
||||
<rect x="24" y="226.5" width="260" height="38.5" rx="2" fill="#E1E1E1" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" fill="#F8F8F8" />
|
||||
<rect opacity="0.8" x="108" y="85" width="80" height="8" rx="2" fill="#E1E1E1" />
|
||||
<rect x="108" y="97" width="48" height="4" rx="1" fill="#E1E1E1" />
|
||||
<rect x="109" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="110" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="133" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="110" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="124" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="136" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<path
|
||||
d="M121 135H127V133H121V135ZM128 136V142H130V136H128ZM127 143H121V145H127V143ZM120 142V136H118V142H120ZM121 143C120.448 143 120 142.552 120 142H118C118 143.657 119.343 145 121 145V143ZM128 142C128 142.552 127.552 143 127 143V145C128.657 145 130 143.657 130 142H128ZM127 135C127.552 135 128 135.448 128 136H130C130 134.343 128.657 133 127 133V135ZM121 133C119.343 133 118 134.343 118 136H120C120 135.448 120.448 135 121 135V133Z"
|
||||
fill="#3E3E3E"
|
||||
/>
|
||||
<rect x="133" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="169" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="181" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="136" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="148" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="181" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="148" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="160" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="181" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="193" y="160" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="109" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="121" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="133" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="145" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="157" y="172" width="6" height="6" rx="1" fill="#3E3E3E" />
|
||||
<rect x="169" y="172" width="6" height="6" rx="1" fill="#C6C6C6" />
|
||||
<rect x="84.5" y="61.5" width="139" height="141" rx="1.5" stroke="#292929" />
|
||||
{/* <path
|
||||
d="M2 1H306V-1H2V1ZM307 2V263H309V2H307ZM306 264H2V266H306V264ZM1 263V1.99999H-1V263H1ZM2 264C1.44772 264 1 263.552 1 263H-1C-1 264.657 0.343147 266 2 266V264ZM307 263C307 263.552 306.552 264 306 264V266C307.657 266 309 264.657 309 263H307ZM306 1C306.552 1 307 1.44772 307 2H309C309 0.343145 307.657 -1 306 -1V1ZM2 -1C0.343151 -1 -1 0.343133 -1 1.99999H1C1 1.44771 1.44771 1 2 1V-1Z"
|
||||
fill="#CFCFCF"
|
||||
/> */}
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function getEmbedSnippetString() {
|
||||
// TODO: Import this string from @calcom/embed-snippet
|
||||
return `
|
||||
(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, "${EMBED_LIB_URL}", "init");
|
||||
Cal("init", {origin:"${WEBAPP_URL}"});
|
||||
`;
|
||||
}
|
||||
|
||||
const EmbedNavBar = () => {
|
||||
const { t } = useLocale();
|
||||
const tabs = [
|
||||
{
|
||||
name: t("Embed"),
|
||||
tabName: "embed-code",
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Preview"),
|
||||
tabName: "embed-preview",
|
||||
icon: EyeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return <NavTabs data-testid="embed-tabs" tabs={tabs} linkProps={{ shallow: true }} />;
|
||||
};
|
||||
const ThemeSelectControl = ({ children, ...props }: ControlProps<any, false>) => {
|
||||
return (
|
||||
<components.Control {...props}>
|
||||
<SunIcon className="h-[32px] w-[32px] text-gray-500" />
|
||||
{children}
|
||||
</components.Control>
|
||||
);
|
||||
};
|
||||
|
||||
const ChooseEmbedTypesDialogContent = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DialogContent size="lg">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("how_you_want_add_cal_site")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("choose_ways_put_cal_site")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{embeds.map((embed, index) => (
|
||||
<button
|
||||
className="mr-2 w-1/3 p-3 text-left hover:rounded-md hover:border hover:bg-neutral-100"
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
embedType: embed.type,
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
||||
{embed.illustration}
|
||||
</div>
|
||||
<div className="mt-2 font-medium text-neutral-900">{embed.title}</div>
|
||||
<p className="text-sm text-gray-500">{embed.subtitle}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
|
||||
const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
eventTypeId,
|
||||
embedType,
|
||||
}: {
|
||||
eventTypeId: EventType["id"];
|
||||
embedType: EmbedType;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const embedCode = useRef<HTMLTextAreaElement>(null);
|
||||
const embed = embeds.find((embed) => embed.type === embedType);
|
||||
|
||||
const { data: eventType, isLoading } = trpc.useQuery([
|
||||
"viewer.eventTypes.get",
|
||||
{
|
||||
id: +eventTypeId,
|
||||
},
|
||||
]);
|
||||
|
||||
const [isEmbedCustomizationOpen, setIsEmbedCustomizationOpen] = useState(true);
|
||||
const [isBookingCustomizationOpen, setIsBookingCustomizationOpen] = useState(true);
|
||||
const [previewState, setPreviewState] = useState({
|
||||
inline: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
theme: "auto",
|
||||
floatingPopup: {},
|
||||
elementClick: {},
|
||||
palette: {
|
||||
brandColor: "#000000",
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
const noPopupQuery = {
|
||||
...router.query,
|
||||
};
|
||||
|
||||
delete noPopupQuery.dialog;
|
||||
|
||||
queryParamsForDialog.forEach((queryParam) => {
|
||||
delete noPopupQuery[queryParam];
|
||||
});
|
||||
|
||||
router.push({
|
||||
query: noPopupQuery,
|
||||
});
|
||||
};
|
||||
|
||||
// Use embed-code as default tab
|
||||
if (!router.query.tabName) {
|
||||
router.query.tabName = "embed-code";
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!embed || !eventType) {
|
||||
close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const calLink = `${eventType.team ? `team/${eventType.team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
}`;
|
||||
|
||||
// TODO: Not sure how to make these template strings look better formatted.
|
||||
// This exact formatting is required to make the code look nicely formatted together.
|
||||
const getEmbedUIInstructionString = () =>
|
||||
`Cal("ui", {
|
||||
${getThemeForSnippet() ? 'theme: "' + previewState.theme + '",\n ' : ""}styles: {
|
||||
branding: ${JSON.stringify(previewState.palette)}
|
||||
}
|
||||
})`;
|
||||
|
||||
const getEmbedTypeSpecificString = () => {
|
||||
if (embedType === "inline") {
|
||||
return `
|
||||
Cal("inline", {
|
||||
elementOrSelector:"#my-cal-inline",
|
||||
calLink: "${calLink}"
|
||||
});
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
} else if (embedType === "floating-popup") {
|
||||
let floatingButtonArg = {
|
||||
calLink,
|
||||
...previewState.floatingPopup,
|
||||
};
|
||||
return `
|
||||
Cal("floatingButton", ${JSON.stringify(floatingButtonArg)});
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
} else if (embedType === "element-click") {
|
||||
return `//Important: Also, add data-cal-link="${calLink}" attribute to the element you want to open Cal on click
|
||||
${getEmbedUIInstructionString().trim()}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const getThemeForSnippet = () => {
|
||||
return previewState.theme !== "auto" ? previewState.theme : null;
|
||||
};
|
||||
|
||||
const getDimension = (dimension: string) => {
|
||||
if (dimension.match(/^\d+$/)) {
|
||||
dimension = `${dimension}%`;
|
||||
}
|
||||
return dimension;
|
||||
};
|
||||
|
||||
const addToPalette = (update: typeof previewState["palette"]) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
palette: {
|
||||
...previewState.palette,
|
||||
...update,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const previewInstruction = (instruction: { name: string; arg: any }) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
mode: "cal:preview",
|
||||
type: "instruction",
|
||||
instruction,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
};
|
||||
|
||||
const inlineEmbedDimensionUpdate = ({ width, height }: { width: string; height: string }) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
mode: "cal:preview",
|
||||
type: "inlineEmbedDimensionUpdate",
|
||||
data: {
|
||||
width: getDimension(width),
|
||||
height: getDimension(height),
|
||||
},
|
||||
},
|
||||
"*"
|
||||
);
|
||||
};
|
||||
|
||||
previewInstruction({
|
||||
name: "ui",
|
||||
arg: {
|
||||
theme: previewState.theme,
|
||||
styles: {
|
||||
branding: {
|
||||
...previewState.palette,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (embedType === "floating-popup") {
|
||||
previewInstruction({
|
||||
name: "floatingButton",
|
||||
arg: {
|
||||
attributes: {
|
||||
id: "my-floating-button",
|
||||
},
|
||||
...previewState.floatingPopup,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (embedType === "inline") {
|
||||
inlineEmbedDimensionUpdate({
|
||||
width: previewState.inline.width,
|
||||
height: previewState.inline.height,
|
||||
});
|
||||
}
|
||||
|
||||
const ThemeOptions = [
|
||||
{ value: "auto", label: "Auto Theme" },
|
||||
{ value: "dark", label: "Dark Theme" },
|
||||
{ value: "light", label: "Light Theme" },
|
||||
];
|
||||
|
||||
const FloatingPopupPositionOptions = [
|
||||
{
|
||||
value: "bottom-right",
|
||||
label: "Bottom Right",
|
||||
},
|
||||
{
|
||||
value: "bottom-left",
|
||||
label: "Bottom Left",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DialogContent size="xl">
|
||||
<div className="flex">
|
||||
<div className="flex w-1/3 flex-col bg-white p-6">
|
||||
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newQuery = { ...router.query };
|
||||
delete newQuery.embedType;
|
||||
delete newQuery.tabName;
|
||||
router.push({
|
||||
query: {
|
||||
...newQuery,
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<ArrowLeftIcon className="mr-4 w-4"></ArrowLeftIcon>
|
||||
</button>
|
||||
{embed.title}
|
||||
</h3>
|
||||
<hr className={classNames("mt-4", embedType === "element-click" ? "hidden" : "")}></hr>
|
||||
<div className={classNames("mt-4 font-medium", embedType === "element-click" ? "hidden" : "")}>
|
||||
<Collapsible
|
||||
open={isEmbedCustomizationOpen}
|
||||
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full items-center text-base font-medium text-neutral-900">
|
||||
<div>
|
||||
{embedType === "inline"
|
||||
? "Inline Embed Customization"
|
||||
: embedType === "floating-popup"
|
||||
? "Floating Popup Customization"
|
||||
: "Element Click Customization"}
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={`${
|
||||
isEmbedCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="text-sm">
|
||||
<div className={classNames("mt-6", embedType === "inline" ? "block" : "hidden")}>
|
||||
{/*TODO: Add Auto/Fixed toggle from Figma */}
|
||||
<div className="text-sm">Embed Window Sizing</div>
|
||||
<div className="justify-left flex items-center">
|
||||
<TextField
|
||||
name="width"
|
||||
labelProps={{ className: "hidden" }}
|
||||
required
|
||||
value={previewState.inline.width}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
let width = e.target.value || "100%";
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
width,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>W</InputLeading>}
|
||||
/>
|
||||
<span className="p-2">x</span>
|
||||
<TextField
|
||||
labelProps={{ className: "hidden" }}
|
||||
name="height"
|
||||
value={previewState.inline.height}
|
||||
required
|
||||
onChange={(e) => {
|
||||
const height = e.target.value || "100%";
|
||||
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
inline: {
|
||||
...previewState.inline,
|
||||
height,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
addOnLeading={<InputLeading>H</InputLeading>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div className="text-sm">Button Text</div>
|
||||
{/* Default Values should come from preview iframe */}
|
||||
<TextField
|
||||
name="buttonText"
|
||||
labelProps={{ className: "hidden" }}
|
||||
onChange={(e) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonText: e.target.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue="Book my Cal"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div className="text-sm">Display Calendar Icon Button</div>
|
||||
<Switch
|
||||
defaultChecked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
hideButtonIcon: !checked,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></Switch>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Position of Button</div>
|
||||
<Select
|
||||
onChange={(position) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonPosition: position?.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
defaultValue={FloatingPopupPositionOptions[0]}
|
||||
options={FloatingPopupPositionOptions}></Select>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Button Color</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 flex items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Text Color</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
floatingPopup: {
|
||||
...previewState.floatingPopup,
|
||||
buttonTextColor: color,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div
|
||||
className={classNames(
|
||||
"mt-4 items-center justify-between",
|
||||
embedType === "floating-popup" ? "flex" : "hidden"
|
||||
)}>
|
||||
<div>Button Color on Hover</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
addToPalette({
|
||||
"floating-popup-button-color-hover": color,
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</div> */}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<hr className="mt-4"></hr>
|
||||
<div className="mt-4 font-medium">
|
||||
<Collapsible
|
||||
open={isBookingCustomizationOpen}
|
||||
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
|
||||
<CollapsibleTrigger className="flex w-full" type="button">
|
||||
<div className="text-base font-medium text-neutral-900">Cal Booking Customization</div>
|
||||
<ChevronRightIcon
|
||||
className={`${
|
||||
isBookingCustomizationOpen ? "rotate-90 transform" : ""
|
||||
} ml-auto h-5 w-5 text-neutral-500`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-6 text-sm">
|
||||
<Label className="flex items-center justify-between">
|
||||
<div>Theme</div>
|
||||
<Select
|
||||
className="w-36"
|
||||
defaultValue={ThemeOptions[0]}
|
||||
components={{
|
||||
Control: ThemeSelectControl,
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
setPreviewState((previewState) => {
|
||||
return {
|
||||
...previewState,
|
||||
theme: option.value,
|
||||
};
|
||||
});
|
||||
}}
|
||||
options={ThemeOptions}></Select>
|
||||
</Label>
|
||||
{[
|
||||
{ name: "brandColor", title: "Brand Color" },
|
||||
// { name: "lightColor", title: "Light Color" },
|
||||
// { name: "lighterColor", title: "Lighter Color" },
|
||||
// { name: "lightestColor", title: "Lightest Color" },
|
||||
// { name: "highlightColor", title: "Highlight Color" },
|
||||
// { name: "medianColor", title: "Median Color" },
|
||||
].map((palette) => (
|
||||
<Label key={palette.name} className="flex items-center justify-between">
|
||||
<div>{palette.title}</div>
|
||||
<div className="w-36">
|
||||
<ColorPicker
|
||||
defaultValue="#000000"
|
||||
onChange={(color) => {
|
||||
//@ts-ignore - How to support dynamic palette names?
|
||||
addToPalette({
|
||||
[palette.name]: color,
|
||||
});
|
||||
}}></ColorPicker>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2/3 bg-gray-50 p-6">
|
||||
<EmbedNavBar />
|
||||
<div>
|
||||
<div
|
||||
className={classNames(router.query.tabName === "embed-code" ? "block" : "hidden", "h-[75vh]")}>
|
||||
<small className="flex py-4 text-neutral-500">{t("place_where_cal_widget_appear")}</small>
|
||||
<TextArea
|
||||
data-testid="embed-code"
|
||||
ref={embedCode}
|
||||
name="embed-code"
|
||||
className="h-[36rem]"
|
||||
readOnly
|
||||
value={
|
||||
`<!-- Cal ${embedType} embed code begins -->\n` +
|
||||
(embedType === "inline"
|
||||
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
|
||||
previewState.inline.height
|
||||
)};overflow:scroll" id="my-cal-inline"></div>\n`
|
||||
: "") +
|
||||
`<script type="text/javascript">
|
||||
${getEmbedSnippetString().trim()}
|
||||
${getEmbedTypeSpecificString().trim()}
|
||||
</script>
|
||||
<!-- Cal ${embedType} embed code ends -->`
|
||||
}></TextArea>
|
||||
<p className="hidden text-sm text-gray-500">
|
||||
{t(
|
||||
"Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={router.query.tabName == "embed-preview" ? "block" : "hidden"}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
data-testid="embed-preview"
|
||||
className="border-1 h-[75vh] border"
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`${WEBAPP_URL}/embed/preview.html?embedType=${embedType}&calLink=${calLink}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
if (!embedCode.current) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(embedCode.current.value);
|
||||
showToast(t("code_copied"), "success");
|
||||
}}>
|
||||
{t("copy_code")}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("Close")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmbedDialog = () => {
|
||||
const router = useRouter();
|
||||
const eventTypeId: EventType["id"] = +(router.query.eventTypeId as string);
|
||||
return (
|
||||
<Dialog name="embed" clearQueryParamsOnClose={queryParamsForDialog}>
|
||||
{!router.query.embedType ? (
|
||||
<ChooseEmbedTypesDialogContent />
|
||||
) : (
|
||||
<EmbedTypeCodeAndPreviewDialogContent
|
||||
eventTypeId={eventTypeId}
|
||||
embedType={router.query.embedType as EmbedType}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmbedButton = ({
|
||||
eventTypeId,
|
||||
className = "",
|
||||
dark,
|
||||
...props
|
||||
}: {
|
||||
eventTypeId: EventType["id"];
|
||||
className: string;
|
||||
dark?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
className = classNames(className, "hidden lg:flex");
|
||||
const openEmbedModal = () => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "embed",
|
||||
eventTypeId,
|
||||
};
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className={className}
|
||||
{...props}
|
||||
data-test-eventtype-id={eventTypeId}
|
||||
data-testid={"event-type-embed"}
|
||||
onClick={() => openEmbedModal()}>
|
||||
<CodeIcon
|
||||
className={classNames("h-4 w-4 ltr:mr-2 rtl:ml-2", dark ? "" : "text-neutral-500")}></CodeIcon>
|
||||
{t("Embed")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +1,62 @@
|
||||
import { AdminRequired } from "components/ui/AdminRequired";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ElementType, FC, Fragment } from "react";
|
||||
import React, { ElementType, FC, Fragment, MouseEventHandler } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
export interface NavTabProps {
|
||||
tabs: {
|
||||
name: string;
|
||||
href: string;
|
||||
/** If you want to change the path as per current tab */
|
||||
href?: string;
|
||||
/** If you want to change query param tabName as per current tab */
|
||||
tabName?: string;
|
||||
icon?: ElementType;
|
||||
adminRequired?: boolean;
|
||||
}[];
|
||||
linkProps?: Omit<LinkProps, "href">;
|
||||
}
|
||||
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps }) => {
|
||||
const NavTabs: FC<NavTabProps> = ({ tabs, linkProps, ...props }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<nav className="-mb-px flex space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse" aria-label="Tabs">
|
||||
<nav
|
||||
className="-mb-px flex space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse"
|
||||
aria-label="Tabs"
|
||||
{...props}>
|
||||
{tabs.map((tab) => {
|
||||
const isCurrent = router.asPath === tab.href;
|
||||
let href: string;
|
||||
let isCurrent;
|
||||
if ((tab.tabName && tab.href) || (!tab.tabName && !tab.href)) {
|
||||
throw new Error("Use either tabName or href");
|
||||
}
|
||||
if (tab.href) {
|
||||
href = tab.href;
|
||||
isCurrent = router.asPath === tab.href;
|
||||
} else if (tab.tabName) {
|
||||
href = "";
|
||||
isCurrent = router.query.tabName === tab.tabName;
|
||||
}
|
||||
const onClick: MouseEventHandler = tab.tabName
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
tabName: tab.tabName,
|
||||
},
|
||||
});
|
||||
}
|
||||
: () => {};
|
||||
|
||||
const Component = tab.adminRequired ? AdminRequired : Fragment;
|
||||
return (
|
||||
<Component key={tab.name}>
|
||||
<Link href={tab.href} {...linkProps}>
|
||||
<Link key={tab.name} href={href!} {...linkProps}>
|
||||
<a
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
isCurrent
|
||||
? "border-neutral-900 text-neutral-900"
|
||||
|
||||
@@ -34,6 +34,7 @@ import HelpMenuItem from "@ee/components/support/HelpMenuItem";
|
||||
import classNames from "@lib/classNames";
|
||||
import { WEBAPP_URL } from "@lib/config/constants";
|
||||
import { shouldShowOnboarding } from "@lib/getting-started";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
@@ -46,16 +47,6 @@ import pkg from "../package.json";
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
import Logo from "./Logo";
|
||||
|
||||
export function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
function useRedirectToLoginIfUnauthenticated(isPublic = false) {
|
||||
const { data: session, status } = useSession();
|
||||
const loading = status === "loading";
|
||||
|
||||
@@ -14,9 +14,9 @@ import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/D
|
||||
|
||||
import { defaultDayRange } from "@lib/availability";
|
||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { TimeRange } from "@lib/types/schedule";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { Dayjs } from "dayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
@@ -8,6 +8,7 @@ import React, { FC, useEffect, useState } from "react";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useSlots } from "@lib/hooks/useSlots";
|
||||
|
||||
@@ -20,6 +21,7 @@ type AvailableTimesProps = {
|
||||
afterBufferTime: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
recurringCount: number | undefined;
|
||||
eventTypeSlug: string;
|
||||
slotInterval: number | null;
|
||||
date: Dayjs;
|
||||
@@ -36,6 +38,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
eventTypeSlug,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
recurringCount,
|
||||
timeFormat,
|
||||
users,
|
||||
schedulingType,
|
||||
@@ -89,6 +92,8 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
date: slot.time.format(),
|
||||
type: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
/** Treat as recurring only when a count exist and it's not a rescheduling workflow */
|
||||
count: recurringCount && !rescheduleUid ? recurringCount : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -109,7 +114,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}
|
||||
data-testid="time">
|
||||
{slot.time.format(timeFormat)}
|
||||
{dayjs.tz(slot.time, timeZone()).format(timeFormat)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { BanIcon, CheckIcon, ClockIcon, XIcon, PencilAltIcon } from "@heroicons/react/outline";
|
||||
import { PaperAirplaneIcon } from "@heroicons/react/outline";
|
||||
import { RefreshIcon } from "@heroicons/react/solid";
|
||||
import { BookingStatus } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { TextArea } from "@calcom/ui/form/fields";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { parseRecurringDates } from "@lib/parseDate";
|
||||
import { inferQueryOutput, trpc, inferQueryInput } from "@lib/trpc";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
function BookingListItem(booking: BookingItem) {
|
||||
type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringCount?: number;
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
// Get user so we can determine 12/24 hour format preferences
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
@@ -30,14 +41,22 @@ function BookingListItem(booking: BookingItem) {
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const mutation = useMutation(
|
||||
async (confirm: boolean) => {
|
||||
let body = {
|
||||
id: booking.id,
|
||||
confirmed: confirm,
|
||||
language: i18n.language,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Upcoming" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if (booking.listingStatus === "upcoming" && booking.recurringEventId !== null) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
const res = await fetch("/api/book/confirm", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
id: booking.id,
|
||||
confirmed: confirm,
|
||||
language: i18n.language,
|
||||
reason: rejectionReason,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -58,14 +77,20 @@ function BookingListItem(booking: BookingItem) {
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: t("reject"),
|
||||
label:
|
||||
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||
? t("reject_all")
|
||||
: t("reject"),
|
||||
onClick: () => setRejectionDialogIsOpen(true),
|
||||
icon: BanIcon,
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label: t("confirm"),
|
||||
label:
|
||||
booking.listingStatus === "upcoming" && booking.recurringEventId !== null
|
||||
? t("confirm_all")
|
||||
: t("confirm"),
|
||||
onClick: () => mutation.mutate(true),
|
||||
icon: CheckIcon,
|
||||
disabled: mutation.isLoading,
|
||||
@@ -112,6 +137,19 @@ function BookingListItem(booking: BookingItem) {
|
||||
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
|
||||
// Calculate the booking date(s)
|
||||
let recurringStrings: string[] = [];
|
||||
if (booking.recurringCount && booking.eventType.recurringEvent?.freq !== null) {
|
||||
[recurringStrings] = parseRecurringDates(
|
||||
{
|
||||
startDate: booking.startTime,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringCount: booking.recurringCount,
|
||||
},
|
||||
i18n
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -154,12 +192,40 @@ function BookingListItem(booking: BookingItem) {
|
||||
</Dialog>
|
||||
|
||||
<tr className="flex">
|
||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell">
|
||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-56">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{booking.recurringCount &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
booking.listingStatus === "upcoming" && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.map((aDate, key) => (
|
||||
<p key={key}>{aDate}</p>
|
||||
))}>
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-1 -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{`${t("every_for_freq", {
|
||||
freq: t(
|
||||
`${RRuleFrequency[booking.eventType.recurringEvent.freq]
|
||||
.toString()
|
||||
.toLowerCase()}`
|
||||
),
|
||||
})} ${booking.recurringCount} ${t(
|
||||
`${RRuleFrequency[booking.eventType.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: booking.recurringCount }
|
||||
)}`}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
|
||||
<div className="sm:hidden">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreditCardIcon,
|
||||
GlobeIcon,
|
||||
InformationCircleIcon,
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useContracts } from "contexts/contractsContext";
|
||||
@@ -17,6 +18,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import {
|
||||
useEmbedStyles,
|
||||
@@ -27,11 +29,12 @@ import {
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { BASE_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
@@ -101,6 +104,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
}
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(eventType.recurringEvent?.count);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
@@ -142,6 +146,15 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
setTimeFormat(is24hClock ? "HH:mm" : "h:mma");
|
||||
};
|
||||
|
||||
// Recurring event sidebar requires more space
|
||||
const maxWidth = selectedDate
|
||||
? recurringEventCount
|
||||
? "max-w-6xl"
|
||||
: "max-w-5xl"
|
||||
: recurringEventCount
|
||||
? "max-w-4xl"
|
||||
: "max-w-3xl";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Theme />
|
||||
@@ -158,9 +171,8 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? classNames(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
: "transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24 " +
|
||||
(selectedDate ? "max-w-5xl" : "max-w-3xl")
|
||||
? classNames(maxWidth)
|
||||
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
||||
)}>
|
||||
{isReady && (
|
||||
<div
|
||||
@@ -168,7 +180,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "bg-white dark:bg-gray-800 sm:dark:border-gray-600",
|
||||
"border-bookinglightest rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : selectedDate ? "max-w-5xl" : "max-w-3xl"
|
||||
isEmbed ? "mx-auto" : maxWidth
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
@@ -243,7 +255,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
<div
|
||||
className={
|
||||
"hidden pr-8 sm:border-r sm:dark:border-gray-700 md:flex md:flex-col " +
|
||||
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||
(selectedDate ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||
}>
|
||||
<AvatarGroup
|
||||
border="border-2 dark:border-gray-800 border-white"
|
||||
@@ -267,15 +279,42 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
{eventType.title}
|
||||
</h1>
|
||||
{eventType?.description && (
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<p className="text-bookinglight mb-3 dark:text-white">
|
||||
<InformationCircleIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinglight mb-2 dark:text-white">
|
||||
<p className="text-bookinglight mb-3 dark:text-white">
|
||||
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent?.count && eventType.recurringEvent?.freq && (
|
||||
<div className="mb-3 text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{t("every_for_freq", {
|
||||
freq: t(
|
||||
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-16 rounded-sm border-gray-300 bg-white text-gray-600 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600 dark:text-white sm:text-sm"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline text-gray-600 dark:text-white">
|
||||
{t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`, {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<p className="mb-1 -ml-2 px-2 py-1 text-gray-600 dark:text-white">
|
||||
<CreditCardIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
|
||||
@@ -302,7 +341,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
{booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-2 text-gray-600 dark:text-white"
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-white"
|
||||
data-testid="former_time_p_desktop">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
@@ -340,6 +379,7 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
|
||||
eventTypeSlug={eventType.slug}
|
||||
slotInterval={eventType.slotInterval}
|
||||
eventLength={eventType.length}
|
||||
recurringCount={recurringEventCount}
|
||||
date={selectedDate}
|
||||
users={eventType.users}
|
||||
schedulingType={eventType.schedulingType ?? null}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
RefreshIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType } from "@prisma/client";
|
||||
@@ -18,20 +20,17 @@ import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
import { useMutation } from "react-query";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
useIsEmbed,
|
||||
useEmbedStyles,
|
||||
useIsBackgroundTransparent,
|
||||
useEmbedType,
|
||||
useEmbedNonStylesConfig,
|
||||
} from "@calcom/embed-core";
|
||||
import { useEmbedNonStylesConfig, useIsBackgroundTransparent, useIsEmbed } 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";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
@@ -40,7 +39,8 @@ import { ensureArray } from "@lib/ensureArray";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { LocationType } from "@lib/location";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import { parseDate } from "@lib/parseDate";
|
||||
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
|
||||
import { parseDate, parseRecurringDates } from "@lib/parseDate";
|
||||
import slugify from "@lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
|
||||
@@ -76,6 +76,7 @@ const BookingPage = ({
|
||||
booking,
|
||||
profile,
|
||||
isDynamicGroupBooking,
|
||||
recurringEventCount,
|
||||
locationLabels,
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
@@ -101,7 +102,7 @@ const BookingPage = ({
|
||||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { attendees, paymentUid } = responseData;
|
||||
const { id, attendees, paymentUid } = responseData;
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
@@ -135,6 +136,38 @@ const BookingPage = ({
|
||||
email: attendees[0].email,
|
||||
location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const recurringMutation = useMutation(createRecurringBooking, {
|
||||
onSuccess: async (responseData = []) => {
|
||||
const { attendees = [], recurringEventId } = responseData[0] || {};
|
||||
const location = (function humanReadableLocation(location) {
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (location.includes("integration")) {
|
||||
return t("web_conferencing_details_to_follow");
|
||||
}
|
||||
return location;
|
||||
})(responseData[0].location);
|
||||
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
query: {
|
||||
date,
|
||||
type: eventType.id,
|
||||
eventSlug: eventType.slug,
|
||||
recur: recurringEventId,
|
||||
user: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
location,
|
||||
eventName: profile.eventName || "",
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -243,6 +276,20 @@ const BookingPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the booking date(s)
|
||||
let recurringStrings: string[] = [],
|
||||
recurringDates: Date[] = [];
|
||||
if (eventType.recurringEvent?.freq && recurringEventCount !== null) {
|
||||
[recurringStrings, recurringDates] = parseRecurringDates(
|
||||
{
|
||||
startDate: date,
|
||||
recurringEvent: eventType.recurringEvent,
|
||||
recurringCount: parseInt(recurringEventCount.toString()),
|
||||
},
|
||||
i18n
|
||||
);
|
||||
}
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(
|
||||
@@ -265,7 +312,7 @@ const BookingPage = ({
|
||||
{}
|
||||
);
|
||||
|
||||
let web3Details;
|
||||
let web3Details: Record<"userWallet" | "userSignature", string> | undefined;
|
||||
if (eventTypeDetail.metadata.smartContractAddress) {
|
||||
web3Details = {
|
||||
// @ts-ignore
|
||||
@@ -274,28 +321,59 @@ const BookingPage = ({
|
||||
};
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
if (recurringDates.length) {
|
||||
// Identify set of bookings to one intance of recurring event to support batch changes
|
||||
const recurringEventId = uuidv4();
|
||||
const recurringBookings = recurringDates.map((recurringDate) => ({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(recurringDate).format(),
|
||||
end: dayjs(recurringDate).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
recurringEventId,
|
||||
// Added to track down the number of actual occurrences selected by the user
|
||||
recurringCount: recurringDates.length,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
}));
|
||||
recurringMutation.mutate(recurringBookings);
|
||||
} else {
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
web3Details,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(eventType.length, "minute").format(),
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(
|
||||
booking.locationType ? booking : { ...booking, locationType: selectedLocation }
|
||||
),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
hasHashedBookingLink,
|
||||
hashedLink,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const disableInput = !!rescheduleUid;
|
||||
@@ -331,7 +409,7 @@ const BookingPage = ({
|
||||
"main overflow-hidden",
|
||||
isEmbed ? "" : "border border-gray-200",
|
||||
isBackgroundTransparent ? "" : "dark:border-1 bg-white dark:bg-gray-800",
|
||||
"rounded-md dark:border-gray-600 sm:border"
|
||||
"rounded-md sm:border sm:dark:border-gray-600"
|
||||
)}>
|
||||
<div className="px-4 py-5 sm:flex sm:p-4">
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-700">
|
||||
@@ -375,10 +453,41 @@ const BookingPage = ({
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-bookinghighlight mb-4">
|
||||
<CalendarIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{parseDate(date, i18n)}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent?.freq && recurringEventCount && (
|
||||
<div className="mb-3 text-gray-600 dark:text-white">
|
||||
<RefreshIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{`${t("every_for_freq", {
|
||||
freq: t(`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`),
|
||||
})} ${recurringEventCount} ${t(
|
||||
`${RRuleFrequency[eventType.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: parseInt(recurringEventCount.toString()) }
|
||||
)}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-bookinghighlight mb-4 flex">
|
||||
<CalendarIcon className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
<div className="-mt-1">
|
||||
{(rescheduleUid || !eventType.recurringEvent.freq) &&
|
||||
parseDate(dayjs.tz(date, timeZone()), i18n)}
|
||||
{!rescheduleUid &&
|
||||
eventType.recurringEvent.freq &&
|
||||
recurringStrings.slice(0, 5).map((aDate, key) => <p key={key}>{aDate}</p>)}
|
||||
{!rescheduleUid && eventType.recurringEvent.freq && recurringStrings.length > 5 && (
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.slice(5).map((aDate, key) => (
|
||||
<p key={key}>{aDate}</p>
|
||||
))}>
|
||||
<p className="text-gray-600 dark:text-white">
|
||||
{t("plus_more", { count: recurringStrings.length - 5 })}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{eventTypeDetail.isWeb3Active && eventType.metadata.smartContractAddress && (
|
||||
<p className="text-bookinglight mb-1 -ml-2 px-2 py-1">
|
||||
{t("requires_ownership_of_a_token") + " " + eventType.metadata.smartContractAddress}
|
||||
@@ -429,13 +538,22 @@ const BookingPage = ({
|
||||
{...bookingForm.register("email")}
|
||||
required
|
||||
className={classNames(
|
||||
"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",
|
||||
disableInput ? "bg-gray-200 dark:text-gray-500" : ""
|
||||
"focus:border-brand block w-full rounded-sm shadow-sm focus:ring-black dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 sm:text-sm",
|
||||
disableInput ? "bg-gray-200 dark:text-gray-500" : "",
|
||||
bookingForm.formState.errors.email
|
||||
? "border-red-700 focus:ring-red-700"
|
||||
: " border-gray-300 dark:border-gray-900"
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
|
||||
disabled={disableInput}
|
||||
/>
|
||||
{bookingForm.formState.errors.email && (
|
||||
<div className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<ExclamationCircleIcon className="mr-2 h-3 w-3" />
|
||||
<p>{t("email_validation_error")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{locations.length > 1 && (
|
||||
|
||||
@@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ClockIcon, CreditCardIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
||||
import { ClockIcon, CreditCardIcon, RefreshIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||
select: {
|
||||
@@ -14,6 +16,7 @@ const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||
price: true,
|
||||
currency: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
@@ -28,6 +31,11 @@ export type EventTypeDescriptionProps = {
|
||||
export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const recurringEvent: RecurringEvent = useMemo(
|
||||
() => (eventType.recurringEvent as RecurringEvent) || [],
|
||||
[eventType.recurringEvent]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
||||
@@ -54,6 +62,12 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||
{t("1_on_1")}
|
||||
</li>
|
||||
)}
|
||||
{recurringEvent?.count && recurringEvent.count > 0 && (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<RefreshIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
{t("repeats_up_to", { count: recurringEvent.count })}
|
||||
</li>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<li className="flex whitespace-nowrap">
|
||||
<CreditCardIcon className="mt-0.5 mr-1.5 inline h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||
|
||||
132
apps/web/components/eventtype/RecurringEventController.tsx
Normal file
132
apps/web/components/eventtype/RecurringEventController.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Collapsible, CollapsibleContent } from "@radix-ui/react-collapsible";
|
||||
import React, { useState } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { Frequency as RRuleFrequency } from "rrule";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
type RecurringEventControllerProps = { recurringEvent: RecurringEvent; formMethods: UseFormReturn<any, any> };
|
||||
|
||||
export default function RecurringEventController({
|
||||
recurringEvent,
|
||||
formMethods,
|
||||
}: RecurringEventControllerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const [recurringEventDefined, setRecurringEventDefined] = useState(recurringEvent?.count !== undefined);
|
||||
|
||||
const [recurringEventInterval, setRecurringEventInterval] = useState(recurringEvent?.interval || 1);
|
||||
const [recurringEventFrequency, setRecurringEventFrequency] = useState(
|
||||
recurringEvent?.freq || RRuleFrequency.WEEKLY
|
||||
);
|
||||
const [recurringEventCount, setRecurringEventCount] = useState(recurringEvent?.count || 12);
|
||||
|
||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||
const recurringEventFreqOptions = Object.entries(RRuleFrequency)
|
||||
.filter(([key, value]) => isNaN(Number(key)) && Number(value) < 3)
|
||||
.map(([key, value]) => ({
|
||||
label: t(`${key.toString().toLowerCase()}`, { count: recurringEventInterval }),
|
||||
value: value.toString(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="block items-start sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0">
|
||||
<label htmlFor="recurringEvent" className="flex text-sm font-medium text-neutral-700">
|
||||
{t("recurring_event")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
onChange={(event) => {
|
||||
setRecurringEventDefined(event?.target.checked);
|
||||
if (!event?.target.checked) {
|
||||
formMethods.setValue("recurringEvent", {});
|
||||
} else {
|
||||
formMethods.setValue(
|
||||
"recurringEvent",
|
||||
recurringEventDefined
|
||||
? recurringEvent
|
||||
: {
|
||||
interval: 1,
|
||||
count: 12,
|
||||
freq: RRuleFrequency.WEEKLY,
|
||||
}
|
||||
);
|
||||
}
|
||||
recurringEvent = formMethods.getValues("recurringEvent");
|
||||
}}
|
||||
type="checkbox"
|
||||
className="text-primary-600 h-4 w-4 rounded border-gray-300"
|
||||
defaultChecked={recurringEventDefined}
|
||||
data-testid="recurring-event-check"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<p className="text-neutral-900">{t("recurring_event_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible
|
||||
open={recurringEventDefined}
|
||||
data-testid="recurring-event-collapsible"
|
||||
onOpenChange={() => setRecurringEventDefined(!recurringEventDefined)}>
|
||||
<CollapsibleContent className="mt-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("repeats_every")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
defaultValue={recurringEvent?.interval || 1}
|
||||
onChange={(event) => {
|
||||
setRecurringEventInterval(parseInt(event?.target.value));
|
||||
recurringEvent.interval = parseInt(event?.target.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
options={recurringEventFreqOptions}
|
||||
value={recurringEventFreqOptions[recurringEventFrequency]}
|
||||
isSearchable={false}
|
||||
className="w-18 block min-w-0 rounded-sm sm:text-sm"
|
||||
onChange={(e) => {
|
||||
if (e?.value) {
|
||||
setRecurringEventFrequency(parseInt(e?.value));
|
||||
recurringEvent.freq = parseInt(e?.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<p className="mr-2 text-neutral-900">{t("max")}</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
className="block w-16 rounded-sm border-gray-300 shadow-sm [appearance:textfield] ltr:mr-2 rtl:ml-2 sm:text-sm"
|
||||
defaultValue={recurringEvent?.count || 12}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
recurringEvent.count = parseInt(event?.target.value);
|
||||
formMethods.setValue("recurringEvent", recurringEvent);
|
||||
}}
|
||||
/>
|
||||
<p className="mr-2 text-neutral-900">
|
||||
{t(`${RRuleFrequency[recurringEventFrequency].toString().toLowerCase()}`, {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,8 +61,8 @@ const ChangePasswordSection = () => {
|
||||
<div className="my-3">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("change_password")}</h2>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-1/2 ltr:mr-2 rtl:ml-2">
|
||||
<div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0">
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2 sm:w-1/2">
|
||||
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
|
||||
{t("current_password")}
|
||||
</label>
|
||||
@@ -79,7 +79,7 @@ const ChangePasswordSection = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 w-1/2">
|
||||
<div className="w-full sm:w-1/2">
|
||||
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
|
||||
{t("new_password")}
|
||||
</label>
|
||||
@@ -98,7 +98,7 @@ const ChangePasswordSection = () => {
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
<div className="flex justify-end py-8">
|
||||
<div className="flex py-8 sm:justify-end">
|
||||
<Button color="secondary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
@@ -17,7 +17,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row justify-between truncate pt-9 pl-2">
|
||||
<div className="flex flex-col justify-between pt-9 pl-2 sm:flex-row">
|
||||
<div>
|
||||
<div className="flex flex-row items-center">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("2fa")}</h2>
|
||||
@@ -27,7 +27,7 @@ const TwoFactorAuthSection = ({ twoFactorEnabled }: { twoFactorEnabled: boolean
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{t("add_an_extra_layer_of_security")}</p>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<div className="mt-5 sm:mt-0 sm:self-center">
|
||||
<Button
|
||||
type="submit"
|
||||
color="secondary"
|
||||
|
||||
@@ -15,10 +15,11 @@ type MembershipRoleOption = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }];
|
||||
const options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
isOpen: boolean;
|
||||
currentMember: MembershipRole;
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
@@ -57,7 +58,6 @@ export default function MemberChangeRoleModal(props: {
|
||||
role: role.value,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContainer isOpen={props.isOpen} onExit={props.onExit}>
|
||||
<>
|
||||
@@ -76,7 +76,7 @@ export default function MemberChangeRoleModal(props: {
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
<Select
|
||||
isSearchable={false}
|
||||
options={options}
|
||||
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
value={role}
|
||||
onChange={(option) => option && setRole(option)}
|
||||
id="role"
|
||||
|
||||
@@ -16,6 +16,7 @@ import Select from "@components/ui/form/Select";
|
||||
type MemberInvitationModalProps = {
|
||||
isOpen: boolean;
|
||||
team: TeamWithMembers | null;
|
||||
currentMember: MembershipRole;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
@@ -24,7 +25,7 @@ type MembershipRoleOption = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }];
|
||||
const _options: MembershipRoleOption[] = [{ value: "MEMBER" }, { value: "ADMIN" }, { value: "OWNER" }];
|
||||
|
||||
export default function MemberInvitationModal(props: MemberInvitationModalProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
@@ -100,7 +101,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
|
||||
</label>
|
||||
<Select
|
||||
defaultValue={options[0]}
|
||||
options={options}
|
||||
options={props.currentMember !== MembershipRole.OWNER ? options.slice(0, 2) : options}
|
||||
id="role"
|
||||
name="role"
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import { PencilIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon } from "@heroicons/react/solid";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import Button from "@calcom/ui/Button";
|
||||
@@ -14,12 +15,12 @@ import Dropdown, {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
@@ -49,6 +50,14 @@ export default function MemberListItem(props: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const ownersInTeam = () => {
|
||||
const { members } = props.team;
|
||||
const owners = members.filter((member) => member["role"] === MembershipRole.OWNER && member["accepted"]);
|
||||
return owners.length;
|
||||
};
|
||||
|
||||
const currentUserId = useCurrentUserId();
|
||||
|
||||
const name =
|
||||
props.member.name ||
|
||||
(() => {
|
||||
@@ -65,7 +74,7 @@ export default function MemberListItem(props: Props) {
|
||||
<div className="flex w-full flex-col justify-between sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, name)}
|
||||
imageSrc={WEBSITE_URL + "/" + props.member.username + "/avatar.png"}
|
||||
alt={name || ""}
|
||||
className="h-9 w-9 rounded-full"
|
||||
/>
|
||||
@@ -121,8 +130,12 @@ export default function MemberListItem(props: Props) {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{(props.team.membership.role === MembershipRole.OWNER ||
|
||||
props.team.membership.role === MembershipRole.ADMIN) && (
|
||||
{((props.team.membership.role === MembershipRole.OWNER &&
|
||||
(props.member.role !== MembershipRole.OWNER ||
|
||||
ownersInTeam() > 1 ||
|
||||
props.member.id !== currentUserId)) ||
|
||||
(props.team.membership.role === MembershipRole.ADMIN &&
|
||||
props.member.role !== MembershipRole.OWNER)) && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
@@ -165,6 +178,7 @@ export default function MemberListItem(props: Props) {
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
isOpen={showChangeMemberRoleModal}
|
||||
currentMember={props.team.membership.role}
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
@@ -181,7 +195,7 @@ export default function MemberListItem(props: Props) {
|
||||
<div className="space-x-2 border-t py-5 rtl:space-x-reverse">
|
||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||
{props.team.membership.role !== MembershipRole.MEMBER && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`} passHref>
|
||||
<Button color="secondary">{t("Open Team Availability")}</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -19,12 +19,12 @@ import Dropdown, {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
import React from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
@@ -52,7 +52,7 @@ const Team = ({ team }: TeamPageProps) => {
|
||||
<div>
|
||||
<Avatar
|
||||
alt={member.name || ""}
|
||||
imageSrc={getPlaceholderAvatar(member.avatar, member.username)}
|
||||
imageSrc={WEBSITE_URL + "/" + member.username + "/avatar.png"}
|
||||
className="-mt-4 h-12 w-12"
|
||||
/>
|
||||
<section className="mt-2 w-full space-y-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InformationCircleIcon } from "@heroicons/react/solid";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
export default function InfoBadge({ content }: { content: string }) {
|
||||
return (
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function WebhookDialogForm(props: {
|
||||
subscriberUrl: "",
|
||||
active: true,
|
||||
payloadTemplate: null,
|
||||
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId">,
|
||||
} as Omit<TWebhook, "userId" | "createdAt" | "eventTypeId" | "appId">,
|
||||
} = props;
|
||||
|
||||
const [useCustomPayloadTemplate, setUseCustomPayloadTemplate] = useState(!!defaultValues.payloadTemplate);
|
||||
@@ -58,7 +58,9 @@ export default function WebhookDialogForm(props: {
|
||||
props.handleClose();
|
||||
}}
|
||||
className="space-y-4">
|
||||
<input type="hidden" {...form.register("id")} />
|
||||
<div>
|
||||
<input type="hidden" {...form.register("id")} />
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
<Controller
|
||||
@@ -76,20 +78,21 @@ export default function WebhookDialogForm(props: {
|
||||
/>
|
||||
</InputGroupBox>
|
||||
</fieldset>
|
||||
<TextField
|
||||
label={t("subscriber_url")}
|
||||
{...form.register("subscriberUrl")}
|
||||
required
|
||||
type="url"
|
||||
onChange={(e) => {
|
||||
form.setValue("subscriberUrl", e.target.value);
|
||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
||||
setUseCustomPayloadTemplate(true);
|
||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={t("subscriber_url")}
|
||||
{...form.register("subscriberUrl")}
|
||||
required
|
||||
type="url"
|
||||
onChange={(e) => {
|
||||
form.setValue("subscriberUrl", e.target.value);
|
||||
if (hasTemplateIntegration({ url: e.target.value })) {
|
||||
setUseCustomPayloadTemplate(true);
|
||||
form.setValue("payloadTemplate", customTemplate({ url: e.target.value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<FieldsetLegend>{t("event_triggers")}</FieldsetLegend>
|
||||
<InputGroupBox className="border-0 bg-gray-50">
|
||||
|
||||
@@ -3,12 +3,12 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { ListItem } from "@components/List";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
|
||||
export type TWebhook = inferQueryOutput<"viewer.webhook.list">[number];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
type contractsContextType = Record<string, string>;
|
||||
|
||||
const contractsContextDefaultValue: contractsContextType = {};
|
||||
@@ -21,18 +23,17 @@ interface addContractsPayload {
|
||||
|
||||
export function ContractsProvider({ children }: Props) {
|
||||
const addContract = (payload: addContractsPayload) => {
|
||||
window.localStorage.setItem(
|
||||
localStorage.setItem(
|
||||
"contracts",
|
||||
JSON.stringify({
|
||||
...JSON.parse(window.localStorage.getItem("contracts") || "{}"),
|
||||
...JSON.parse(localStorage.getItem("contracts") || "{}"),
|
||||
[payload.address]: payload.signature,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const value = {
|
||||
contracts:
|
||||
typeof window !== "undefined" ? JSON.parse(window.localStorage.getItem("contracts") || "{}") : {},
|
||||
contracts: typeof window !== "undefined" ? JSON.parse(localStorage.getItem("contracts") || "{}") : {},
|
||||
addContract,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import Button from "@calcom/ui/Button";
|
||||
|
||||
import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
const TrialBanner = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -8,11 +8,11 @@ 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 { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { Form, TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||
|
||||
import { TApiKeys } from "./ApiKeyListItem";
|
||||
@@ -102,17 +102,18 @@ export default function ApiKeyDialogForm(props: {
|
||||
setSuccessfulNewApiKeyModal(true);
|
||||
}}
|
||||
className="space-y-4">
|
||||
<div className=" mb-10 mt-1">
|
||||
<div className="mb-10 mt-1">
|
||||
<h2 className="font-semi-bold font-cal text-xl tracking-wide text-gray-900">{props.title}</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("api_key_modal_subtitle")}</p>
|
||||
</div>
|
||||
<TextField
|
||||
label={t("personal_note")}
|
||||
placeholder={t("personal_note_placeholder")}
|
||||
{...form.register("note")}
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<TextField
|
||||
label={t("personal_note")}
|
||||
placeholder={t("personal_note_placeholder")}
|
||||
{...form.register("note")}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="block text-sm font-medium text-gray-700">{t("expire_date")}</span>
|
||||
|
||||
@@ -24,12 +24,12 @@ export default function ApiKeyListContainer() {
|
||||
query={query}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
<div className="flex flex-row justify-between truncate pl-2 pr-1 ">
|
||||
<div className="flex flex-col justify-between truncate pl-2 pr-1 sm:flex-row">
|
||||
<div className="mt-9">
|
||||
<h2 className="font-cal text-lg font-medium leading-6 text-gray-900">{t("api_keys")}</h2>
|
||||
<p className="mt-1 mb-5 text-sm text-gray-500">{t("api_keys_subtitle")}</p>
|
||||
</div>
|
||||
<div className="self-center">
|
||||
<div className="mb-9 sm:self-center">
|
||||
<Button StartIcon={PlusIcon} color="secondary" onClick={() => setNewApiKeyModal(true)}>
|
||||
{t("generate_new_api_key")}
|
||||
</Button>
|
||||
|
||||
@@ -7,11 +7,11 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { ListItem } from "@components/List";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Badge from "@components/ui/Badge";
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import utc from "dayjs/plugin/utc";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
@@ -36,7 +37,7 @@ export default function TeamAvailabilityModal(props: Props) {
|
||||
<div className="min-w-64 w-64 space-y-5 p-5 pr-0">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, props.member?.name as string)}
|
||||
imageSrc={WEBSITE_URL + "/" + props.member?.username + "/avatar.png"}
|
||||
alt={props.member?.name || ""}
|
||||
className="h-14 w-14 rounded-full"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,8 @@ import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
@@ -45,7 +46,7 @@ export default function TeamAvailabilityScreen(props: Props) {
|
||||
HeaderComponent={
|
||||
<div className="mb-6 flex items-center">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(member?.avatar, member?.name as string)}
|
||||
imageSrc={WEBSITE_URL + "/" + member.username + "/avatar.png"}
|
||||
alt={member?.name || ""}
|
||||
className="min-w-10 min-h-10 mt-1 h-10 w-10 rounded-full"
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import EventManager from "@calcom/core/EventManager";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import prisma from "@calcom/prisma";
|
||||
import stripe from "@calcom/stripe/server";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { IS_PRODUCTION } from "@lib/config/constants";
|
||||
import { HttpError as HttpCode } from "@lib/core/http/error";
|
||||
@@ -49,6 +49,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
confirmed: true,
|
||||
attendees: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
@@ -70,6 +71,23 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
|
||||
if (!booking) throw new Error("No booking found");
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({ recurringEvent: true });
|
||||
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({ select: eventTypeSelect });
|
||||
type EventTypeRaw = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: booking.eventTypeId,
|
||||
},
|
||||
select: eventTypeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
const eventType = {
|
||||
recurringEvent: (eventTypeRaw?.recurringEvent || {}) as RecurringEvent,
|
||||
};
|
||||
|
||||
const { user } = booking;
|
||||
|
||||
if (!user) throw new Error("No user found");
|
||||
@@ -137,7 +155,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
|
||||
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
||||
|
||||
await sendScheduledEmails({ ...evt });
|
||||
await sendScheduledEmails({ ...evt }, eventType.recurringEvent);
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Alert } from "@calcom/ui/Alert";
|
||||
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import Shell, { useMeQuery } from "@components/Shell";
|
||||
import Shell from "@components/Shell";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
export function TeamAvailabilityPage() {
|
||||
|
||||
@@ -3,6 +3,8 @@ import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
import { isBrowserLocale24h } from "./timeFormat";
|
||||
|
||||
dayjs.extend(utc);
|
||||
@@ -21,11 +23,11 @@ const timeOptions: TimeOptions = {
|
||||
const isInitialized = false;
|
||||
|
||||
const initClock = () => {
|
||||
if (typeof localStorage === "undefined" || isInitialized) {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
// This only sets browser locale if there's no preference on localStorage.
|
||||
if (!localStorage || !localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
|
||||
if (!localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
|
||||
timeOptions.is24hClock = localStorage.getItem("timeOption.is24hClock") === "true";
|
||||
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
import { recurringEvent } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent, Person, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import AttendeeAwaitingPaymentEmail from "@lib/emails/templates/attendee-awaiting-payment-email";
|
||||
import AttendeeCancelledEmail from "@lib/emails/templates/attendee-cancelled-email";
|
||||
@@ -17,14 +18,14 @@ import OrganizerRescheduledEmail from "@lib/emails/templates/organizer-reschedul
|
||||
import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-email";
|
||||
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
||||
|
||||
export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendScheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
@@ -36,7 +37,7 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerScheduledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerScheduledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
||||
@@ -47,14 +48,14 @@ export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendRescheduledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeRescheduledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
@@ -66,7 +67,7 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerRescheduledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerRescheduledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerScheduledEmail.sendEmail failed", e));
|
||||
@@ -77,10 +78,13 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerRequestEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const organizerRequestEmail = new OrganizerRequestEmail(calEvent);
|
||||
const organizerRequestEmail = new OrganizerRequestEmail(calEvent, recurringEvent);
|
||||
resolve(organizerRequestEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestEmail.sendEmail failed", e));
|
||||
@@ -88,10 +92,14 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => {
|
||||
export const sendAttendeeRequestEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
attendee: Person,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee);
|
||||
const attendeeRequestEmail = new AttendeeRequestEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(attendeeRequestEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendRequestEmail.sendEmail failed", e));
|
||||
@@ -99,14 +107,14 @@ export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee
|
||||
});
|
||||
};
|
||||
|
||||
export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendDeclinedEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee);
|
||||
const declinedEmail = new AttendeeDeclinedEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(declinedEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
@@ -118,14 +126,14 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||
export const sendCancelledEmails = async (calEvent: CalendarEvent, recurringEvent: RecurringEvent = {}) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee);
|
||||
const scheduledEmail = new AttendeeCancelledEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeCancelledEmail.sendEmail failed", e));
|
||||
@@ -137,7 +145,7 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new OrganizerCancelledEmail(calEvent);
|
||||
const scheduledEmail = new OrganizerCancelledEmail(calEvent, recurringEvent);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerCancelledEmail.sendEmail failed", e));
|
||||
@@ -148,10 +156,13 @@ export const sendCancelledEmails = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerRequestReminderEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent);
|
||||
const organizerRequestReminderEmail = new OrganizerRequestReminderEmail(calEvent, recurringEvent);
|
||||
resolve(organizerRequestReminderEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestReminderEmail.sendEmail failed", e));
|
||||
@@ -159,14 +170,17 @@ export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent)
|
||||
});
|
||||
};
|
||||
|
||||
export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendAwaitingPaymentEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
...calEvent.attendees.map((attendee) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee);
|
||||
const paymentEmail = new AttendeeAwaitingPaymentEmail(calEvent, attendee, recurringEvent);
|
||||
resolve(paymentEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeAwaitingPaymentEmail.sendEmail failed", e));
|
||||
@@ -178,10 +192,13 @@ export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
|
||||
export const sendOrganizerPaymentRefundFailedEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
try {
|
||||
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent);
|
||||
const paymentRefundFailedEmail = new OrganizerPaymentRefundFailedEmail(calEvent, recurringEvent);
|
||||
resolve(paymentRefundFailedEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerPaymentRefundFailedEmail.sendEmail failed", e));
|
||||
@@ -213,14 +230,19 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
|
||||
|
||||
export const sendRequestRescheduleEmail = async (
|
||||
calEvent: CalendarEvent,
|
||||
metadata: { rescheduleLink: string }
|
||||
metadata: { rescheduleLink: string },
|
||||
recurringEvent: RecurringEvent = {}
|
||||
) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(calEvent, metadata);
|
||||
const requestRescheduleEmail = new AttendeeRequestRescheduledEmail(
|
||||
calEvent,
|
||||
metadata,
|
||||
recurringEvent
|
||||
);
|
||||
resolve(requestRescheduleEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRequestRescheduledEmail.sendEmail failed", e));
|
||||
@@ -231,7 +253,11 @@ export const sendRequestRescheduleEmail = async (
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(calEvent, metadata);
|
||||
const requestRescheduleEmail = new OrganizerRequestRescheduleEmail(
|
||||
calEvent,
|
||||
metadata,
|
||||
recurringEvent
|
||||
);
|
||||
resolve(requestRescheduleEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("OrganizerRequestRescheduledEmail.sendEmail failed", e));
|
||||
|
||||
@@ -42,7 +42,9 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
|
||||
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.attendee.language.translate("event_request_declined")}
|
||||
${this.attendee.language.translate(
|
||||
this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
|
||||
)}
|
||||
${this.attendee.language.translate("emailed_you_and_any_other_attendees")}
|
||||
${this.getWhat()}
|
||||
${this.getWhen()}
|
||||
@@ -75,7 +77,9 @@ ${this.getRejectionReason()}
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("xCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.attendee.language.translate("event_request_declined"),
|
||||
this.attendee.language.translate(
|
||||
this.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
|
||||
),
|
||||
this.attendee.language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
|
||||
@@ -87,10 +87,17 @@ ${this.getAdditionalNotes()}
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("calendarCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("booking_submitted"),
|
||||
this.calEvent.organizer.language.translate("user_needs_to_confirm_or_reject_booking", {
|
||||
user: this.calEvent.organizer.name,
|
||||
})
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
|
||||
),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent.count
|
||||
? "user_needs_to_confirm_or_reject_booking_recurring"
|
||||
: "user_needs_to_confirm_or_reject_booking",
|
||||
{
|
||||
user: this.calEvent.organizer.name,
|
||||
}
|
||||
)
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
@@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
|
||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -24,8 +24,8 @@ dayjs.extend(toArray);
|
||||
|
||||
export default class AttendeeRequestRescheduledEmail extends OrganizerScheduledEmail {
|
||||
private metadata: { rescheduleLink: string };
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
|
||||
super(calEvent);
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
|
||||
super(calEvent, recurringEvent);
|
||||
this.metadata = metadata;
|
||||
}
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
|
||||
@@ -4,13 +4,15 @@ import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray } from "ics";
|
||||
import { DatasetJsonLdProps } from "next-seo";
|
||||
import nodemailer from "nodemailer";
|
||||
import rrule from "rrule";
|
||||
|
||||
import { getAppName } from "@calcom/app-store/utils";
|
||||
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { Person, CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -29,10 +31,12 @@ dayjs.extend(toArray);
|
||||
export default class AttendeeScheduledEmail {
|
||||
calEvent: CalendarEvent;
|
||||
attendee: Person;
|
||||
recurringEvent: RecurringEvent;
|
||||
|
||||
constructor(calEvent: CalendarEvent, attendee: Person) {
|
||||
constructor(calEvent: CalendarEvent, attendee: Person, recurringEvent: RecurringEvent) {
|
||||
this.calEvent = calEvent;
|
||||
this.attendee = attendee;
|
||||
this.recurringEvent = recurringEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
@@ -53,6 +57,11 @@ export default class AttendeeScheduledEmail {
|
||||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
// Taking care of recurrence rule beforehand
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (this.recurringEvent?.count) {
|
||||
recurrenceRule = new rrule(this.recurringEvent).toString();
|
||||
}
|
||||
const icsEvent = createEvent({
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
@@ -72,6 +81,7 @@ export default class AttendeeScheduledEmail {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
})),
|
||||
...{ recurrenceRule },
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
@@ -125,7 +135,9 @@ export default class AttendeeScheduledEmail {
|
||||
}
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled")}
|
||||
${this.calEvent.attendees[0].language.translate(
|
||||
this.recurringEvent?.count ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled"
|
||||
)}
|
||||
${this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")}
|
||||
|
||||
${getRichDescription(this.calEvent)}
|
||||
@@ -157,7 +169,11 @@ ${getRichDescription(this.calEvent)}
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("checkCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.attendees[0].language.translate("your_event_has_been_scheduled"),
|
||||
this.calEvent.attendees[0].language.translate(
|
||||
this.recurringEvent?.count
|
||||
? "your_event_has_been_scheduled_recurring"
|
||||
: "your_event_has_been_scheduled"
|
||||
),
|
||||
this.calEvent.attendees[0].language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
@@ -250,12 +266,30 @@ ${getRichDescription(this.calEvent)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected getRecurringWhen(): string {
|
||||
if (this.recurringEvent?.freq) {
|
||||
return ` - ${this.calEvent.attendees[0].language.translate("every_for_freq", {
|
||||
freq: this.calEvent.attendees[0].language.translate(
|
||||
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`
|
||||
),
|
||||
})} ${this.recurringEvent.count} ${this.calEvent.attendees[0].language.translate(
|
||||
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: this.recurringEvent.count }
|
||||
)}`;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected getWhen(): string {
|
||||
return `
|
||||
<p style="height: 6px"></p>
|
||||
<div style="line-height: 6px;">
|
||||
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("when")}</p>
|
||||
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("when")}${
|
||||
this.recurringEvent?.count ? this.getRecurringWhen() : ""
|
||||
}</p>
|
||||
<p style="color: #494949; font-weight: 400; line-height: 24px;">
|
||||
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
|
||||
${this.calEvent.attendees[0].language.translate(
|
||||
this.getInviteeStart().format("dddd").toLowerCase()
|
||||
)}, ${this.calEvent.attendees[0].language.translate(
|
||||
|
||||
@@ -86,7 +86,9 @@ ${process.env.NEXT_PUBLIC_WEBAPP_URL} + "/bookings/upcoming"
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("calendarCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("event_awaiting_approval"),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "event_awaiting_approval_recurring" : "event_awaiting_approval"
|
||||
),
|
||||
this.calEvent.organizer.language.translate("someone_requested_an_event")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
|
||||
@@ -6,7 +6,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
|
||||
import { getCancelLink } from "@calcom/lib/CalEventParser";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -24,8 +24,8 @@ dayjs.extend(toArray);
|
||||
|
||||
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
|
||||
private metadata: { rescheduleLink: string };
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
|
||||
super(calEvent);
|
||||
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }, recurringEvent: RecurringEvent) {
|
||||
super(calEvent, recurringEvent);
|
||||
this.metadata = metadata;
|
||||
}
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
|
||||
@@ -5,12 +5,13 @@ import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { createEvent, DateArray, Person } from "ics";
|
||||
import nodemailer from "nodemailer";
|
||||
import rrule from "rrule";
|
||||
|
||||
import { getAppName } from "@calcom/app-store/utils";
|
||||
import { getCancelLink, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { CalendarEvent, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import {
|
||||
emailHead,
|
||||
@@ -28,9 +29,11 @@ dayjs.extend(toArray);
|
||||
|
||||
export default class OrganizerScheduledEmail {
|
||||
calEvent: CalendarEvent;
|
||||
recurringEvent: RecurringEvent;
|
||||
|
||||
constructor(calEvent: CalendarEvent) {
|
||||
constructor(calEvent: CalendarEvent, recurringEvent: RecurringEvent) {
|
||||
this.calEvent = calEvent;
|
||||
this.recurringEvent = recurringEvent;
|
||||
}
|
||||
|
||||
public sendEmail() {
|
||||
@@ -51,6 +54,11 @@ export default class OrganizerScheduledEmail {
|
||||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
// Taking care of recurrence rule beforehand
|
||||
let recurrenceRule: string | undefined = undefined;
|
||||
if (this.recurringEvent?.count) {
|
||||
recurrenceRule = new rrule(this.recurringEvent).toString();
|
||||
}
|
||||
const icsEvent = createEvent({
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
@@ -66,6 +74,7 @@ export default class OrganizerScheduledEmail {
|
||||
description: this.getTextBody(),
|
||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||
...{ recurrenceRule },
|
||||
attendees: this.calEvent.attendees.map((attendee: Person) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
@@ -121,7 +130,9 @@ export default class OrganizerScheduledEmail {
|
||||
|
||||
protected getTextBody(): string {
|
||||
return `
|
||||
${this.calEvent.organizer.language.translate("new_event_scheduled")}
|
||||
${this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
|
||||
)}
|
||||
${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
|
||||
|
||||
${getRichDescription(this.calEvent)}
|
||||
@@ -153,7 +164,9 @@ ${getRichDescription(this.calEvent)}
|
||||
<div style="background-color:#F5F5F5;">
|
||||
${emailSchedulingBodyHeader("checkCircle")}
|
||||
${emailScheduledBodyHeaderContent(
|
||||
this.calEvent.organizer.language.translate("new_event_scheduled"),
|
||||
this.calEvent.organizer.language.translate(
|
||||
this.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
|
||||
),
|
||||
this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
|
||||
)}
|
||||
${emailSchedulingBodyDivider()}
|
||||
@@ -240,12 +253,30 @@ ${getRichDescription(this.calEvent)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected getRecurringWhen(): string {
|
||||
if (this.recurringEvent?.freq) {
|
||||
return ` - ${this.calEvent.attendees[0].language.translate("every_for_freq", {
|
||||
freq: this.calEvent.attendees[0].language.translate(
|
||||
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`
|
||||
),
|
||||
})} ${this.recurringEvent.count} ${this.calEvent.attendees[0].language.translate(
|
||||
`${rrule.FREQUENCIES[this.recurringEvent.freq].toString().toLowerCase()}`,
|
||||
{ count: this.recurringEvent.count }
|
||||
)}`;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
protected getWhen(): string {
|
||||
return `
|
||||
<p style="height: 6px"></p>
|
||||
<div style="line-height: 6px;">
|
||||
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}</p>
|
||||
<p style="color: #494949;">${this.calEvent.organizer.language.translate("when")}${
|
||||
this.recurringEvent?.count ? this.getRecurringWhen() : ""
|
||||
}</p>
|
||||
<p style="color: #494949; font-weight: 400; line-height: 24px;">
|
||||
${this.recurringEvent?.count ? `${this.calEvent.attendees[0].language.translate("starting")} ` : ""}
|
||||
${this.calEvent.organizer.language.translate(
|
||||
this.getOrganizerStart().format("dddd").toLowerCase()
|
||||
)}, ${this.calEvent.organizer.language.translate(
|
||||
|
||||
9
apps/web/lib/hooks/useCurrentUserId.ts
Normal file
9
apps/web/lib/hooks/useCurrentUserId.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import useMeQuery from "./useMeQuery";
|
||||
|
||||
export const useCurrentUserId = () => {
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
return user?.id;
|
||||
};
|
||||
|
||||
export default useCurrentUserId;
|
||||
13
apps/web/lib/hooks/useMeQuery.ts
Normal file
13
apps/web/lib/hooks/useMeQuery.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { trpc } from "../trpc";
|
||||
|
||||
export function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
export default useMeQuery;
|
||||
@@ -40,7 +40,7 @@ export default function useTheme(theme?: Maybe<string>) {
|
||||
// TODO: isReady doesn't seem required now. This is also impacting PSI Score for pages which are using isReady.
|
||||
setIsReady(true);
|
||||
setTheme(theme);
|
||||
}, []);
|
||||
}, [theme]);
|
||||
|
||||
function Theme() {
|
||||
const code = applyThemeAndAddListener.toString();
|
||||
|
||||
22
apps/web/lib/mutations/bookings/create-recurring-booking.ts
Normal file
22
apps/web/lib/mutations/bookings/create-recurring-booking.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
||||
|
||||
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
|
||||
|
||||
const createRecurringBooking = async (data: ExtendedBookingCreateBody[]) => {
|
||||
return Promise.all(
|
||||
data.map((booking, key) => {
|
||||
// We only want to send the first occurrence of the meeting at the moment, not all at once
|
||||
if (key === 0) {
|
||||
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", booking);
|
||||
} else {
|
||||
return fetch.post<ExtendedBookingCreateBody, BookingResponse>("/api/book/event", {
|
||||
...booking,
|
||||
noEmail: true,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export default createRecurringBooking;
|
||||
@@ -1,14 +1,42 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { I18n } from "next-i18next";
|
||||
import { RRule } from "rrule";
|
||||
|
||||
import { recurringEvent } from "@calcom/prisma/zod-utils";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { detectBrowserTimeFormat } from "@lib/timeFormat";
|
||||
|
||||
import { parseZone } from "./parseZone";
|
||||
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
if (!date) return "No date";
|
||||
const processDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
const parsedZone = parseZone(date);
|
||||
if (!parsedZone?.isValid()) return "Invalid date";
|
||||
const formattedTime = parsedZone?.format(detectBrowserTimeFormat);
|
||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
||||
};
|
||||
|
||||
export const parseDate = (date: string | null | Dayjs, i18n: I18n) => {
|
||||
if (!date) return ["No date"];
|
||||
return processDate(date, i18n);
|
||||
};
|
||||
|
||||
export const parseRecurringDates = (
|
||||
{
|
||||
startDate,
|
||||
recurringEvent,
|
||||
recurringCount,
|
||||
}: { startDate: string | null | Dayjs; recurringEvent: RecurringEvent; recurringCount: number },
|
||||
i18n: I18n
|
||||
): [string[], Date[]] => {
|
||||
const { count, ...restRecurringEvent } = recurringEvent;
|
||||
const rule = new RRule({
|
||||
...restRecurringEvent,
|
||||
count: recurringCount,
|
||||
dtstart: dayjs(startDate).toDate(),
|
||||
});
|
||||
const dateStrings = rule.all().map((r) => {
|
||||
return processDate(dayjs(r), i18n);
|
||||
});
|
||||
return [dateStrings, rule.all()];
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ export type TeamWithMembers = AsyncReturnType<typeof getTeamWithMembers>;
|
||||
export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
username: true,
|
||||
avatar: true,
|
||||
email: true,
|
||||
name: true,
|
||||
id: true,
|
||||
@@ -44,6 +43,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||
length: true,
|
||||
slug: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
users: {
|
||||
@@ -72,7 +72,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
|
||||
...obj.user,
|
||||
isMissingSeat: obj.user.plan === UserPlan.FREE,
|
||||
role: membership?.role,
|
||||
accepted: membership?.role === "OWNER" ? true : membership?.accepted,
|
||||
accepted: membership?.accepted,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export type BookingCreateBody = {
|
||||
name: string;
|
||||
notes?: string;
|
||||
rescheduleUid?: string;
|
||||
recurringEventId?: string;
|
||||
start: string;
|
||||
timeZone: string;
|
||||
user?: string | string[];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { compile } from "handlebars";
|
||||
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
@@ -24,13 +25,13 @@ function jsonParse(jsonString: string) {
|
||||
const sendPayload = async (
|
||||
triggerEvent: string,
|
||||
createdAt: string,
|
||||
subscriberUrl: string,
|
||||
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
|
||||
data: CalendarEvent & {
|
||||
metadata?: { [key: string]: string };
|
||||
rescheduleUid?: string;
|
||||
},
|
||||
template?: string | null
|
||||
}
|
||||
) => {
|
||||
const { subscriberUrl, appId, payloadTemplate: template } = webhook;
|
||||
if (!subscriberUrl || !data) {
|
||||
throw new Error("Missing required elements to send webhook payload.");
|
||||
}
|
||||
@@ -38,13 +39,22 @@ const sendPayload = async (
|
||||
const contentType =
|
||||
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
|
||||
|
||||
const body = template
|
||||
? applyTemplate(template, data, contentType)
|
||||
: JSON.stringify({
|
||||
triggerEvent: triggerEvent,
|
||||
createdAt: createdAt,
|
||||
payload: data,
|
||||
});
|
||||
data.description = data.description || data.additionalNotes;
|
||||
|
||||
let body;
|
||||
|
||||
/* Zapier id is hardcoded in the DB, we send the raw data for this case */
|
||||
if (appId === "zapier") {
|
||||
body = JSON.stringify(data);
|
||||
} else if (template) {
|
||||
body = applyTemplate(template, data, contentType);
|
||||
} else {
|
||||
body = JSON.stringify({
|
||||
triggerEvent: triggerEvent,
|
||||
createdAt: createdAt,
|
||||
payload: data,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(subscriberUrl, {
|
||||
method: "POST",
|
||||
|
||||
@@ -8,7 +8,7 @@ export type GetSubscriberOptions = {
|
||||
triggerEvent: WebhookTriggerEvents;
|
||||
};
|
||||
|
||||
const getSubscribers = async (options: GetSubscriberOptions) => {
|
||||
const getWebhooks = async (options: GetSubscriberOptions) => {
|
||||
const { userId, eventTypeId } = options;
|
||||
const allWebhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
@@ -32,10 +32,11 @@ const getSubscribers = async (options: GetSubscriberOptions) => {
|
||||
select: {
|
||||
subscriberUrl: true,
|
||||
payloadTemplate: true,
|
||||
appId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return allWebhooks;
|
||||
};
|
||||
|
||||
export default getSubscribers;
|
||||
export default getWebhooks;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
/** @type {import("next-i18next").UserConfig} */
|
||||
const config = {
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: [
|
||||
@@ -31,3 +32,5 @@ module.exports = {
|
||||
localePath: path.resolve("./public/static/locales"),
|
||||
reloadOnPrerender: process.env.NODE_ENV !== "production",
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -9,6 +9,7 @@ const withTM = require("next-transpile-modules")([
|
||||
"@calcom/stripe",
|
||||
"@calcom/ui",
|
||||
"@calcom/embed-core",
|
||||
"@calcom/embed-snippet",
|
||||
]);
|
||||
const { i18n } = require("./next-i18next.config");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "1.5.3",
|
||||
"version": "1.5.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -22,7 +22,7 @@
|
||||
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.x",
|
||||
"node": ">=14.x < 15",
|
||||
"yarn": ">=1.19.0 < 2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -35,6 +35,7 @@
|
||||
"@calcom/stripe": "*",
|
||||
"@calcom/tsconfig": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@calcom/embed-core": "*",
|
||||
"@daily-co/daily-js": "^0.21.0",
|
||||
"@glidejs/glide": "^3.5.2",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
@@ -104,6 +105,7 @@
|
||||
"react-use-intercom": "1.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.6",
|
||||
"rrule": "^2.6.9",
|
||||
"short-uuid": "^4.2.0",
|
||||
"stripe": "^8.191.0",
|
||||
"superjson": "1.8.1",
|
||||
|
||||
@@ -18,6 +18,7 @@ import defaultEvents, {
|
||||
getUsernameSlugLink,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
@@ -272,6 +273,7 @@ const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) =>
|
||||
description: true,
|
||||
hidden: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
@@ -84,6 +85,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
schedule: {
|
||||
select: {
|
||||
availability: true,
|
||||
@@ -256,6 +258,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
|
||||
});
|
||||
|
||||
const schedule = eventType.schedule
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
getUsernameList,
|
||||
} from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -69,6 +70,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const usernameList = getUsernameList(asStringOrThrow(context.query.user as string));
|
||||
const eventTypeSlug = context.query.slug as string;
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
@@ -111,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
periodDays: true,
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
recurringEvent: true,
|
||||
metadata: true,
|
||||
periodCountCalendarDays: true,
|
||||
price: true,
|
||||
@@ -150,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||
isWeb3Active:
|
||||
web3Credentials && web3Credentials.key
|
||||
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
|
||||
@@ -204,6 +208,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const t = await getTranslation(context.locale ?? "en", "common");
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventType.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
away: user.away,
|
||||
@@ -211,6 +224,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
profile,
|
||||
eventType: eventTypeObject,
|
||||
booking,
|
||||
recurringEventCount,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isDynamicGroupBooking,
|
||||
hasHashedBookingLink: false,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Prisma, User, Booking, SchedulingType, BookingStatus } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import rrule from "rrule";
|
||||
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { AdditionInformation } from "@calcom/types/Calendar";
|
||||
import type { AdditionInformation, RecurringEvent } from "@calcom/types/Calendar";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { refund } from "@ee/lib/stripe/server";
|
||||
|
||||
@@ -94,12 +95,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
confirmed: true,
|
||||
attendees: true,
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
},
|
||||
},
|
||||
location: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
payment: true,
|
||||
destinationCalendar: true,
|
||||
recurringEventId: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -147,6 +154,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
destinationCalendar: booking?.destinationCalendar || currentUser.destinationCalendar,
|
||||
};
|
||||
|
||||
const recurringEvent = booking.eventType?.recurringEvent as RecurringEvent;
|
||||
|
||||
if (req.body.recurringEventId && recurringEvent) {
|
||||
const groupedRecurringBookings = await prisma.booking.groupBy({
|
||||
where: {
|
||||
recurringEventId: booking.recurringEventId,
|
||||
},
|
||||
by: [Prisma.BookingScalarFieldEnum.recurringEventId],
|
||||
_count: true,
|
||||
});
|
||||
// Overriding the recurring event configuration count to be the actual number of events booked for
|
||||
// the recurring event (equal or less than recurring event configuration count)
|
||||
recurringEvent.count = groupedRecurringBookings[0]._count;
|
||||
}
|
||||
|
||||
if (reqBody.confirmed) {
|
||||
const eventManager = new EventManager(currentUser);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
@@ -170,43 +192,93 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
}
|
||||
try {
|
||||
await sendScheduledEmails({ ...evt, additionInformation: metadata });
|
||||
await sendScheduledEmails(
|
||||
{ ...evt, additionInformation: metadata },
|
||||
req.body.recurringEventId ? recurringEvent : {} // Send email with recurring event info only on recurring event context
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
|
||||
// Should perform update on booking (confirm) -> then trigger the rest handlers
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
confirmed: true,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
if (req.body.recurringEventId) {
|
||||
// The booking to confirm is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
||||
// bookings as confirmed. Prisma updateMany does not support relations, so doing this in two steps for now.
|
||||
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: req.body.recurringEventId,
|
||||
confirmed: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
unconfirmedRecurringBookings.map(async (recurringBooking) => {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: recurringBooking.id,
|
||||
},
|
||||
data: {
|
||||
confirmed: true,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// @NOTE: be careful with this as if any error occurs before this booking doesn't get confirmed
|
||||
// Should perform update on booking (confirm) -> then trigger the rest handlers
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
confirmed: true,
|
||||
references: {
|
||||
create: scheduleResult.referencesToCreate,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
} else {
|
||||
await refund(booking, evt);
|
||||
const rejectionReason = asStringOrNull(req.body.reason) || "";
|
||||
evt.rejectionReason = rejectionReason;
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
rejected: true,
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason: rejectionReason,
|
||||
},
|
||||
});
|
||||
if (req.body.recurringEventId) {
|
||||
// The booking to reject is a recurring event and comes from /booking/upcoming, proceeding to mark all related
|
||||
// bookings as rejected. Prisma updateMany does not support relations, so doing this in two steps for now.
|
||||
const unconfirmedRecurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: req.body.recurringEventId,
|
||||
confirmed: false,
|
||||
},
|
||||
});
|
||||
unconfirmedRecurringBookings.map(async (recurringBooking) => {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: recurringBooking.id,
|
||||
},
|
||||
data: {
|
||||
rejected: true,
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason: rejectionReason,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await refund(booking, evt); // No payment integration for recurring events for v1
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
rejected: true,
|
||||
status: BookingStatus.REJECTED,
|
||||
rejectionReason: rejectionReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await sendDeclinedEmails(evt);
|
||||
await sendDeclinedEmails(evt, req.body.recurringEventId ? recurringEvent : {}); // Send email with recurring event info only on recurring event context
|
||||
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
BookingStatus,
|
||||
Credential,
|
||||
Payment,
|
||||
Prisma,
|
||||
SchedulingType,
|
||||
WebhookTriggerEvents,
|
||||
} from "@prisma/client";
|
||||
import { BookingStatus, Credential, Prisma, SchedulingType, WebhookTriggerEvents } from "@prisma/client";
|
||||
import async from "async";
|
||||
import dayjs from "dayjs";
|
||||
import dayjsBusinessTime from "dayjs-business-time";
|
||||
@@ -13,18 +6,24 @@ import isBetween from "dayjs/plugin/isBetween";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import rrule from "rrule";
|
||||
import short from "short-uuid";
|
||||
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, getGroupName } from "@calcom/lib/defaultEvents";
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
|
||||
import type { AdditionInformation, CalendarEvent, EventBusyDate, Person } from "@calcom/types/Calendar";
|
||||
import type {
|
||||
AdditionInformation,
|
||||
CalendarEvent,
|
||||
EventBusyDate,
|
||||
RecurringEvent,
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
||||
import { handlePayment } from "@ee/lib/stripe/server";
|
||||
|
||||
@@ -83,7 +82,7 @@ async function refreshCredentials(credentials: Array<Credential>): Promise<Array
|
||||
return await async.mapLimit(credentials, 5, refreshCredential);
|
||||
}
|
||||
|
||||
function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number): boolean {
|
||||
function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, length: number): boolean {
|
||||
// Check for conflicts
|
||||
let t = true;
|
||||
|
||||
@@ -190,7 +189,7 @@ const getUserNameWithBookingCounts = async (eventTypeId: number, selectedUserNam
|
||||
};
|
||||
|
||||
const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
return await prisma.eventType.findUnique({
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
@@ -220,14 +219,22 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
metadata: true,
|
||||
destinationCalendar: true,
|
||||
hideCalendarNotes: true,
|
||||
recurringEvent: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...eventType,
|
||||
recurringEvent: (eventType.recurringEvent || undefined) as RecurringEvent,
|
||||
};
|
||||
};
|
||||
|
||||
type User = Prisma.UserGetPayload<typeof userSelect>;
|
||||
|
||||
type ExtendedBookingCreateBody = BookingCreateBody & { noEmail?: boolean; recurringCount?: number };
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const reqBody = req.body as BookingCreateBody;
|
||||
const { recurringCount, noEmail, ...reqBody } = req.body as ExtendedBookingCreateBody;
|
||||
|
||||
// handle dynamic user
|
||||
const dynamicUserList = Array.isArray(reqBody.user)
|
||||
@@ -382,6 +389,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}; // used for invitee emails
|
||||
}
|
||||
|
||||
if (reqBody.recurringEventId && eventType.recurringEvent) {
|
||||
// Overriding the recurring event configuration count to be the actual number of events booked for
|
||||
// the recurring event (equal or less than recurring event configuration count)
|
||||
eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount });
|
||||
}
|
||||
|
||||
// Initialize EventManager with credentials
|
||||
const rescheduleUid = reqBody.rescheduleUid;
|
||||
async function getOriginalRescheduledBooking(uid: string) {
|
||||
@@ -481,6 +494,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
if (reqBody.recurringEventId) {
|
||||
newBookingData.recurringEventId = reqBody.recurringEventId;
|
||||
}
|
||||
if (originalRescheduledBooking) {
|
||||
newBookingData["paid"] = originalRescheduledBooking.paid;
|
||||
newBookingData["fromReschedule"] = originalRescheduledBooking.uid;
|
||||
@@ -573,7 +589,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
let isAvailableToBeBooked = true;
|
||||
try {
|
||||
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
|
||||
if (eventType.recurringEvent) {
|
||||
const allBookingDates = new rrule({
|
||||
dtstart: new Date(reqBody.start),
|
||||
...eventType.recurringEvent,
|
||||
}).all();
|
||||
// Go through each date for the recurring event and check if each one's availability
|
||||
isAvailableToBeBooked = allBookingDates
|
||||
.map((aDate) => isAvailable(bufferedBusyTimes, aDate, eventType.length)) // <-- array of booleans
|
||||
.reduce((acc, value) => acc && value, true); // <-- checks boolean array applying "AND" to each value and the current one, starting in true
|
||||
} else {
|
||||
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
|
||||
}
|
||||
} catch {
|
||||
log.debug({
|
||||
message: "Unable set isAvailableToBeBooked. Using true. ",
|
||||
@@ -674,11 +701,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
}
|
||||
|
||||
await sendRescheduledEmails({
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes, // Resets back to the addtionalNote input and not the overriden value
|
||||
});
|
||||
if (noEmail !== true) {
|
||||
await sendRescheduledEmails(
|
||||
{
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes, // Resets back to the addtionalNote input and not the overriden value
|
||||
},
|
||||
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||
);
|
||||
}
|
||||
}
|
||||
// If it's not a reschedule, doesn't require confirmation and there's no price,
|
||||
// Create a booking
|
||||
@@ -708,17 +740,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
metadata.conferenceData = results[0].createdEvent?.conferenceData;
|
||||
metadata.entryPoints = results[0].createdEvent?.entryPoints;
|
||||
}
|
||||
await sendScheduledEmails({
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes,
|
||||
});
|
||||
if (noEmail !== true) {
|
||||
await sendScheduledEmails(
|
||||
{
|
||||
...evt,
|
||||
additionInformation: metadata,
|
||||
additionalNotes,
|
||||
},
|
||||
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType.requiresConfirmation && !rescheduleUid) {
|
||||
await sendOrganizerRequestEmail({ ...evt, additionalNotes });
|
||||
await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]);
|
||||
if (eventType.requiresConfirmation && !rescheduleUid && noEmail !== true) {
|
||||
await sendOrganizerRequestEmail(
|
||||
{ ...evt, additionalNotes },
|
||||
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||
);
|
||||
await sendAttendeeRequestEmail(
|
||||
{ ...evt, additionalNotes },
|
||||
attendeesList[0],
|
||||
reqBody.recurringEventId ? (eventType.recurringEvent as RecurringEvent) : {}
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof eventType.price === "number" && eventType.price > 0 && !originalRescheduledBooking?.paid) {
|
||||
@@ -753,17 +797,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
metadata: reqBody.metadata,
|
||||
});
|
||||
const promises = subscribers.map((sub) =>
|
||||
sendPayload(
|
||||
eventTrigger,
|
||||
new Date().toISOString(),
|
||||
sub.subscriberUrl,
|
||||
{
|
||||
...evt,
|
||||
rescheduleUid,
|
||||
metadata: reqBody.metadata,
|
||||
},
|
||||
sub.payloadTemplate
|
||||
).catch((e) => {
|
||||
sendPayload(eventTrigger, new Date().toISOString(), sub, {
|
||||
...evt,
|
||||
rescheduleUid,
|
||||
metadata: reqBody.metadata,
|
||||
}).catch((e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingStatus, User, Booking, Attendee, BookingReference } from "@prisma/client";
|
||||
import { BookingStatus, User, Booking, Attendee, BookingReference, EventType } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
@@ -6,7 +6,6 @@ import type { TFunction } from "next-i18next";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { getCalendar } from "@calcom/core/CalendarManager";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
|
||||
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
@@ -29,7 +28,7 @@ const rescheduleSchema = z.object({
|
||||
rescheduleReason: z.string().optional(),
|
||||
});
|
||||
|
||||
const findUserOwnerByUserId = async (userId: number) => {
|
||||
const findUserDataByUserId = async (userId: number) => {
|
||||
return await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
@@ -57,10 +56,10 @@ const handler = async (
|
||||
bookingId,
|
||||
rescheduleReason: cancellationReason,
|
||||
}: { bookingId: string; rescheduleReason: string; cancellationReason: string } = req.body;
|
||||
let userOwner: Awaited<ReturnType<typeof findUserOwnerByUserId>>;
|
||||
let userOwner: Awaited<ReturnType<typeof findUserDataByUserId>>;
|
||||
try {
|
||||
if (session?.user?.id) {
|
||||
userOwner = await findUserOwnerByUserId(session?.user.id);
|
||||
userOwner = await findUserDataByUserId(session?.user.id);
|
||||
} else {
|
||||
return res.status(501);
|
||||
}
|
||||
@@ -76,6 +75,10 @@ const handler = async (
|
||||
location: true,
|
||||
attendees: true,
|
||||
references: true,
|
||||
userId: true,
|
||||
dynamicEventSlugRef: true,
|
||||
dynamicGroupSlugRef: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
@@ -88,18 +91,22 @@ const handler = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingToReschedule && bookingToReschedule.eventTypeId && userOwner) {
|
||||
const event = await prisma.eventType.findFirst({
|
||||
select: {
|
||||
title: true,
|
||||
users: true,
|
||||
schedulingType: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
id: bookingToReschedule.eventTypeId,
|
||||
},
|
||||
});
|
||||
if (bookingToReschedule && userOwner) {
|
||||
let event: Partial<EventType> = {};
|
||||
if (bookingToReschedule.eventTypeId) {
|
||||
event = await prisma.eventType.findFirst({
|
||||
select: {
|
||||
title: true,
|
||||
users: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
id: bookingToReschedule.eventTypeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingToReschedule.id,
|
||||
@@ -136,7 +143,7 @@ const handler = async (
|
||||
const builder = new CalendarEventBuilder();
|
||||
builder.init({
|
||||
title: bookingToReschedule.title,
|
||||
type: event.title,
|
||||
type: event && event.title ? event.title : bookingToReschedule.title,
|
||||
startTime: bookingToReschedule.startTime.toISOString(),
|
||||
endTime: bookingToReschedule.endTime.toISOString(),
|
||||
attendees: usersToPeopleType(
|
||||
@@ -149,9 +156,13 @@ const handler = async (
|
||||
|
||||
const director = new CalendarEventDirector();
|
||||
director.setBuilder(builder);
|
||||
director.setExistingBooking(bookingToReschedule as unknown as Booking);
|
||||
director.setExistingBooking(bookingToReschedule);
|
||||
director.setCancellationReason(cancellationReason);
|
||||
await director.buildForRescheduleEmail();
|
||||
if (!!event) {
|
||||
await director.buildWithoutEventTypeForRescheduleEmail();
|
||||
} else {
|
||||
await director.buildForRescheduleEmail();
|
||||
}
|
||||
|
||||
// Handling calendar and videos cancellation
|
||||
// This can set previous time as available, until virtual calendar is done
|
||||
@@ -174,6 +185,31 @@ const handler = async (
|
||||
}
|
||||
});
|
||||
|
||||
// Updating attendee destinationCalendar if required
|
||||
if (
|
||||
bookingToReschedule.destinationCalendar &&
|
||||
bookingToReschedule.destinationCalendar.userId &&
|
||||
bookingToReschedule.destinationCalendar.integration.endsWith("_calendar")
|
||||
) {
|
||||
const { destinationCalendar } = bookingToReschedule;
|
||||
if (destinationCalendar.userId) {
|
||||
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
|
||||
(ref) => !!credentialsMap.get(ref.type)
|
||||
);
|
||||
const attendeeData = await findUserDataByUserId(destinationCalendar.userId);
|
||||
const attendeeCredentialsMap = new Map();
|
||||
attendeeData.credentials.forEach((credential) => {
|
||||
attendeeCredentialsMap.set(credential.type, credential);
|
||||
});
|
||||
bookingRefsFiltered.forEach((bookingRef) => {
|
||||
if (bookingRef.uid) {
|
||||
const calendar = getCalendar(attendeeCredentialsMap.get(destinationCalendar.integration));
|
||||
calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send emails
|
||||
await sendRequestRescheduleEmail(builder.calendarEvent, {
|
||||
rescheduleLink: builder.rescheduleLink,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getSession } from "@lib/auth";
|
||||
import { sendCancelledEmails } from "@lib/emails/email-manager";
|
||||
import prisma from "@lib/prisma";
|
||||
import sendPayload from "@lib/webhooks/sendPayload";
|
||||
import getSubscribers from "@lib/webhooks/subscriptions";
|
||||
import getWebhooks from "@lib/webhooks/subscriptions";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
@@ -136,13 +136,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
eventTypeId: (bookingToDelete.eventTypeId as number) || 0,
|
||||
triggerEvent: eventTrigger,
|
||||
};
|
||||
const subscribers = await getSubscribers(subscriberOptions);
|
||||
const promises = subscribers.map((sub) =>
|
||||
sendPayload(eventTrigger, new Date().toISOString(), sub.subscriberUrl, evt, sub.payloadTemplate).catch(
|
||||
(e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
|
||||
}
|
||||
)
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
const promises = webhooks.map((webhook) =>
|
||||
sendPayload(eventTrigger, new Date().toISOString(), webhook, evt).catch((e) => {
|
||||
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
@@ -10,8 +11,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
// Check that user is authenticated
|
||||
const session = await getSession({ req });
|
||||
const userId = session?.user?.id;
|
||||
|
||||
if (!session) {
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: "You must be logged in to do this" });
|
||||
return;
|
||||
}
|
||||
@@ -19,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
if (req.method === "GET") {
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: session.user?.id,
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
@@ -31,18 +33,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
if (req.method == "DELETE") {
|
||||
const id = req.body.id;
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
credentials: {
|
||||
delete: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
};
|
||||
const integration = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
/* If the user deletes a zapier integration, we delete all his api keys as well. */
|
||||
if (integration?.appId === "zapier") {
|
||||
data.apiKeys = {
|
||||
deleteMany: {
|
||||
userId,
|
||||
appId: "zapier",
|
||||
},
|
||||
};
|
||||
/* We also delete all user's zapier wehbooks */
|
||||
data.webhooks = {
|
||||
deleteMany: {
|
||||
userId,
|
||||
appId: "zapier",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: session?.user?.id,
|
||||
},
|
||||
data: {
|
||||
credentials: {
|
||||
delete: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
id: userId,
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "Integration deleted successfully" });
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { AppConfiguration, InstallAppButton } from "@calcom/app-store/components";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { App } from "@calcom/types/App";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
@@ -26,87 +26,6 @@ import IntegrationListItem from "@components/integrations/IntegrationListItem";
|
||||
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
|
||||
import WebhookListContainer from "@components/webhook/WebhookListContainer";
|
||||
|
||||
function IframeEmbedContainer() {
|
||||
const { t } = useLocale();
|
||||
// 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_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>`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShellSubHeading title={t("iframe_embed")} subtitle={t("embed_calcom")} className="mt-10" />
|
||||
<div className="lg:col-span-9 lg:pb-8">
|
||||
<List>
|
||||
<ListItem className={classNames("flex-col")}>
|
||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
|
||||
<Image width={40} height={40} src="/apps/embed.svg" alt="Embed" />
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3">{t("standard_iframe")}</ListItemTitle>
|
||||
<ListItemText component="p">{t("embed_your_calendar")}</ListItemText>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<input
|
||||
id="iframe"
|
||||
className="px-2 py-1 text-sm text-gray-500 "
|
||||
placeholder={t("loading")}
|
||||
defaultValue={iframeTemplate}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(iframeTemplate);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
<ClipboardIcon className="-mb-0.5 h-4 w-4 text-gray-800 ltr:mr-2 rtl:ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
<ListItem className={classNames("flex-col")}>
|
||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-3 rtl:space-x-reverse")}>
|
||||
<Image width={40} height={40} src="/apps/embed.svg" alt="Embed" />
|
||||
<div className="flex-grow truncate pl-2">
|
||||
<ListItemTitle component="h3">{t("responsive_fullscreen_iframe")}</ListItemTitle>
|
||||
<ListItemText component="p">A fullscreen scheduling experience on your website</ListItemText>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="fullscreen"
|
||||
className="px-2 py-1 text-sm text-gray-500 "
|
||||
placeholder={t("loading")}
|
||||
defaultValue={htmlTemplate}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(htmlTemplate);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
<ClipboardIcon className="-mb-0.5 h-4 w-4 text-gray-800 ltr:mr-2 rtl:ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
<div className="grid grid-cols-2 space-x-4 rtl:space-x-reverse">
|
||||
<div>
|
||||
<label htmlFor="iframe" className="block text-sm font-medium text-gray-700"></label>
|
||||
<div className="mt-1"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fullscreen" className="block text-sm font-medium text-gray-700"></label>
|
||||
<div className="mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectOrDisconnectIntegrationButton(props: {
|
||||
//
|
||||
credentialIds: number[];
|
||||
@@ -242,8 +161,9 @@ function IntegrationsContainer() {
|
||||
isGlobal={item.isGlobal}
|
||||
installed={item.installed}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}>
|
||||
<AppConfiguration type={item.type} credentialIds={item.credentialIds} />
|
||||
</IntegrationListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
@@ -342,7 +262,6 @@ export default function IntegrationsPage() {
|
||||
<IntegrationsContainer />
|
||||
<CalendarListContainer />
|
||||
<WebhookListContainer title={t("webhooks")} subtitle={t("receive_cal_meeting_data")} />
|
||||
<IframeEmbedContainer />
|
||||
<Web3Container />
|
||||
</ClientSuspense>
|
||||
</AppsShell>
|
||||
|
||||
38
apps/web/pages/apps/setup/[appName].tsx
Normal file
38
apps/web/pages/apps/setup/[appName].tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import _zapierMetadata from "@calcom/app-store/zapier/_metadata";
|
||||
import { ZapierSetup } from "@calcom/app-store/zapier/components";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
export default function SetupInformation() {
|
||||
const router = useRouter();
|
||||
const appName = router.query.appName;
|
||||
const { status } = useSession();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
router.replace({
|
||||
pathname: "/auth/login",
|
||||
query: {
|
||||
callbackUrl: `/apps/setup/${appName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (appName === _zapierMetadata.name.toLowerCase() && status === "authenticated") {
|
||||
return <ZapierSetup trpc={trpc}></ZapierSetup>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
|
||||
import { inferQueryInput, trpc } from "@lib/trpc";
|
||||
import { inferQueryInput, inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import BookingsShell from "@components/BookingsShell";
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
@@ -17,6 +17,8 @@ import BookingListItem from "@components/booking/BookingListItem";
|
||||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
|
||||
type BookingPage = inferQueryOutput<"viewer.bookings">;
|
||||
|
||||
export default function Bookings() {
|
||||
const router = useRouter();
|
||||
@@ -26,6 +28,7 @@ export default function Bookings() {
|
||||
|
||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
upcoming: t("upcoming_bookings"),
|
||||
recurring: t("recurring_bookings"),
|
||||
past: t("past_bookings"),
|
||||
cancelled: t("cancelled_bookings"),
|
||||
};
|
||||
@@ -44,6 +47,18 @@ export default function Bookings() {
|
||||
|
||||
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
||||
|
||||
// Get the recurrentCount value from the grouped recurring bookings
|
||||
// created with the same recurringEventId
|
||||
const defineRecurrentCount = (booking: BookingOutput, page: BookingPage) => {
|
||||
let recurringCount = undefined;
|
||||
if (booking.recurringEventId !== null) {
|
||||
recurringCount = page.groupedRecurringBookings.filter(
|
||||
(group) => group.recurringEventId === booking.recurringEventId
|
||||
)[0]._count; // If found, only one object exists, just assing the needed _count value
|
||||
}
|
||||
return { recurringCount };
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell
|
||||
heading={t("bookings")}
|
||||
@@ -66,7 +81,12 @@ export default function Bookings() {
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
{page.bookings.map((booking) => (
|
||||
<BookingListItem key={booking.id} {...booking} />
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
{...defineRecurrentCount(booking, page)}
|
||||
{...booking}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Prisma } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import { GetBookingType } from "@lib/getBooking";
|
||||
@@ -37,6 +39,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
recurringEvent: true,
|
||||
schedulingType: true,
|
||||
userId: true,
|
||||
schedule: {
|
||||
@@ -131,6 +134,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const [user] = users;
|
||||
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
|
||||
metadata: {} as JSONObject,
|
||||
recurringEvent: (eventTypeSelect.recurringEvent || {}) as RecurringEvent,
|
||||
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
|
||||
slug,
|
||||
|
||||
@@ -6,8 +6,9 @@ import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
@@ -28,6 +29,7 @@ export default function Book(props: HashLinkPageProps) {
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const link = asStringOrThrow(context.query.link as string);
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
const slug = context.query.slug as string;
|
||||
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
@@ -41,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
periodType: true,
|
||||
periodDays: true,
|
||||
periodStartDate: true,
|
||||
recurringEvent: true,
|
||||
periodEndDate: true,
|
||||
metadata: true,
|
||||
periodCountCalendarDays: true,
|
||||
@@ -122,6 +125,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
|
||||
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||
isWeb3Active:
|
||||
web3Credentials && web3Credentials.key
|
||||
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
|
||||
@@ -148,6 +152,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const t = await getTranslation(context.locale ?? "en", "common");
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventTypeObject?.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventTypeObject.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
locationLabels: getLocationLabels(t),
|
||||
@@ -155,6 +168,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
eventType: eventTypeObject,
|
||||
booking: null,
|
||||
trpcState: ssr.dehydrate(),
|
||||
recurringEventCount,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: true,
|
||||
hashedLink: link,
|
||||
|
||||
@@ -26,7 +26,9 @@ import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Controller, Noop, useForm, UseFormReturn } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import short, { generate } from "short-uuid";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SelectGifInput } from "@calcom/app-store/giphy/components";
|
||||
@@ -34,9 +36,11 @@ import getApps, { getLocationOptions } 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 { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import Switch from "@calcom/ui/Switch";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
@@ -52,11 +56,12 @@ import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { ClientSuspense } from "@components/ClientSuspense";
|
||||
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import Loader from "@components/Loader";
|
||||
import Shell from "@components/Shell";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import RecurringEventController from "@components/eventtype/RecurringEventController";
|
||||
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
|
||||
import Badge from "@components/ui/Badge";
|
||||
import InfoBadge from "@components/ui/InfoBadge";
|
||||
@@ -64,7 +69,7 @@ import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
import CheckedSelect from "@components/ui/form/CheckedSelect";
|
||||
import { DateRangePicker } from "@components/ui/form/DateRangePicker";
|
||||
import MinutesField from "@components/ui/form/MinutesField";
|
||||
import Select, { SelectProps } from "@components/ui/form/Select";
|
||||
import Select from "@components/ui/form/Select";
|
||||
import * as RadioArea from "@components/ui/form/radio-area";
|
||||
import WebhookListContainer from "@components/webhook/WebhookListContainer";
|
||||
|
||||
@@ -271,9 +276,21 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
PERIOD_TYPES.find((s) => s.type === eventType.periodType) ||
|
||||
PERIOD_TYPES.find((s) => s.type === "UNLIMITED");
|
||||
|
||||
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
||||
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
|
||||
|
||||
const [requirePayment, setRequirePayment] = useState(
|
||||
eventType.price > 0 && eventType.recurringEvent?.count !== undefined
|
||||
);
|
||||
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
|
||||
const generateHashedLink = (id: number) => {
|
||||
const translator = short();
|
||||
const seed = `${id}:${new Date().getTime()}`;
|
||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||
return uid;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTokens = async () => {
|
||||
@@ -308,6 +325,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
console.log(tokensList); // Just here to make sure it passes the gc hook. Can remove once actual use is made of tokensList.
|
||||
|
||||
fetchTokens();
|
||||
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0].id));
|
||||
}, []);
|
||||
|
||||
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
||||
@@ -454,9 +473,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
team ? `team/${team.slug}` : eventType.users[0].username
|
||||
}/${eventType.slug}`;
|
||||
|
||||
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${
|
||||
eventType.hashedLink ? eventType.hashedLink.link : "xxxxxxxxxxxxxxxxx"
|
||||
}/${eventType.slug}`;
|
||||
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
|
||||
const mapUserToValue = ({
|
||||
id,
|
||||
@@ -482,12 +499,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
description: string;
|
||||
disableGuests: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
recurringEvent: RecurringEvent;
|
||||
schedulingType: SchedulingType | null;
|
||||
price: number;
|
||||
currency: string;
|
||||
hidden: boolean;
|
||||
hideCalendarNotes: boolean;
|
||||
hashedLink: boolean;
|
||||
hashedLink: string | undefined;
|
||||
locations: { type: LocationType; address?: string; link?: string }[];
|
||||
customInputs: EventTypeCustomInput[];
|
||||
users: string[];
|
||||
@@ -509,6 +527,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
}>({
|
||||
defaultValues: {
|
||||
locations: eventType.locations || [],
|
||||
recurringEvent: eventType.recurringEvent || {},
|
||||
schedule: eventType.schedule?.id,
|
||||
periodDates: {
|
||||
startDate: periodDates.startDate,
|
||||
@@ -927,15 +946,15 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
giphyThankYouPage,
|
||||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
recurringEvent,
|
||||
locations,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
if (requirePayment) input.currency = currency;
|
||||
|
||||
updateMutation.mutate({
|
||||
...input,
|
||||
locations,
|
||||
recurringEvent,
|
||||
periodStartDate: periodDates.startDate,
|
||||
periodEndDate: periodDates.endDate,
|
||||
periodCountCalendarDays: periodCountCalendarDays === "1",
|
||||
@@ -1333,6 +1352,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<RecurringEventController
|
||||
recurringEvent={eventType.recurringEvent}
|
||||
formMethods={formMethods}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="disableGuests"
|
||||
control={formMethods.control}
|
||||
@@ -1354,27 +1378,31 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
<Controller
|
||||
name="hashedLink"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.hashedLink ? true : false}
|
||||
defaultValue={hashedUrl}
|
||||
render={() => (
|
||||
<>
|
||||
<CheckboxField
|
||||
id="hashedLink"
|
||||
name="hashedLink"
|
||||
label={t("hashed_link")}
|
||||
description={t("hashed_link_description")}
|
||||
id="hashedLinkCheck"
|
||||
name="hashedLinkCheck"
|
||||
label={t("private_link")}
|
||||
description={t("private_link_description")}
|
||||
defaultChecked={eventType.hashedLink ? true : false}
|
||||
onChange={(e) => {
|
||||
setHashedLinkVisible(e?.target.checked);
|
||||
formMethods.setValue("hashedLink", e?.target.checked);
|
||||
formMethods.setValue(
|
||||
"hashedLink",
|
||||
e?.target.checked ? hashedUrl : undefined
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{hashedLinkVisible && (
|
||||
<div className="block items-center sm:flex">
|
||||
<div className="!mt-1 block items-center sm:flex">
|
||||
<div className="min-w-48 mb-4 sm:mb-0"></div>
|
||||
<div className="w-full">
|
||||
<div className="relative mt-1 flex w-full">
|
||||
<input
|
||||
disabled
|
||||
name="hashedLink"
|
||||
data-testid="generated-hash-url"
|
||||
type="text"
|
||||
className=" grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm"
|
||||
@@ -1389,9 +1417,11 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
<Button
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (eventType.hashedLink) {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
showToast("Link copied!", "success");
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
@@ -1640,7 +1670,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
<SuccessRedirectEdit<typeof formMethods>
|
||||
formMethods={formMethods}
|
||||
eventType={eventType}></SuccessRedirectEdit>
|
||||
{hasPaymentIntegration && (
|
||||
{hasPaymentIntegration && eventType.recurringEvent?.count !== undefined && (
|
||||
<>
|
||||
<hr className="border-neutral-200" />
|
||||
<div className="block sm:flex">
|
||||
@@ -1822,6 +1852,26 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy_link")}
|
||||
</button>
|
||||
{hashedLinkVisible && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (eventType.hashedLink) {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900">
|
||||
<LinkIcon className="h-4 w-4 text-neutral-500 ltr:mr-2 rtl:ml-2" />
|
||||
{t("copy_private_link")}
|
||||
</button>
|
||||
)}
|
||||
<EmbedButton
|
||||
className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-200 hover:text-gray-900"
|
||||
eventTypeId={eventType.id}
|
||||
/>
|
||||
<Dialog>
|
||||
<DialogTrigger className="text-md flex items-center rounded-sm px-2 py-1 text-sm font-medium text-red-500 hover:bg-gray-200">
|
||||
<TrashIcon className="h-4 w-4 text-red-500 ltr:mr-2 rtl:ml-2" />
|
||||
@@ -1870,28 +1920,30 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
addLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
}}>
|
||||
<Controller
|
||||
name="locationType"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<Select
|
||||
maxMenuHeight={100}
|
||||
name="location"
|
||||
defaultValue={selectedLocation}
|
||||
options={locationOptions}
|
||||
isSearchable={false}
|
||||
className=" my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
locationFormMethods.unregister("locationLink");
|
||||
locationFormMethods.unregister("locationAddress");
|
||||
setSelectedLocation(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Controller
|
||||
name="locationType"
|
||||
control={locationFormMethods.control}
|
||||
render={() => (
|
||||
<Select
|
||||
maxMenuHeight={100}
|
||||
name="location"
|
||||
defaultValue={selectedLocation}
|
||||
options={locationOptions}
|
||||
isSearchable={false}
|
||||
className=" my-4 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
locationFormMethods.unregister("locationLink");
|
||||
locationFormMethods.unregister("locationAddress");
|
||||
setSelectedLocation(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<LocationOptions />
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button onClick={() => setShowLocationModal(false)} type="button" color="secondary">
|
||||
@@ -1969,6 +2021,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
/>
|
||||
)}
|
||||
</ClientSuspense>
|
||||
<EmbedDialog />
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
@@ -2048,6 +2101,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
periodEndDate: true,
|
||||
periodCountCalendarDays: true,
|
||||
requiresConfirmation: true,
|
||||
recurringEvent: true,
|
||||
hideCalendarNotes: true,
|
||||
disableGuests: true,
|
||||
minimumBookingNotice: true,
|
||||
@@ -2112,6 +2166,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { locations, metadata, ...restEventType } = rawEventType;
|
||||
const eventType = {
|
||||
...restEventType,
|
||||
recurringEvent: (restEventType.recurringEvent || {}) as RecurringEvent,
|
||||
locations: locations as unknown as Location[],
|
||||
metadata: (metadata || {}) as JSONObject,
|
||||
isWeb3Active:
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
ClipboardCopyIcon,
|
||||
TrashIcon,
|
||||
PencilIcon,
|
||||
CodeIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { UsersIcon } from "@heroicons/react/solid";
|
||||
import { Trans } from "next-i18next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
import React, { Fragment, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -30,15 +31,16 @@ import Dropdown, {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import classNames from "@lib/classNames";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Shell from "@components/Shell";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||
import EventTypeDescription from "@components/eventtype/EventTypeDescription";
|
||||
@@ -299,6 +301,12 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
{t("duplicate")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<EmbedButton
|
||||
dark
|
||||
className="w-full rounded-none"
|
||||
eventTypeId={type.id}></EmbedButton>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
@@ -519,9 +527,9 @@ const CTA = () => {
|
||||
};
|
||||
|
||||
const WithQuery = withQuery(["viewer.eventTypes"]);
|
||||
|
||||
const EventTypesPage = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
@@ -574,6 +582,7 @@ const EventTypesPage = () => {
|
||||
{data.eventTypeGroups.length === 0 && (
|
||||
<CreateFirstEventTypeView profiles={data.profiles} canAddEvents={data.viewer.canAddEvents} />
|
||||
)}
|
||||
<EmbedDialog></EmbedDialog>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useIntercom } from "react-use-intercom";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell, { useMeQuery } from "@components/Shell";
|
||||
import Shell from "@components/Shell";
|
||||
|
||||
type CardProps = { title: string; description: string; className?: string; children: ReactNode };
|
||||
const Card = ({ title, description, className = "", children }: CardProps): JSX.Element => (
|
||||
|
||||
@@ -145,6 +145,7 @@ export function TeamSettingsPage() {
|
||||
<MemberInvitationModal
|
||||
isOpen={showMemberInvitationModal}
|
||||
team={team}
|
||||
currentMember={team.membership.role}
|
||||
onExit={() => setShowMemberInvitationModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,12 +8,13 @@ import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import EmptyScreen from "@components/EmptyScreen";
|
||||
import Loader from "@components/Loader";
|
||||
import SettingsShell from "@components/SettingsShell";
|
||||
import Shell, { useMeQuery } from "@components/Shell";
|
||||
import Shell from "@components/Shell";
|
||||
import TeamCreateModal from "@components/team/TeamCreateModal";
|
||||
import TeamList from "@components/team/TeamList";
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CheckIcon } from "@heroicons/react/outline";
|
||||
import { ArrowLeftIcon, ClockIcon, XIcon } from "@heroicons/react/solid";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
@@ -11,6 +13,7 @@ import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import RRule from "rrule";
|
||||
|
||||
import { SpaceBookingSuccessPage } from "@calcom/app-store/spacebooking/components";
|
||||
import {
|
||||
@@ -21,6 +24,8 @@ import {
|
||||
} from "@calcom/embed-core";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { EmailInput } from "@calcom/ui/form/fields";
|
||||
|
||||
@@ -41,6 +46,7 @@ import { ssrInit } from "@server/lib/ssr";
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(toArray);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
function redirectToExternalUrl(url: string) {
|
||||
window.parent.location.href = url;
|
||||
@@ -133,7 +139,9 @@ function RedirectionToast({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Success(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
type SuccessProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Success(props: SuccessProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { location: _location, name, reschedule } = router.query;
|
||||
@@ -143,7 +151,7 @@ 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;
|
||||
const { eventType, bookingInfo } = props;
|
||||
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
const isEmbed = useIsEmbed();
|
||||
@@ -212,7 +220,23 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||
|
||||
return encodeURIComponent(event.value ? event.value : false);
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
const titleSuffix = props.recurringBookings ? "_recurring" : "";
|
||||
if (needsConfirmation) {
|
||||
if (props.profile.name !== null) {
|
||||
return t("user_needs_to_confirm_or_reject_booking" + titleSuffix, {
|
||||
user: props.profile.name,
|
||||
});
|
||||
}
|
||||
return t("needs_to_be_confirmed_or_rejected" + titleSuffix);
|
||||
}
|
||||
return t("emailed_you_and_attendees" + titleSuffix);
|
||||
}
|
||||
const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id)));
|
||||
const title = t(
|
||||
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
|
||||
);
|
||||
return (
|
||||
(isReady && (
|
||||
<>
|
||||
@@ -220,10 +244,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||
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")}
|
||||
/>
|
||||
<HeadSeo title={title} description={title} />
|
||||
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
|
||||
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
|
||||
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
|
||||
@@ -263,30 +284,55 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||
<h3
|
||||
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
|
||||
id="modal-headline">
|
||||
{needsConfirmation ? t("submitted") : t("meeting_is_scheduled")}
|
||||
{needsConfirmation
|
||||
? props.recurringBookings
|
||||
? t("submitted_recurring")
|
||||
: t("submitted")
|
||||
: props.recurringBookings
|
||||
? t("meeting_is_scheduled_recurring")
|
||||
: t("meeting_is_scheduled")}
|
||||
</h3>
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{needsConfirmation
|
||||
? props.profile.name !== null
|
||||
? t("user_needs_to_confirm_or_reject_booking", { user: props.profile.name })
|
||||
: t("needs_to_be_confirmed_or_rejected")
|
||||
: t("emailed_you_and_attendees")}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-600 dark:text-gray-300">{getTitle()}</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="col-span-2">
|
||||
{date.format("dddd, DD MMMM YYYY")}
|
||||
<div className="col-span-2 mb-6">
|
||||
{date.format("MMMM DD, YYYY")}
|
||||
<br />
|
||||
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
|
||||
{date.format("LT")} - {date.add(props.eventType.length, "m").format("LT")}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<RecurringBookings
|
||||
isReschedule={reschedule === "true"}
|
||||
eventType={props.eventType}
|
||||
recurringBookings={props.recurringBookings}
|
||||
date={date}
|
||||
is24h={is24h}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-medium">{t("who")}</div>
|
||||
<div className="col-span-2">
|
||||
{bookingInfo?.user && (
|
||||
<div className="mb-3">
|
||||
<p>{bookingInfo.user.name}</p>
|
||||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee, index) => (
|
||||
<div
|
||||
key={attendee.name}
|
||||
className={index === bookingInfo.attendees.length - 1 ? "" : "mb-3"}>
|
||||
<p>{attendee.name}</p>
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{location && (
|
||||
<>
|
||||
<div className="mt-6 font-medium">{t("where")}</div>
|
||||
@@ -301,6 +347,14 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{bookingInfo?.description && (
|
||||
<>
|
||||
<div className="mt-6 font-medium">{t("additional_notes")}</div>
|
||||
<div className="col-span-2 mt-6 mb-6">
|
||||
<p>{bookingInfo.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,6 +376,10 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||
}` +
|
||||
(typeof location === "string"
|
||||
? "&location=" + encodeURIComponent(location)
|
||||
: "") +
|
||||
(props.eventType.recurringEvent
|
||||
? "&recur=" +
|
||||
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
|
||||
: "")
|
||||
}>
|
||||
<a className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
|
||||
@@ -447,21 +505,15 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||
{props.userHasSpaceBooking && (
|
||||
<SpaceBookingSuccessPage
|
||||
open={props.userHasSpaceBooking}
|
||||
what={`
|
||||
what={`
|
||||
${needsConfirmation ? t("submitted") : `${t("meeting_is_scheduled")}.`}
|
||||
${
|
||||
needsConfirmation
|
||||
? props.profile.name !== null
|
||||
? t("user_needs_to_confirm_or_reject_booking", { user: props.profile.name })
|
||||
: t("needs_to_be_confirmed_or_rejected")
|
||||
: t("emailed_you_and_attendees")
|
||||
} ${t("what")}: ${eventName}`}
|
||||
${getTitle()} ${t("what")}: ${eventName}`}
|
||||
where={`${t("where")}: ${
|
||||
location ? (location?.startsWith("http") ? { location } : location) : "Far far a way galaxy"
|
||||
}`}
|
||||
when={`${t("when")}: ${date.format("dddd, DD MMMM YYYY")} ${date.format(
|
||||
is24h ? "H:mm" : "h:mma"
|
||||
)} - ${props.eventType.length} mins (${
|
||||
when={`${t("when")}: ${props.recurringBookings ? t("starting") : ""} ${date.format(
|
||||
"dddd, DD MMMM YYYY"
|
||||
)} ${date.format(is24h ? "H:mm" : "h:mma")} - ${props.eventType.length} mins (${
|
||||
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
||||
})`}
|
||||
/>
|
||||
@@ -472,6 +524,71 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
|
||||
);
|
||||
}
|
||||
|
||||
type RecurringBookingsProps = {
|
||||
isReschedule: boolean;
|
||||
eventType: SuccessProps["eventType"];
|
||||
recurringBookings: SuccessProps["recurringBookings"];
|
||||
date: dayjs.Dayjs;
|
||||
is24h: boolean;
|
||||
};
|
||||
|
||||
function RecurringBookings({
|
||||
isReschedule = false,
|
||||
eventType,
|
||||
recurringBookings,
|
||||
date,
|
||||
is24h,
|
||||
}: RecurringBookingsProps) {
|
||||
const [moreEventsVisible, setMoreEventsVisible] = useState(false);
|
||||
const { t } = useLocale();
|
||||
return !isReschedule && recurringBookings ? (
|
||||
<>
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookings.slice(0, 4).map((dateStr, idx) => (
|
||||
<div key={idx} className="mb-2">
|
||||
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
|
||||
<br />
|
||||
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{recurringBookings.length > 4 && (
|
||||
<Collapsible open={moreEventsVisible} onOpenChange={() => setMoreEventsVisible(!moreEventsVisible)}>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className={classNames("flex w-full", moreEventsVisible ? "hidden" : "")}>
|
||||
{t("plus_more", { count: recurringBookings.length - 4 })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookings.slice(4).map((dateStr, idx) => (
|
||||
<div key={idx} className="mb-2">
|
||||
{dayjs(dateStr).format("dddd, DD MMMM YYYY")}
|
||||
<br />
|
||||
{dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</>
|
||||
) : !eventType.recurringEvent.freq ? (
|
||||
<>
|
||||
{date.format("dddd, DD MMMM YYYY")}
|
||||
<br />
|
||||
{date.format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "}
|
||||
<span className="text-bookinglight">
|
||||
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||
</span>
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const getEventTypesFromDB = async (typeId: number) => {
|
||||
return await prisma.eventType.findUnique({
|
||||
where: {
|
||||
@@ -483,6 +600,7 @@ const getEventTypesFromDB = async (typeId: number) => {
|
||||
description: true,
|
||||
length: true,
|
||||
eventName: true,
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
userId: true,
|
||||
successRedirectUrl: true,
|
||||
@@ -513,8 +631,10 @@ const getEventTypesFromDB = async (typeId: number) => {
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const ssr = await ssrInit(context);
|
||||
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
|
||||
const recurringEventIdQuery = asStringOrNull(context.query.recur);
|
||||
const typeSlug = asStringOrNull(context.query.eventSlug) ?? "15min";
|
||||
const dynamicEventName = asStringOrNull(context.query.eventName) ?? "";
|
||||
const bookingId = parseInt(context.query.bookingId as string);
|
||||
|
||||
if (isNaN(typeId)) {
|
||||
return {
|
||||
@@ -522,9 +642,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
const eventType = !typeId ? getDefaultEvent(typeSlug) : await getEventTypesFromDB(typeId);
|
||||
let eventTypeRaw = !typeId ? getDefaultEvent(typeSlug) : await getEventTypesFromDB(typeId);
|
||||
|
||||
if (!eventType) {
|
||||
if (!eventTypeRaw) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
@@ -532,11 +652,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
let spaceBookingAvailable = false;
|
||||
|
||||
let userHasSpaceBooking = false;
|
||||
if (eventType.users[0] && eventType.users[0].id) {
|
||||
if (eventTypeRaw.users[0] && eventTypeRaw.users[0].id) {
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: "spacebooking_other",
|
||||
userId: eventType.users[0].id,
|
||||
userId: eventTypeRaw.users[0].id,
|
||||
},
|
||||
});
|
||||
if (credential && credential.type === "spacebooking_other") {
|
||||
@@ -544,11 +664,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!eventType.users.length && eventType.userId) {
|
||||
if (!eventTypeRaw.users.length && eventTypeRaw.userId) {
|
||||
// TODO we should add `user User` relation on `EventType` so this extra query isn't needed
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: eventType.userId,
|
||||
id: eventTypeRaw.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -563,17 +683,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
eventType.users.push(user);
|
||||
eventTypeRaw.users.push(user as any);
|
||||
}
|
||||
}
|
||||
|
||||
if (!eventType.users.length) {
|
||||
if (!eventTypeRaw.users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
// if (!typeId) eventType["eventName"] = getDynamicEventName(users, typeSlug);
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||
};
|
||||
|
||||
const profile = {
|
||||
name: eventType.team?.name || eventType.users[0]?.name || null,
|
||||
@@ -583,14 +706,49 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
|
||||
};
|
||||
|
||||
const bookingInfo = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
select: {
|
||||
description: true,
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
let recurringBookings = null;
|
||||
if (recurringEventIdQuery) {
|
||||
// We need to get the dates for the bookings to be able to show them in the UI
|
||||
recurringBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: recurringEventIdQuery,
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
hideBranding: eventType.team ? eventType.team.hideBranding : isBrandingHidden(eventType.users[0]),
|
||||
profile,
|
||||
eventType,
|
||||
recurringBookings: recurringBookings ? recurringBookings.map((obj) => obj.startTime.toString()) : null,
|
||||
trpcState: ssr.dehydrate(),
|
||||
dynamicEventName,
|
||||
userHasSpaceBooking,
|
||||
bookingInfo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import Button from "@calcom/ui/Button";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
@@ -13,7 +14,6 @@ import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import { defaultAvatarSrc } from "@lib/profile";
|
||||
import { getTeamWithMembers } from "@lib/queries/teams";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -68,7 +68,7 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
size={10}
|
||||
items={type.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
image: user.avatar || "",
|
||||
image: WEBSITE_URL + "/" + user.username + "/avatar.png" || "",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
<div>
|
||||
<Theme />
|
||||
<HeadSeo title={teamName} description={teamName} />
|
||||
<div className="rounded-md bg-white px-4 pt-24 pb-12 dark:bg-gray-800 md:border">
|
||||
<div className="rounded-md bg-white px-4 pt-24 pb-12 dark:bg-gray-900">
|
||||
<div className="max-w-96 mx-auto mb-8 text-center">
|
||||
<Avatar
|
||||
alt={teamName}
|
||||
@@ -104,7 +104,6 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
{!showMembers.isOn && team.eventTypes.length > 0 && (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{eventTypes}
|
||||
|
||||
<div className="relative mt-12">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200 dark:border-gray-700" />
|
||||
@@ -148,7 +147,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
...type,
|
||||
users: type.users.map((user) => ({
|
||||
...user,
|
||||
avatar: user.avatar || defaultAvatarSrc({ email: user.email || "" }),
|
||||
avatar: WEBSITE_URL + "/" + user.username + "/avatar.png",
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { UserPlan } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getWorkingHours } from "@lib/availability";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
@@ -68,6 +70,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
minimumBookingNotice: true,
|
||||
beforeEventBuffer: true,
|
||||
afterEventBuffer: true,
|
||||
recurringEvent: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
timeZone: true,
|
||||
@@ -107,6 +110,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
metadata: (eventType.metadata || {}) as JSONObject,
|
||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
|
||||
});
|
||||
|
||||
eventTypeObject.availability = [];
|
||||
|
||||
@@ -3,8 +3,9 @@ import { GetServerSidePropsContext } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import { getLocationLabels } from "@calcom/app-store/utils";
|
||||
import { RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import { asStringOrThrow, asStringOrNull } from "@lib/asStringOrNull";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
import prisma from "@lib/prisma";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -21,13 +22,14 @@ export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||
const recurringEventCountQuery = asStringOrNull(context.query.count);
|
||||
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
const eventTypeRaw = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
@@ -44,6 +46,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodCountCalendarDays: true,
|
||||
recurringEvent: true,
|
||||
disableGuests: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
@@ -65,7 +68,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) return { notFound: true };
|
||||
if (!eventTypeRaw) return { notFound: true };
|
||||
|
||||
const eventType = {
|
||||
...eventTypeRaw,
|
||||
recurringEvent: (eventTypeRaw.recurringEvent || {}) as RecurringEvent,
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
return {
|
||||
@@ -83,6 +91,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const t = await getTranslation(context.locale ?? "en", "common");
|
||||
|
||||
// Checking if number of recurring event ocurrances is valid against event type configuration
|
||||
const recurringEventCount =
|
||||
(eventType.recurringEvent?.count &&
|
||||
recurringEventCountQuery &&
|
||||
(parseInt(recurringEventCountQuery) <= eventType.recurringEvent.count
|
||||
? recurringEventCountQuery
|
||||
: eventType.recurringEvent.count)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
props: {
|
||||
locationLabels: getLocationLabels(t),
|
||||
@@ -96,6 +113,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
eventName: null,
|
||||
},
|
||||
eventType: eventTypeObject,
|
||||
recurringEventCount,
|
||||
booking,
|
||||
isDynamicGroupBooking: false,
|
||||
hasHashedBookingLink: false,
|
||||
|
||||
@@ -4,16 +4,14 @@ test.describe("App Store - Authed", () => {
|
||||
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
|
||||
test("Browse apple-calendar and try to install", async ({ page }) => {
|
||||
await page.goto("/apps");
|
||||
await page.click('[data-testid="app-store-category-calendar"]');
|
||||
if (!page.url().includes("apps/categories/calendar")) {
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
console.log(url, url.pathname);
|
||||
return url.pathname.includes("apps/categories/calendar");
|
||||
},
|
||||
});
|
||||
}
|
||||
await page.click('[data-testid="app-store-app-card-apple-calendar"]');
|
||||
page.click('[data-testid="app-store-category-calendar"]');
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
console.log(url, url.pathname);
|
||||
return url.pathname.includes("apps/categories/calendar");
|
||||
},
|
||||
});
|
||||
page.click('[data-testid="app-store-app-card-apple-calendar"]');
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return url.pathname.includes("apps/apple-calendar");
|
||||
|
||||
188
apps/web/playwright/embed-code-generator.test.ts
Normal file
188
apps/web/playwright/embed-code-generator.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
|
||||
function chooseEmbedType(page: Page, embedType: string) {
|
||||
page.locator(`[data-testid=${embedType}]`).click();
|
||||
}
|
||||
|
||||
async function gotToPreviewTab(page: Page) {
|
||||
await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click();
|
||||
}
|
||||
|
||||
async function clickEmbedButton(page: Page) {
|
||||
const embedButton = page.locator("[data-testid=event-type-embed]");
|
||||
const eventTypeId = await embedButton.getAttribute("data-test-eventtype-id");
|
||||
embedButton.click();
|
||||
return eventTypeId;
|
||||
}
|
||||
|
||||
async function clickFirstEventTypeEmbedButton(page: Page) {
|
||||
const menu = page.locator("[data-testid*=event-type-options]").first();
|
||||
await menu.click();
|
||||
const eventTypeId = await clickEmbedButton(page);
|
||||
return eventTypeId;
|
||||
}
|
||||
|
||||
async function expectToBeNavigatingToEmbedTypesDialog(
|
||||
page: Page,
|
||||
{ eventTypeId, basePage }: { eventTypeId: string | null; basePage: string }
|
||||
) {
|
||||
if (!eventTypeId) {
|
||||
throw new Error("Couldn't find eventTypeId");
|
||||
}
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return (
|
||||
url.pathname === basePage &&
|
||||
url.searchParams.get("dialog") === "embed" &&
|
||||
url.searchParams.get("eventTypeId") === eventTypeId
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectToBeNavigatingToEmbedCodeAndPreviewDialog(
|
||||
page: Page,
|
||||
{ eventTypeId, embedType, basePage }: { eventTypeId: string | null; embedType: string; basePage: string }
|
||||
) {
|
||||
if (!eventTypeId) {
|
||||
throw new Error("Couldn't find eventTypeId");
|
||||
}
|
||||
await page.waitForNavigation({
|
||||
url: (url) => {
|
||||
return (
|
||||
url.pathname === basePage &&
|
||||
url.searchParams.get("dialog") === "embed" &&
|
||||
url.searchParams.get("eventTypeId") === eventTypeId &&
|
||||
url.searchParams.get("embedType") === embedType &&
|
||||
url.searchParams.get("tabName") === "embed-code"
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectToContainValidCode(page: Page, { embedType }: { embedType: string }) {
|
||||
const embedCode = await page.locator("[data-testid=embed-code]").inputValue();
|
||||
expect(embedCode.includes("(function (C, A, L)")).toBe(true);
|
||||
expect(embedCode.includes(`Cal ${embedType} embed code begins`)).toBe(true);
|
||||
return {
|
||||
message: () => `passed`,
|
||||
pass: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectToContainValidPreviewIframe(
|
||||
page: Page,
|
||||
{ embedType, calLink }: { embedType: string; calLink: string }
|
||||
) {
|
||||
expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain(
|
||||
`/preview.html?embedType=${embedType}&calLink=${calLink}`
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Embed Code Generator Tests", () => {
|
||||
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
|
||||
|
||||
test.describe("Event Types Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/event-types");
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose Inline for First Event Type", async ({ page }) => {
|
||||
const eventTypeId = await clickFirstEventTypeEmbedButton(page);
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
eventTypeId,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
eventTypeId,
|
||||
embedType: "inline",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
await expectToContainValidCode(page, { embedType: "inline" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
|
||||
await expectToContainValidPreviewIframe(page, { embedType: "inline", calLink: "pro/30min" });
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page }) => {
|
||||
const eventTypeId = await clickFirstEventTypeEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
eventTypeId,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "floating-popup");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
eventTypeId,
|
||||
embedType: "floating-popup",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
await expectToContainValidCode(page, { embedType: "floating-popup" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, { embedType: "floating-popup", calLink: "pro/30min" });
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose element-click for First Event Type", async ({ page }) => {
|
||||
const eventTypeId = await clickFirstEventTypeEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
eventTypeId,
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "element-click");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
eventTypeId,
|
||||
embedType: "element-click",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
await expectToContainValidCode(page, { embedType: "element-click" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, { embedType: "element-click", calLink: "pro/30min" });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Event Type Edit Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/event-types/3");
|
||||
});
|
||||
|
||||
test("open Embed Dialog for the Event Type", async ({ page }) => {
|
||||
const eventTypeId = await clickEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
eventTypeId,
|
||||
basePage: "/event-types/3",
|
||||
});
|
||||
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
eventTypeId,
|
||||
basePage: "/event-types/3",
|
||||
embedType: "inline",
|
||||
});
|
||||
|
||||
await expectToContainValidCode(page, {
|
||||
embedType: "inline",
|
||||
});
|
||||
|
||||
gotToPreviewTab(page);
|
||||
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
calLink: "pro/30min",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,45 @@ test.describe("Event Types tests", () => {
|
||||
isCreated = await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("enabling recurring event comes with default options", async ({ page }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const nonce = randomString(3);
|
||||
eventTitle = `my recurring event ${nonce}`;
|
||||
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
await page.fill("[name=length]", "15");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
await page.waitForNavigation({
|
||||
url(url) {
|
||||
return url.pathname !== "/event-types";
|
||||
},
|
||||
});
|
||||
|
||||
await page.click("[data-testid=show-advanced-settings]");
|
||||
await expect(await page.locator("[data-testid=recurring-event-collapsible] > *")).not.toBeVisible();
|
||||
await page.click("[data-testid=recurring-event-check]");
|
||||
isCreated = await expect(
|
||||
await page.locator("[data-testid=recurring-event-collapsible] > *")
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
await page
|
||||
.locator("[data-testid=recurring-event-collapsible] input[type=number]")
|
||||
.nth(0)
|
||||
.getAttribute("value")
|
||||
).toBe("1");
|
||||
await expect(
|
||||
await page.locator("[data-testid=recurring-event-collapsible] div[class$=singleValue]").textContent()
|
||||
).toBe("week");
|
||||
await expect(
|
||||
await page
|
||||
.locator("[data-testid=recurring-event-collapsible] input[type=number]")
|
||||
.nth(1)
|
||||
.getAttribute("value")
|
||||
).toBe("12");
|
||||
});
|
||||
|
||||
test("can duplicate an existing event type", async ({ page }) => {
|
||||
const firstTitle = await page.locator("[data-testid=event-type-title-3]").innerText();
|
||||
const firstFullSlug = await page.locator("[data-testid=event-type-slug-3]").innerText();
|
||||
|
||||
@@ -28,25 +28,19 @@ test.describe("hash my url", () => {
|
||||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@id="hashedLink"]');
|
||||
await page.click('//*[@id="hashedLink"]');
|
||||
await page.waitForSelector('//*[@id="hashedLinkCheck"]');
|
||||
// ignore if it is already checked, and click if unchecked
|
||||
const isChecked = await page.isChecked('//*[@id="hashedLinkCheck"]');
|
||||
!isChecked && (await page.click('//*[@id="hashedLinkCheck"]'));
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
|
||||
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
// click update
|
||||
await page.focus('//button[@type="submit"]');
|
||||
await page.keyboard.press("Enter");
|
||||
});
|
||||
|
||||
test("book using generated url hash", async ({ page }) => {
|
||||
// await page.pause();
|
||||
await page.goto("/event-types");
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await page.click('//ul[@data-testid="event-types"]/li[1]');
|
||||
// We wait for the page to load
|
||||
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
|
||||
await page.click('//*[@data-testid="show-advanced-settings"]');
|
||||
// we wait for the hashedLink setting to load
|
||||
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
|
||||
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
await page.goto($url);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"event_declined_subject": "Declined: {{eventType}} with {{name}} at {{date}}",
|
||||
"event_cancelled_subject": "Cancelled: {{eventType}} with {{name}} at {{date}}",
|
||||
"event_request_declined": "Your event request has been declined",
|
||||
"event_request_declined_recurring": "Your recurring event request has been declined",
|
||||
"event_request_cancelled": "Your scheduled event was cancelled",
|
||||
"organizer": "Organizer",
|
||||
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
|
||||
@@ -23,6 +24,7 @@
|
||||
"rejection_confirmation": "Reject the booking",
|
||||
"manage_this_event": "Manage this event",
|
||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
||||
"your_event_has_been_scheduled_recurring": "Your recurring event has been scheduled",
|
||||
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
|
||||
"remove_banner_instructions": "To remove this banner, please open your .env file and change the <1>NEXT_PUBLIC_LICENSE_CONSENT</1> variable to '{{agree}}'.",
|
||||
"error_message": "The error message was: '{{errorMessage}}'",
|
||||
@@ -57,6 +59,7 @@
|
||||
"confirm_or_reject_request": "Confirm or reject the request",
|
||||
"check_bookings_page_to_confirm_or_reject": "Check your bookings page to confirm or reject the booking.",
|
||||
"event_awaiting_approval": "An event is waiting for your approval",
|
||||
"event_awaiting_approval_recurring": "A recurring event is waiting for your approval",
|
||||
"someone_requested_an_event": "Someone has requested to schedule an event on your calendar.",
|
||||
"someone_requested_password_reset": "Someone has requested a link to change your password.",
|
||||
"password_reset_instructions": "If you didn't request this, you can safely ignore this email and your password will not be changed.",
|
||||
@@ -79,6 +82,7 @@
|
||||
"manage_my_bookings": "Manage my bookings",
|
||||
"need_to_make_a_change": "Need to make a change?",
|
||||
"new_event_scheduled": "A new event has been scheduled.",
|
||||
"new_event_scheduled_recurring": "A new recurring event has been scheduled.",
|
||||
"invitee_email": "Invitee Email",
|
||||
"invitee_timezone": "Invitee Time Zone",
|
||||
"event_type": "Event Type",
|
||||
@@ -128,6 +132,7 @@
|
||||
"ping_test": "Ping test",
|
||||
"add_to_homescreen": "Add this app to your home screen for faster access and improved experience.",
|
||||
"upcoming": "Upcoming",
|
||||
"recurring": "Recurring",
|
||||
"past": "Past",
|
||||
"choose_a_file": "Choose a file...",
|
||||
"upload_image": "Upload image",
|
||||
@@ -232,13 +237,20 @@
|
||||
"add_to_calendar": "Add to calendar",
|
||||
"other": "Other",
|
||||
"emailed_you_and_attendees": "We emailed you and the other attendees a calendar invitation with all the details.",
|
||||
"emailed_you_and_attendees_recurring": "We emailed you and the other attendees a calendar invitation for the first of these recurring events.",
|
||||
"emailed_you_and_any_other_attendees": "You and any other attendees have been emailed with this information.",
|
||||
"needs_to_be_confirmed_or_rejected": "Your booking still needs to be confirmed or rejected.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Your recurring meeting still needs to be confirmed or rejected.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} still needs to confirm or reject the booking.",
|
||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} still needs to confirm or reject each booking of the recurring meeting.",
|
||||
"meeting_is_scheduled": "This meeting is scheduled",
|
||||
"meeting_is_scheduled_recurring": "The recurring events are scheduled",
|
||||
"submitted": "Your booking has been submitted",
|
||||
"submitted_recurring": "Your recurring meeting has been submitted",
|
||||
"booking_submitted": "Your booking has been submitted",
|
||||
"booking_submitted_recurring": "Your recurring meeting has been submitted",
|
||||
"booking_confirmed": "Your booking has been confirmed",
|
||||
"booking_confirmed_recurring": "Your recurring meeting has been confirmed",
|
||||
"enter_new_password": "Enter the new password you'd like for your account.",
|
||||
"reset_password": "Reset Password",
|
||||
"change_your_password": "Change your password",
|
||||
@@ -282,6 +294,7 @@
|
||||
"bookings": "Bookings",
|
||||
"bookings_description": "See upcoming and past events booked through your event type links.",
|
||||
"upcoming_bookings": "As soon as someone books a time with you it will show up here.",
|
||||
"recurring_bookings": "As soon as someone books a recurring meeting with you it will show up here.",
|
||||
"past_bookings": "Your past bookings will show up here.",
|
||||
"cancelled_bookings": "Your cancelled bookings will show up here.",
|
||||
"on": "on",
|
||||
@@ -432,6 +445,7 @@
|
||||
"edit_role": "Edit Role",
|
||||
"edit_team": "Edit team",
|
||||
"reject": "Reject",
|
||||
"reject_all": "Reject all",
|
||||
"accept": "Accept",
|
||||
"leave": "Leave",
|
||||
"profile": "Profile",
|
||||
@@ -460,6 +474,7 @@
|
||||
"cancel_event": "Cancel this event",
|
||||
"continue": "Continue",
|
||||
"confirm": "Confirm",
|
||||
"confirm_all": "Confirm all",
|
||||
"disband_team": "Disband Team",
|
||||
"disband_team_confirmation_message": "Are you sure you want to disband this team? Anyone who you've shared this team link with will no longer be able to book using it.",
|
||||
"remove_member_confirmation_message": "Are you sure you want to remove this member from the team?",
|
||||
@@ -485,6 +500,7 @@
|
||||
"user_from_team": "{{user}} from {{team}}",
|
||||
"preview": "Preview",
|
||||
"link_copied": "Link copied!",
|
||||
"private_link_copied": "Private link copied!",
|
||||
"link_shared": "Link shared!",
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
@@ -526,6 +542,18 @@
|
||||
"language": "Language",
|
||||
"timezone": "Timezone",
|
||||
"first_day_of_week": "First Day of Week",
|
||||
"repeats_up_to": "Repeats up to {{count}} time",
|
||||
"repeats_up_to_plural": "Repeats up to {{count}} times",
|
||||
"every_for_freq": "Every {{freq}} for",
|
||||
"repeats_every": "Repeats every",
|
||||
"weekly": "week",
|
||||
"weekly_plural": "weeks",
|
||||
"monthly": "month",
|
||||
"monthly_plural": "months",
|
||||
"yearly": "year",
|
||||
"yearly_plural": "years",
|
||||
"plus_more": "+ {{count}} more",
|
||||
"max": "Max",
|
||||
"single_theme": "Single Theme",
|
||||
"brand_color": "Brand Color",
|
||||
"light_brand_color": "Brand Color (Light Theme)",
|
||||
@@ -582,10 +610,14 @@
|
||||
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email.",
|
||||
"opt_in_booking": "Opt-in Booking",
|
||||
"opt_in_booking_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.",
|
||||
"recurring_event": "Recurring Event",
|
||||
"recurring_event_description": "People can subscribe for recurring events",
|
||||
"starting": "Starting",
|
||||
"disable_guests": "Disable Guests",
|
||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||
"hashed_link": "Generate hashed URL",
|
||||
"hashed_link_description": "Generate a hashed URL to share without exposing your Cal username",
|
||||
"private_link": "Generate private URL",
|
||||
"copy_private_link": "Copy private link",
|
||||
"private_link_description": "Generate a private URL to share without exposing your Cal username",
|
||||
"invitees_can_schedule": "Invitees can schedule",
|
||||
"date_range": "Date Range",
|
||||
"calendar_days": "calendar days",
|
||||
@@ -749,6 +781,7 @@
|
||||
"you_will_only_view_it_once": "You will not be able to view it again once you close this modal.",
|
||||
"copy_to_clipboard": "Copy to clipboard",
|
||||
"enabled_after_update": "Enabled after update",
|
||||
"enabled_after_update_description": "The private link will work after saving",
|
||||
"confirm_delete_api_key": "Revoke this API key",
|
||||
"revoke_api_key": "Revoke API key",
|
||||
"api_key_copied": "API key copied!",
|
||||
@@ -774,5 +807,19 @@
|
||||
"impersonate_user_tip":"All uses of this feature is audited.",
|
||||
"impersonating_user_warning":"Impersonating username \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "<0>Click Here to stop</0>.",
|
||||
"calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions"
|
||||
"email_validation_error":"That doesn't look like an email address",
|
||||
"place_where_cal_widget_appear": "Place this code in your HTML where you want your Cal widget to appear.",
|
||||
"copy_code": "Copy Code",
|
||||
"code_copied": "Code copied!",
|
||||
"how_you_want_add_cal_site":"How do you want to add Cal to your site?",
|
||||
"choose_ways_put_cal_site":"Choose one of the following ways to put Cal on your site.",
|
||||
"setting_up_zapier": "Setting up your Zapier integration",
|
||||
"generate_api_key": "Generate Api Key",
|
||||
"your_unique_api_key": "Your unique API key",
|
||||
"copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.",
|
||||
"zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>",
|
||||
"install_zapier_app": "Please first install the Zapier App in the app store.",
|
||||
"go_to_app_store": "Go to App Store",
|
||||
"calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions",
|
||||
"calendar_no_busy_slots": "There are no busy slots"
|
||||
}
|
||||
|
||||
13
apps/web/public/static/locales/en/vital.json
Normal file
13
apps/web/public/static/locales/en/vital.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"connected_vital_app": "Connected with",
|
||||
"vital_app_sleep_automation": "Sleeping reschedule automation",
|
||||
"vital_app_automation_description": "You can select different parameters to trigger the reschedule based on your sleeping metrics.",
|
||||
"vital_app_parameter": "Parameter",
|
||||
"vital_app_trigger": "Trigger at below or equal than",
|
||||
"vital_app_save_button": "Save configuration",
|
||||
"vital_app_total_label": "Total (total = rem + light sleep + deep sleep)",
|
||||
"vital_app_duration_label": "Duration (duration = bedtime end - bedtime start)",
|
||||
"vital_app_hours": "hours",
|
||||
"vital_app_save_success": "Success saving your Vital Configurations",
|
||||
"vital_app_save_error": "An error ocurred saving your Vital Configurations"
|
||||
}
|
||||
@@ -660,5 +660,6 @@
|
||||
"availability_updated_successfully": "Disponibilidad actualizada correctamente",
|
||||
"requires_ownership_of_a_token": "Requiere la propiedad de un token perteneciente a la siguiente dirección:",
|
||||
"example_name": "Juan Pérez",
|
||||
"you_are_being_redirected": "Serás redirigido a {{ url }} en $t(second, {\"count\": {{seconds}} })."
|
||||
"you_are_being_redirected": "Serás redirigido a {{ url }} en $t(second, {\"count\": {{seconds}} }).",
|
||||
"zapier_setup_instructions": "<0>Inicia sesión en tu cuenta de Zapier y crea un nuevo Zap.</0><1>Selecciona Cal.com cómo tu aplicación disparadora. Tambien elige tu evento disparador.</1><2>Elige tu cuenta e ingresa tu Clave API única.</2><3>Prueba tu disparador.</3><4>¡Listo!</4>"
|
||||
}
|
||||
|
||||
13
apps/web/public/static/locales/es/vital.json
Normal file
13
apps/web/public/static/locales/es/vital.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"connected_vital_app": "Conectado con",
|
||||
"vital_app_sleep_automation": "Automatización de reagendado en base al patron de sueño",
|
||||
"vital_app_automation_description": "Puedes seleccionar diferentes parámetros para activar el reagendado automático en base a tus patrones de sueño.",
|
||||
"vital_app_parameter": "Parámetro",
|
||||
"vital_app_trigger": "Activar cuando sea igual o menor que",
|
||||
"vital_app_save_button": "Guardar configuración",
|
||||
"vital_app_total_label": "Total (total = rem + sueño ligero + sueño profundo)",
|
||||
"vital_app_duration_label": "Duration (duration = Hora que te levantaste de la cama - Hora que te acostaste en la cama)",
|
||||
"vital_app_hours": "horas",
|
||||
"vital_app_save_success": "Fue un éxito el guardado de tus configuraciones de App Vital",
|
||||
"vital_app_save_error": "Ocurrió un error al intentar guardar tus configuraciones de App Vital"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user