diff --git a/.env.appStore.example b/.env.appStore.example new file mode 100644 index 00000000..9f6825c3 --- /dev/null +++ b/.env.appStore.example @@ -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" +# ********************************************************************************************************* diff --git a/.env.example b/.env.example index 78205898..cebebfa0 100644 --- a/.env.example +++ b/.env.example @@ -5,16 +5,6 @@ # - SHARED # - NEXTAUTH # - E-MAIL SETTINGS -# - APP STORE -# - DAILY.CO VIDEO -# - GOOGLE CALENDAR/MEET/LOGIN -# - HUBSPOT -# - OFFICE 365 -# - SLACK -# - STRIPE -# - TANDEM -# - ZOOM -# - GIPHY # - LICENSE ************************************************************************************************* # Set this value to 'agree' to accept our license: @@ -35,6 +25,7 @@ NEXT_PUBLIC_LICENSE_CONSENT='' NEXT_PUBLIC_WEBAPP_URL='http://localhost:3000' # Change to 'http://localhost:3001' if running the website simultaneously NEXT_PUBLIC_WEBSITE_URL='http://localhost:3000' +NEXT_PUBLIC_EMBED_LIB_URL='http://localhost:3000/embed/embed.js' # To enable SAML login, set both these variables # @see https://github.com/calcom/cal.com/tree/main/packages/ee#setting-up-saml-login @@ -115,64 +106,3 @@ EMAIL_SERVER_PASSWORD='' ## @see https://support.google.com/accounts/answer/185833 # EMAIL_SERVER_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= -# ********************************************************************************************************* diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 34d45183..aff95c12 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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: diff --git a/.gitignore b/.gitignore index 53ec8256..3df22db2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ yarn-error.log* .env.production.local .env.* !.env.example +!.env.appStore.example # vercel .vercel diff --git a/README.md b/README.md index ffdd09c6..05f9055e 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - +

