Merge pull request #2672 from calcom/main

v1.5.4
This commit is contained in:
Omar López
2022-05-09 14:56:23 -06:00
committed by GitHub
parent 7399d6421e
commit ed808c3be6
244 changed files with 7237 additions and 3128 deletions

84
.env.appStore.example Normal file
View 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"
# *********************************************************************************************************

View File

@@ -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=
# *********************************************************************************************************

View File

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

@@ -39,6 +39,7 @@ yarn-error.log*
.env.production.local
.env.*
!.env.example
!.env.appStore.example
# vercel
.vercel

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/docs
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs

View File

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

View File

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

View File

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

View File

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

@@ -61,3 +61,6 @@ yarn-error.log*
# Typescript
tsconfig.tsbuildinfo
# Autogenerated embed content
public/embed

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,7 +77,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
/>
<DialogFooter>
<DialogClose>
<DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
<Button

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ export type BookingCreateBody = {
name: string;
notes?: string;
rescheduleUid?: string;
recurringEventId?: string;
start: string;
timeZone: string;
user?: string | string[];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -145,6 +145,7 @@ export function TeamSettingsPage() {
<MemberInvitationModal
isOpen={showMemberInvitationModal}
team={team}
currentMember={team.membership.role}
onExit={() => setShowMemberInvitationModal(false)}
/>
)}

View File

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

View File

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

View File

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

View File

@@ -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 = [];

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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"
}

View File

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

View 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