@@ -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 `/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 @@ -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/) - -[](https://jitsu.com/?utm_source=cal.com-gihub) + + Jitsu.com 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. diff --git a/apps/admin b/apps/admin index cf71a8b4..943cd10d 160000 --- a/apps/admin +++ b/apps/admin @@ -1 +1 @@ -Subproject commit cf71a8b47ec9d37da7e4facb61356a293cb0bd13 +Subproject commit 943cd10de1f6661273d2ec18acdaa93118852714 diff --git a/apps/api b/apps/api index 6124577b..be2d4338 160000 --- a/apps/api +++ b/apps/api @@ -1 +1 @@ -Subproject commit 6124577bc21502c018378a299e50fc96bff14b99 +Subproject commit be2d4338ee1023a2d2862978ccf91554d47ff51f diff --git a/apps/docs/components/Anchor.tsx b/apps/docs/components/Anchor.tsx new file mode 100644 index 00000000..b7322b6b --- /dev/null +++ b/apps/docs/components/Anchor.tsx @@ -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 ( + + + § + + {children} + + ); +} diff --git a/apps/docs/lib/useWindowSize.ts b/apps/docs/lib/useWindowSize.ts new file mode 100644 index 00000000..1987e6f3 --- /dev/null +++ b/apps/docs/lib/useWindowSize.ts @@ -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({ + 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; +} diff --git a/apps/docs/package.json b/apps/docs/package.json index c8381960..b006c560 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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", diff --git a/apps/docs/pages/integrations/embed.mdx b/apps/docs/pages/integrations/embed.mdx index 40cde9d9..7a876d8b 100644 --- a/apps/docs/pages/integrations/embed.mdx +++ b/apps/docs/pages/integrations/embed.mdx @@ -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 choose any of the ways to show your Cal Link embedded on your website. + ```html -``` +*Sample sandbox* +``` + #### @@ -108,6 +118,14 @@ const MyComponent = () => ( ); ``` +*Sample sandbox* + + ### 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* + + -
React ```jsx @@ -131,11 +156,37 @@ To show the embed as a popup on clicking an element, simply add `data-cal-link` const MyComponent = ()=> { return } +``` -```` +*Sample sandbox* +
+### Floating pop-up button + +```html + +``` + +*Sample sandbox* + + ## Supported Instructions Consider an instruction as a function with that name and that would be called with the given arguments. diff --git a/apps/docs/pages/public-api.mdx b/apps/docs/pages/public-api.mdx index 12eb944e..a5a2fa62 100644 --- a/apps/docs/pages/public-api.mdx +++ b/apps/docs/pages/public-api.mdx @@ -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"; + -Public API | Cal.com - + Public API | Cal.com + 768 ? "calc(100vw - 16rem)": "100vw", + minHeight: useWindowSize().width > 768 ? "100vh" : "200vh", + height: "auto", + border: 0, + }} + /> diff --git a/apps/swagger/.env.example b/apps/swagger/.env.example index 2bef2516..0b62b0bc 100644 --- a/apps/swagger/.env.example +++ b/apps/swagger/.env.example @@ -1 +1 @@ -NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/docs \ No newline at end of file +NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs \ No newline at end of file diff --git a/apps/swagger/lib/snippets.ts b/apps/swagger/lib/snippets.ts index b1ec3fc3..fdeb5ead 100644 --- a/apps/swagger/lib/snippets.ts +++ b/apps/swagger/lib/snippets.ts @@ -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 diff --git a/apps/swagger/package.json b/apps/swagger/package.json index 129452ad..4879680c 100644 --- a/apps/swagger/package.json +++ b/apps/swagger/package.json @@ -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", diff --git a/apps/swagger/pages/index.tsx b/apps/swagger/pages/index.tsx index 06314336..6fc36fe4 100644 --- a/apps/swagger/pages/index.tsx +++ b/apps/swagger/pages/index.tsx @@ -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 ( - ); diff --git a/apps/swagger/styles/globals.css b/apps/swagger/styles/globals.css index e5e2dcc2..52d33738 100644 --- a/apps/swagger/styles/globals.css +++ b/apps/swagger/styles/globals.css @@ -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; + } +} \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore index abc84320..46f3800f 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -61,3 +61,6 @@ yarn-error.log* # Typescript tsconfig.tsbuildinfo + +# Autogenerated embed content +public/embed diff --git a/apps/web/components/BookingsShell.tsx b/apps/web/components/BookingsShell.tsx index 700d0029..014e379e 100644 --- a/apps/web/components/BookingsShell.tsx +++ b/apps/web/components/BookingsShell.tsx @@ -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", diff --git a/apps/web/components/Embed.tsx b/apps/web/components/Embed.tsx new file mode 100644 index 00000000..1885747c --- /dev/null +++ b/apps/web/components/Embed.tsx @@ -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: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* */} + + ), + }, + { + title: "Floating pop-up button", + subtitle: "Adds a floating button on your site that launches Cal in a dialog.", + type: "floating-popup", + illustration: ( + + + + + + + + + + {/* */} + + ), + }, + { + title: "Pop up via element click", + subtitle: "Open your Cal dialog when someone clicks an element.", + type: "element-click", + illustration: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* */} + + ), + }, +]; + +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 ; +}; +const ThemeSelectControl = ({ children, ...props }: ControlProps) => { + return ( + + + {children} + + ); +}; + +const ChooseEmbedTypesDialogContent = () => { + const { t } = useLocale(); + const router = useRouter(); + return ( + +
+ +
+

{t("choose_ways_put_cal_site")}

+
+
+
+ {embeds.map((embed, index) => ( + + ))} +
+
+ ); +}; + +const EmbedTypeCodeAndPreviewDialogContent = ({ + eventTypeId, + embedType, +}: { + eventTypeId: EventType["id"]; + embedType: EmbedType; +}) => { + const { t } = useLocale(); + const router = useRouter(); + const iframeRef = useRef(null); + const embedCode = useRef(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 ( + +
+
+ +
+
+ setIsEmbedCustomizationOpen((val) => !val)}> + +
+ {embedType === "inline" + ? "Inline Embed Customization" + : embedType === "floating-popup" + ? "Floating Popup Customization" + : "Element Click Customization"} +
+ +
+ +
+ {/*TODO: Add Auto/Fixed toggle from Figma */} +
Embed Window Sizing
+
+ { + setPreviewState((previewState) => { + let width = e.target.value || "100%"; + + return { + ...previewState, + inline: { + ...previewState.inline, + width, + }, + }; + }); + }} + addOnLeading={W} + /> + x + { + const height = e.target.value || "100%"; + + setPreviewState((previewState) => { + return { + ...previewState, + inline: { + ...previewState.inline, + height, + }, + }; + }); + }} + addOnLeading={H} + /> +
+
+
+
Button Text
+ {/* Default Values should come from preview iframe */} + { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonText: e.target.value, + }, + }; + }); + }} + defaultValue="Book my Cal" + required + /> +
+
+
Display Calendar Icon Button
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + hideButtonIcon: !checked, + }, + }; + }); + }}> +
+
+
Position of Button
+ +
+
+
Button Color
+
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonColor: color, + }, + }; + }); + }}> +
+
+
+
Text Color
+
+ { + setPreviewState((previewState) => { + return { + ...previewState, + floatingPopup: { + ...previewState.floatingPopup, + buttonTextColor: color, + }, + }; + }); + }}> +
+
+ {/*
+
Button Color on Hover
+
+ { + addToPalette({ + "floating-popup-button-color-hover": color, + }); + }}> +
+
*/} +
+
+
+
+
+ setIsBookingCustomizationOpen((val) => !val)}> + +
Cal Booking Customization
+ +
+ +
+ + {[ + { 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) => ( + + ))} +
+
+
+
+
+
+ +
+
+ {t("place_where_cal_widget_appear")} + +

+ {t( + "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options." + )} +

+
+
+ `; - const htmlTemplate = `${t( - "schedule_a_meeting" - )}${iframeTemplate}`; - - return ( - <> - -
- - -
- Embed -
- {t("standard_iframe")} - {t("embed_your_calendar")} -
-
- - -
-
-
- -
- Embed -
- {t("responsive_fullscreen_iframe")} - A fullscreen scheduling experience on your website -
-
- - -
-
-
-
-
-
- -
-
-
- -
-
-
-
- - ); -} - function ConnectOrDisconnectIntegrationButton(props: { // credentialIds: number[]; @@ -242,8 +161,9 @@ function IntegrationsContainer() { isGlobal={item.isGlobal} installed={item.installed} /> - } - /> + }> + + ))} @@ -342,7 +262,6 @@ export default function IntegrationsPage() { - diff --git a/apps/web/pages/apps/setup/[appName].tsx b/apps/web/pages/apps/setup/[appName].tsx new file mode 100644 index 00000000..b8bdc7c6 --- /dev/null +++ b/apps/web/pages/apps/setup/[appName].tsx @@ -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 ( +
+ +
+ ); + } + + if (status === "unauthenticated") { + router.replace({ + pathname: "/auth/login", + query: { + callbackUrl: `/apps/setup/${appName}`, + }, + }); + } + + if (appName === _zapierMetadata.name.toLowerCase() && status === "authenticated") { + return ; + } + + return null; +} diff --git a/apps/web/pages/bookings/[status].tsx b/apps/web/pages/bookings/[status].tsx index 9f7833b5..b6e77cb8 100644 --- a/apps/web/pages/bookings/[status].tsx +++ b/apps/web/pages/bookings/[status].tsx @@ -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 = { 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 ( ( {page.bookings.map((booking) => ( - + ))} ))} diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 58508b4a..49aa8136 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -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, diff --git a/apps/web/pages/d/[link]/book.tsx b/apps/web/pages/d/[link]/book.tsx index 91c309b6..48b0eafc 100644 --- a/apps/web/pages/d/[link]/book.tsx +++ b/apps/web/pages/d/[link]/book.tsx @@ -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()({ @@ -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, diff --git a/apps/web/pages/event-types/[type].tsx b/apps/web/pages/event-types/[type].tsx index d02b82a9..0f6ff47a 100644 --- a/apps/web/pages/event-types/[type].tsx +++ b/apps/web/pages/event-types/[type].tsx @@ -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) => { 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) => { 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) { @@ -454,9 +473,7 @@ const EventTypePage = (props: inferSSRProps) => { 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) => { 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) => { }>({ defaultValues: { locations: eventType.locations || [], + recurringEvent: eventType.recurringEvent || {}, schedule: eventType.schedule?.id, periodDates: { startDate: periodDates.startDate, @@ -927,15 +946,15 @@ const EventTypePage = (props: inferSSRProps) => { 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) => { )} /> + + ) => { ( <> { setHashedLinkVisible(e?.target.checked); - formMethods.setValue("hashedLink", e?.target.checked); + formMethods.setValue( + "hashedLink", + e?.target.checked ? hashedUrl : undefined + ); }} /> {hashedLinkVisible && ( -
+
) => { + {hashedLinkVisible && ( + + )} + @@ -1870,28 +1920,30 @@ const EventTypePage = (props: inferSSRProps) => { addLocation(newLocation, details); setShowLocationModal(false); }}> - ( - { + if (val) { + locationFormMethods.setValue("locationType", val.value); + locationFormMethods.unregister("locationLink"); + locationFormMethods.unregister("locationAddress"); + setSelectedLocation(val); + } + }} + /> + )} + /> +
); @@ -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: diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 2550cb20..69846ff3 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -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")} + + + @@ -519,9 +527,9 @@ const CTA = () => { }; const WithQuery = withQuery(["viewer.eventTypes"]); + const EventTypesPage = () => { const { t } = useLocale(); - return (
@@ -574,6 +582,7 @@ const EventTypesPage = () => { {data.eventTypeGroups.length === 0 && ( )} + )} /> diff --git a/apps/web/pages/settings/billing.tsx b/apps/web/pages/settings/billing.tsx index de79b1b3..4e6df89b 100644 --- a/apps/web/pages/settings/billing.tsx +++ b/apps/web/pages/settings/billing.tsx @@ -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 => ( diff --git a/apps/web/pages/settings/teams/[id]/index.tsx b/apps/web/pages/settings/teams/[id]/index.tsx index 1ced8949..443fdb2b 100644 --- a/apps/web/pages/settings/teams/[id]/index.tsx +++ b/apps/web/pages/settings/teams/[id]/index.tsx @@ -145,6 +145,7 @@ export function TeamSettingsPage() { setShowMemberInvitationModal(false)} /> )} diff --git a/apps/web/pages/settings/teams/index.tsx b/apps/web/pages/settings/teams/index.tsx index 034f77c1..be391687 100644 --- a/apps/web/pages/settings/teams/index.tsx +++ b/apps/web/pages/settings/teams/index.tsx @@ -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"; diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx index ccfe1f84..7be45f1c 100644 --- a/apps/web/pages/success.tsx +++ b/apps/web/pages/success.tsx @@ -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) { +type SuccessProps = inferSSRProps; + +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) 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) 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) className={isEmbed ? "" : "h-screen bg-neutral-100 dark:bg-neutral-900"} data-testid="success-page"> - +
@@ -263,30 +284,55 @@ export default function Success(props: inferSSRProps)
-

- {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")} -

+

{getTitle()}

-
{t("what")}
{eventName}
{t("when")}
-
- {date.format("dddd, DD MMMM YYYY")} +
+ {date.format("MMMM DD, YYYY")}
- {date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "} + {date.format("LT")} - {date.add(props.eventType.length, "m").format("LT")}{" "} ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
+
+ +
+
{t("who")}
+
+ {bookingInfo?.user && ( +
+

{bookingInfo.user.name}

+

{bookingInfo.user.email}

+
+ )} + {bookingInfo?.attendees.map((attendee, index) => ( +
+

{attendee.name}

+

{attendee.email}

+
+ ))} +
{location && ( <>
{t("where")}
@@ -301,6 +347,14 @@ export default function Success(props: inferSSRProps)
)} + {bookingInfo?.description && ( + <> +
{t("additional_notes")}
+
+

{bookingInfo.description}

+
+ + )}
@@ -322,6 +376,10 @@ export default function Success(props: inferSSRProps) }` + (typeof location === "string" ? "&location=" + encodeURIComponent(location) + : "") + + (props.eventType.recurringEvent + ? "&recur=" + + encodeURIComponent(new RRule(props.eventType.recurringEvent).toString()) : "") }> @@ -447,21 +505,15 @@ export default function Success(props: inferSSRProps) {props.userHasSpaceBooking && ( @@ -472,6 +524,71 @@ export default function Success(props: inferSSRProps) ); } +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) => ( +
+ {dayjs(dateStr).format("dddd, DD MMMM YYYY")} +
+ {dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "} + + ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) + +
+ ))} + {recurringBookings.length > 4 && ( + setMoreEventsVisible(!moreEventsVisible)}> + + {t("plus_more", { count: recurringBookings.length - 4 })} + + + {eventType.recurringEvent?.count && + recurringBookings.slice(4).map((dateStr, idx) => ( +
+ {dayjs(dateStr).format("dddd, DD MMMM YYYY")} +
+ {dayjs(dateStr).format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "} + + ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) + +
+ ))} +
+
+ )} + + ) : !eventType.recurringEvent.freq ? ( + <> + {date.format("dddd, DD MMMM YYYY")} +
+ {date.format(is24h ? "H:mm" : "h:mma")} - {eventType.length} mins{" "} + + ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) + + + ) : 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, }, }; } diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 1e2160d4..90a3ba4d 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -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" || "", }))} />
@@ -86,7 +86,7 @@ function TeamPage({ team }: TeamPageProps) {
-
+
0 && (
{eventTypes} -