From 271d4319b9f59ed1b8deba58b4befc04473ebcb3 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Fri, 14 Oct 2022 21:54:43 +0530 Subject: [PATCH] Introduce EventTypeAppCard in app-store and make it super easy to add it through CLI - Also adds Fathom app (#4727) * Add OmniInstall button * Make AppCards configurable by the app itself * Make OmniInstallAppButton not redirect * Fixes * Add extendsFeature support to CLI * Move to automatic file generation approach as dynamic import checking doesnt work correctly * Use zod everywhere consistenly for metadata and fix all TS issues * Fix viewer.eventTypes endpoint. Make prisma base select and _ prefixed models consistent in expecting scalars only * Remove unnecessary zod parsing of event-types as it is making the scope of the PR huge * Fix UI TS errors * wip * Add zod types support in EventTypeAppCard.tsx * Fixes during PR review and other failing tests * Remove unused app * Fix stripe installation flow * More fixes * Fix apps and active apps count * self review * Add loading attribute to OmniInsall button * Handle empty state * Improve types * Fix stripe app installation bug * added fathom app (#4804) * added fathom app wrapper, needs script injection to public booking page * new logo * Add Fathom script support on booking pages and add it as an eventTypeapp * Add automation and analytics apps * Add missing pieces for analytics category * Rename BookingPageScripts to BookingPageTagManager Co-authored-by: Hariom Balhara * Fix lint error * Fix runtime error with legayAppData being undefined * Remove duplicate automation key Co-authored-by: Peer Richelsen --- apps/web/components/Gates.tsx | 10 +- .../booking/pages/AvailabilityPage.tsx | 16 +- .../components/booking/pages/BookingPage.tsx | 16 +- .../eventtype/EventTypeDescription.tsx | 89 -- .../v2/apps/OmniInstallAppButton.tsx | 63 ++ .../components/v2/eventtype/EventAppsTab.tsx | 311 +++---- .../v2/eventtype/EventRecurringTab.tsx | 23 +- .../v2/eventtype/EventTypeSingleLayout.tsx | 18 +- .../v2/eventtype/RecurringEventController.tsx | 2 - apps/web/pages/[user].tsx | 73 +- apps/web/pages/[user]/[type].tsx | 4 +- apps/web/pages/d/[link]/[slug].tsx | 4 +- apps/web/pages/success.tsx | 867 ------------------ apps/web/pages/team/[slug]/[type].tsx | 5 +- .../pages/v2/apps/installed/[category].tsx | 26 +- .../web/pages/v2/event-types/[type]/index.tsx | 96 +- apps/web/pages/v2/event-types/index.tsx | 2 +- apps/web/pages/v2/success.tsx | 30 +- apps/web/public/static/locales/en/common.json | 11 +- packages/app-store-cli/src/CliApp.tsx | 34 +- packages/app-store-cli/src/app-store.ts | 73 +- packages/app-store-cli/src/execSync.ts | 13 + packages/app-store/BookingPageTagManager.tsx | 36 + packages/app-store/EventTypeAppContext.tsx | 28 + packages/app-store/_components/AppCard.tsx | 57 ++ .../extensions/EventTypeAppCard.tsx | 43 + packages/app-store/_templates/zod.ts | 9 + .../app-store/_utils/useAddAppMutation.ts | 62 +- packages/app-store/apps.browser.generated.tsx | 22 +- packages/app-store/apps.server.generated.ts | 1 + .../routing-forms/components/SingleForm.tsx | 2 +- .../pages/forms/[...appPages].tsx | 2 +- packages/app-store/ee/routing-forms/zod.ts | 4 + packages/app-store/eventTypeAppCardZod.ts | 6 + packages/app-store/fathom/README.mdx | 10 + packages/app-store/fathom/_metadata.ts | 10 + packages/app-store/fathom/api/add.ts | 17 + packages/app-store/fathom/api/index.ts | 1 + packages/app-store/fathom/components/.gitkeep | 0 packages/app-store/fathom/config.json | 16 + .../fathom/extensions/EventTypeAppCard.tsx | 39 + packages/app-store/fathom/index.ts | 2 + packages/app-store/fathom/package.json | 14 + packages/app-store/fathom/static/1.jpg | Bin 0 -> 175680 bytes packages/app-store/fathom/static/icon.svg | 6 + packages/app-store/fathom/zod.ts | 9 + packages/app-store/giphy/_metadata.ts | 1 + .../giphy/components/InstallAppButton.tsx | 19 - packages/app-store/giphy/components/index.ts | 1 - .../giphy/extensions/EventTypeAppCard.tsx | 38 + packages/app-store/giphy/zod.ts | 9 + .../rainbow/components/RainbowInstallForm.tsx | 28 +- packages/app-store/rainbow/config.json | 1 + .../rainbow/extensions/EventTypeAppCard.tsx | 35 + packages/app-store/rainbow/zod.ts | 10 + packages/app-store/stripepayment/_metadata.ts | 1 + .../extensions/EventTypeAppCard.tsx | 76 ++ .../app-store/stripepayment/lib/server.ts | 19 +- packages/app-store/stripepayment/zod.ts | 10 + packages/app-store/types.d.ts | 11 + packages/app-store/utils.ts | 51 +- .../features/bookings/lib/handleNewBooking.ts | 22 +- .../ee/payments/components/PaymentPage.tsx | 6 +- .../features/ee/payments/pages/payment.tsx | 7 +- packages/lib/defaultEvents.ts | 4 +- packages/lib/getStripeAppData.ts | 11 + packages/lib/server/queries/teams/index.ts | 11 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 4 + packages/prisma/seed-app-store.config.json | 52 +- packages/prisma/zod-utils.ts | 10 + packages/trpc/server/routers/viewer.tsx | 108 ++- .../trpc/server/routers/viewer/eventTypes.tsx | 10 +- packages/types/App.d.ts | 7 +- packages/ui/ErrorBoundary.tsx | 4 +- packages/ui/v2/core/Shell.tsx | 2 +- packages/ui/v2/core/apps/AppCard.tsx | 2 +- .../v2/core/layouts/InstalledAppsLayout.tsx | 10 + .../event-types/EventTypeDescription.tsx | 26 +- 79 files changed, 1324 insertions(+), 1466 deletions(-) delete mode 100644 apps/web/components/eventtype/EventTypeDescription.tsx create mode 100644 apps/web/components/v2/apps/OmniInstallAppButton.tsx delete mode 100644 apps/web/pages/success.tsx create mode 100644 packages/app-store-cli/src/execSync.ts create mode 100644 packages/app-store/BookingPageTagManager.tsx create mode 100644 packages/app-store/EventTypeAppContext.tsx create mode 100644 packages/app-store/_components/AppCard.tsx create mode 100644 packages/app-store/_templates/extensions/EventTypeAppCard.tsx create mode 100644 packages/app-store/_templates/zod.ts create mode 100644 packages/app-store/eventTypeAppCardZod.ts create mode 100644 packages/app-store/fathom/README.mdx create mode 100644 packages/app-store/fathom/_metadata.ts create mode 100644 packages/app-store/fathom/api/add.ts create mode 100644 packages/app-store/fathom/api/index.ts create mode 100644 packages/app-store/fathom/components/.gitkeep create mode 100644 packages/app-store/fathom/config.json create mode 100644 packages/app-store/fathom/extensions/EventTypeAppCard.tsx create mode 100644 packages/app-store/fathom/index.ts create mode 100644 packages/app-store/fathom/package.json create mode 100644 packages/app-store/fathom/static/1.jpg create mode 100644 packages/app-store/fathom/static/icon.svg create mode 100644 packages/app-store/fathom/zod.ts delete mode 100644 packages/app-store/giphy/components/InstallAppButton.tsx create mode 100644 packages/app-store/giphy/extensions/EventTypeAppCard.tsx create mode 100644 packages/app-store/giphy/zod.ts create mode 100644 packages/app-store/rainbow/extensions/EventTypeAppCard.tsx create mode 100644 packages/app-store/rainbow/zod.ts create mode 100644 packages/app-store/stripepayment/extensions/EventTypeAppCard.tsx create mode 100644 packages/app-store/stripepayment/zod.ts create mode 100644 packages/lib/getStripeAppData.ts create mode 100644 packages/prisma/migrations/20221006133952_add_analytics_category/migration.sql diff --git a/apps/web/components/Gates.tsx b/apps/web/components/Gates.tsx index 114c32cc9c..2b86cb88e1 100644 --- a/apps/web/components/Gates.tsx +++ b/apps/web/components/Gates.tsx @@ -11,14 +11,14 @@ export type GateState = { type GateProps = { children: React.ReactNode; gates: Gate[]; - metadata: JSONObject; + appData: JSONObject; dispatch: Dispatch>; }; const RainbowGate = dynamic(() => import("@calcom/app-store/rainbow/components/RainbowKit")); // To add a new Gate just add the gate logic to the switch statement -const Gates: React.FC = ({ children, gates, metadata, dispatch }) => { +const Gates: React.FC = ({ children, gates, appData, dispatch }) => { const [rainbowToken, setRainbowToken] = useState(); useEffect(() => { @@ -31,12 +31,12 @@ const Gates: React.FC = ({ children, gates, metadata, dispatch }) => for (const gate of gates) { switch (gate) { case "rainbow": - if (metadata.blockchainId && metadata.smartContractAddress && !rainbowToken) { + if (appData.blockchainId && appData.smartContractAddress && !rainbowToken) { gateWrappers = ( + chainId={appData.blockchainId as number} + tokenAddress={appData.smartContractAddress as string}> {gateWrappers} ); diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 3ab973a2f7..be007cceef 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -10,6 +10,8 @@ import { Toaster } from "react-hot-toast"; import { FormattedNumber, IntlProvider } from "react-intl"; import { z } from "zod"; +import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; +import { getEventTypeAppData } from "@calcom/app-store/utils"; import dayjs, { Dayjs } from "@calcom/dayjs"; import { useEmbedNonStylesConfig, @@ -20,6 +22,7 @@ import { import CustomBranding from "@calcom/lib/CustomBranding"; import classNames from "@calcom/lib/classNames"; import { WEBSITE_URL } from "@calcom/lib/constants"; +import getStripeAppData from "@calcom/lib/getStripeAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import notEmpty from "@calcom/lib/notEmpty"; @@ -367,6 +370,8 @@ const AvailabilityPage = ({ profile, eventType }: Props) => { ), [timeZone, timeFormat, timeFormatFromProfile] ); + const stripeAppData = getStripeAppData(eventType); + const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {}; const rawSlug = profile.slug ? profile.slug.split("/") : []; if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug. const slug = rawSlug.join("/"); @@ -374,13 +379,13 @@ const AvailabilityPage = ({ profile, eventType }: Props) => { // Define conditional gates here const gates = [ // Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress` - eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress + rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress ? ("rainbow" as Gate) : undefined, ]; return ( - + { noindex: eventType.hidden, }} /> +
{
)} - {eventType.price > 0 && ( + {stripeAppData.price > 0 && (

diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index 6f920aefac..1110a944f2 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -13,6 +13,7 @@ import { ReactMultiEmail } from "react-multi-email"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; +import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; import { locationKeyToString, getEventLocationValue, @@ -20,6 +21,7 @@ import { EventLocationType, } from "@calcom/app-store/locations"; import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client"; +import { getEventTypeAppData } from "@calcom/app-store/utils"; import { LocationObject, LocationType } from "@calcom/core/location"; import dayjs from "@calcom/dayjs"; import { @@ -29,6 +31,7 @@ import { } from "@calcom/embed-core/embed-iframe"; import CustomBranding from "@calcom/lib/CustomBranding"; import classNames from "@calcom/lib/classNames"; +import getStripeAppData from "@calcom/lib/getStripeAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import { HttpError } from "@calcom/lib/http-error"; @@ -98,6 +101,7 @@ const BookingPage = ({ }), {} ); + const stripeAppData = getStripeAppData(eventType); useEffect(() => { if (top !== window) { @@ -410,17 +414,18 @@ const BookingPage = ({ } }); } + const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {}; // Define conditional gates here const gates = [ // Rainbow gate is only added if the event has both a `blockchainId` and a `smartContractAddress` - eventType.metadata && eventType.metadata.blockchainId && eventType.metadata.smartContractAddress + rainbowAppData && rainbowAppData.blockchainId && rainbowAppData.smartContractAddress ? ("rainbow" as Gate) : undefined, ]; return ( - + {rescheduleUid @@ -436,6 +441,7 @@ const BookingPage = ({ +
- {eventType.price > 0 && ( + {stripeAppData.price > 0 && (

diff --git a/apps/web/components/eventtype/EventTypeDescription.tsx b/apps/web/components/eventtype/EventTypeDescription.tsx deleted file mode 100644 index 58011ece0a..0000000000 --- a/apps/web/components/eventtype/EventTypeDescription.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Prisma, SchedulingType } from "@prisma/client"; -import { useMemo } from "react"; -import { FormattedNumber, IntlProvider } from "react-intl"; - -import { parseRecurringEvent } from "@calcom/lib"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { baseEventTypeSelect } from "@calcom/prisma/selects"; -import { Icon } from "@calcom/ui/Icon"; - -import classNames from "@lib/classNames"; - -const eventTypeData = Prisma.validator()({ - select: baseEventTypeSelect, -}); - -type EventType = Prisma.EventTypeGetPayload; - -export type EventTypeDescriptionProps = { - eventType: EventType; - className?: string; -}; - -export const EventTypeDescription = ({ eventType, className }: EventTypeDescriptionProps) => { - const { t } = useLocale(); - - const recurringEvent = useMemo( - () => parseRecurringEvent(eventType.recurringEvent), - [eventType.recurringEvent] - ); - - return ( - <> -
- {eventType.description && ( -

- {eventType.description.substring(0, 100)} - {eventType.description.length > 100 && "..."} -

- )} -
    -
  • -
  • - {eventType.schedulingType ? ( -
  • -
  • - ) : ( -
  • -
  • - )} - {recurringEvent?.count && recurringEvent.count > 0 && ( -
  • -
  • - )} - {eventType.price > 0 && ( -
  • -
  • - )} - {eventType.requiresConfirmation && ( -
  • -
  • - )} -
-
- - ); -}; - -export default EventTypeDescription; diff --git a/apps/web/components/v2/apps/OmniInstallAppButton.tsx b/apps/web/components/v2/apps/OmniInstallAppButton.tsx new file mode 100644 index 0000000000..18a90a2702 --- /dev/null +++ b/apps/web/components/v2/apps/OmniInstallAppButton.tsx @@ -0,0 +1,63 @@ +import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; +import { InstallAppButton } from "@calcom/app-store/components"; +import { classNames } from "@calcom/lib"; +import useApp from "@calcom/lib/hooks/useApp"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Icon } from "@calcom/ui/Icon"; +import { showToast } from "@calcom/ui/v2"; +import Button from "@calcom/ui/v2/core/Button"; + +/** + * Use this component to allow installing an app from anywhere on the app. + * Use of this component requires you to remove custom InstallAppButtonComponent so that it can manage the redirection itself + */ +export default function OmniInstallAppButton({ appId, className }: { appId: string; className: string }) { + const { t } = useLocale(); + const { data: app } = useApp(appId); + const utils = trpc.useContext(); + + const mutation = useAddAppMutation(null, { + onSuccess: () => { + //TODO: viewer.appById might be replaced with viewer.apps so that a single query needs to be invalidated. + utils.invalidateQueries(["viewer.appById", { appId }]); + utils.invalidateQueries(["viewer.apps", { extendsFeature: "EventType" }]); + showToast(t("app_successfully_installed"), "success"); + }, + onError: (error) => { + if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); + }, + }); + + if (!app) { + return null; + } + + return ( + { + if (useDefaultComponent) { + props = { + onClick: () => { + mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug, isOmniInstall: true }); + }, + }; + } + + return ( + + ); + }} + /> + ); +} diff --git a/apps/web/components/v2/eventtype/EventAppsTab.tsx b/apps/web/components/v2/eventtype/EventAppsTab.tsx index 7508778304..f965b7e640 100644 --- a/apps/web/components/v2/eventtype/EventAppsTab.tsx +++ b/apps/web/components/v2/eventtype/EventAppsTab.tsx @@ -1,208 +1,131 @@ import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]"; -import React, { useState } from "react"; +import React from "react"; import { useFormContext } from "react-hook-form"; -import { Controller } from "react-hook-form"; -import { FormattedNumber, IntlProvider } from "react-intl"; -import { SelectGifInput } from "@calcom/app-store/giphy/components"; -import RainbowInstallForm from "@calcom/app-store/rainbow/components/RainbowInstallForm"; +import EventTypeAppContext, { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext"; +import { EventTypeAddonMap } from "@calcom/app-store/apps.browser.generated"; +import { EventTypeAppsList } from "@calcom/app-store/utils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { inferQueryOutput, trpc } from "@calcom/trpc/react"; import { Icon } from "@calcom/ui"; -import { Alert, Button, EmptyScreen, Select, Switch, TextField } from "@calcom/ui/v2"; +import ErrorBoundary from "@calcom/ui/ErrorBoundary"; +import { Button, EmptyScreen } from "@calcom/ui/v2"; -const AppCard = ({ - logo, - name, - description, - switchOnClick, - switchChecked, - children, -}: { - logo: string; - name: string; - description: React.ReactNode; - switchChecked?: boolean; - switchOnClick?: (e: boolean) => void; - children?: React.ReactNode; -}) => { - return ( -
-
- {name} -
- {name} -

{description}

-
-
- -
-
- {children} -
- ); -}; - -export const EventAppsTab = ({ - hasPaymentIntegration, - currency, +type EventType = Pick["eventType"]; +function AppCardWrapper({ + app, eventType, - hasGiphyIntegration, - hasRainbowIntegration, -}: Pick< - EventTypeSetupInfered, - "eventType" | "hasPaymentIntegration" | "hasGiphyIntegration" | "hasRainbowIntegration" | "currency" ->) => { - const formMethods = useFormContext(); - const [showGifSelection, setShowGifSelection] = useState( - hasGiphyIntegration && !!eventType.metadata["giphyThankYouPage"] - ); - const [showRainbowSection, setShowRainbowSection] = useState( - hasRainbowIntegration && - !!eventType.metadata["blockchainId"] && - !!eventType.metadata["smartContractAddress"] - ); - const [requirePayment, setRequirePayment] = useState(eventType.price > 0); - const recurringEventDefined = eventType.recurringEvent?.count !== undefined; + getAppData, + setAppData, +}: { + app: inferQueryOutput<"viewer.apps">[number]; + eventType: EventType; + getAppData: GetAppData; + setAppData: SetAppData; +}) { + const dirName = app.slug === "stripe" ? "stripepayment" : app.slug; + const Component = EventTypeAddonMap[dirName as keyof typeof EventTypeAddonMap]; - const getCurrencySymbol = (locale: string, currency: string) => - (0) - .toLocaleString(locale, { - style: "currency", - currency, - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }) - .replace(/\d/g, "") - .trim(); - - const { t } = useLocale(); - - const installedApps = [hasPaymentIntegration, hasGiphyIntegration].filter(Boolean).length; - - if (installedApps === 0) { - return ( -
- - {t("empty_installed_apps_button")}{" "} - - } - /> -
- ); + if (!Component) { + throw new Error('No component found for "' + dirName + '"'); } return ( -
- {/* TODO:Strip isnt fully setup yet */} - {hasPaymentIntegration && ( - { - if (!e) { - formMethods.setValue("price", 0); - setRequirePayment(false); - } else { - setRequirePayment(true); - } - }} - description={ - <> -
- {t("require_payment")} (0.5% +{" "} - - - {" "} - {t("commission_per_transaction")}) -
- - } - logo="/api/app-store/stripepayment/icon.svg"> - <> - {recurringEventDefined ? ( - - ) : ( - requirePayment && ( -
- ( - {getCurrencySymbol("en", currency)}} - {...field} - step="0.01" - min="0.5" - type="number" - required - className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm" - placeholder="Price" - onChange={(e) => { - field.onChange(e.target.valueAsNumber * 100); - }} - value={field.value > 0 ? field.value / 100 : undefined} - /> - )} - /> -
- ) - )} - -
- )} - {hasGiphyIntegration && ( - { - if (!e) { - setShowGifSelection(false); - formMethods.setValue("giphyThankYouPage", ""); - } else { - setShowGifSelection(true); - } - }} - switchChecked={showGifSelection}> - {showGifSelection && ( - { - formMethods.setValue("giphyThankYouPage", url); - }} - /> - )} - - )} - {hasRainbowIntegration && ( - { - if (!e) { - formMethods.setValue("blockchainId", 1); - formMethods.setValue("smartContractAddress", ""); - } + + + + + + ); +} - setShowRainbowSection(e); - }} - switchChecked={showRainbowSection}> - {showRainbowSection && ( - { + const { t } = useLocale(); + const { data: eventTypeApps, isLoading } = trpc.useQuery([ + "viewer.apps", + { + extendsFeature: "EventType", + }, + ]); + const methods = useFormContext(); + const installedApps = eventTypeApps?.filter((app) => app.credentials.length); + const notInstalledApps = eventTypeApps?.filter((app) => !app.credentials.length); + const allAppsData = methods.watch("metadata")?.apps || {}; + + const setAllAppsData = (_allAppsData: typeof allAppsData) => { + methods.setValue("metadata", { + ...methods.getValues("metadata"), + apps: _allAppsData, + }); + }; + + const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => { + return function (key) { + const appData = allAppsData[appId as keyof typeof allAppsData] || {}; + if (key) { + return appData[key as keyof typeof appData]; + } + return appData; + }; + }; + + const getAppDataSetter = (appId: EventTypeAppsList): SetAppData => { + return function (key, value) { + // Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render) + const allAppsDataFromForm = methods.getValues("metadata")?.apps || {}; + const appData = allAppsDataFromForm[appId]; + setAllAppsData({ + ...allAppsDataFromForm, + [appId]: { + ...appData, + [key]: value, + }, + }); + }; + }; + + return ( + <> +
+
+ {!isLoading && !installedApps?.length ? ( + + {t("empty_installed_apps_button")}{" "} + + } /> - )} - - )} -
+ ) : null} + {installedApps?.map((app) => ( + + ))} +
+
+
+ {!isLoading && notInstalledApps?.length ? ( +

Available Apps

+ ) : null} +
+ {notInstalledApps?.map((app) => ( + + ))} +
+
+ ); }; diff --git a/apps/web/components/v2/eventtype/EventRecurringTab.tsx b/apps/web/components/v2/eventtype/EventRecurringTab.tsx index d748793bb2..5da6cda426 100644 --- a/apps/web/components/v2/eventtype/EventRecurringTab.tsx +++ b/apps/web/components/v2/eventtype/EventRecurringTab.tsx @@ -1,25 +1,18 @@ -import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]"; +import { EventTypeSetupInfered } from "pages/v2/event-types/[type]"; import { useState } from "react"; -import { useFormContext } from "react-hook-form"; + +import getStripeAppData from "@calcom/lib/getStripeAppData"; import RecurringEventController from "./RecurringEventController"; -export const EventRecurringTab = ({ - eventType, - hasPaymentIntegration, -}: Pick) => { - const requirePayment = eventType.price > 0; - const [recurringEventDefined, setRecurringEventDefined] = useState( - eventType.recurringEvent?.count !== undefined - ); +export const EventRecurringTab = ({ eventType }: Pick) => { + const stripeAppData = getStripeAppData(eventType); + + const requirePayment = stripeAppData.price > 0; return (
- +
); }; diff --git a/apps/web/components/v2/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/v2/eventtype/EventTypeSingleLayout.tsx index 5d62de3b63..c914838bf5 100644 --- a/apps/web/components/v2/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/v2/eventtype/EventTypeSingleLayout.tsx @@ -42,6 +42,7 @@ type Props = { team: EventTypeSetupInfered["team"]; disableBorder?: boolean; enabledAppsNumber: number; + installedAppsNumber: number; enabledWorkflowsNumber: number; formMethods: UseFormReturn; }; @@ -51,8 +52,9 @@ function getNavigation(props: { eventType: Props["eventType"]; enabledAppsNumber: number; enabledWorkflowsNumber: number; + installedAppsNumber: number; }) { - const { eventType, t, enabledAppsNumber, enabledWorkflowsNumber } = props; + const { eventType, t, enabledAppsNumber, installedAppsNumber, enabledWorkflowsNumber } = props; return [ { name: "event_setup_tab_title", @@ -88,7 +90,8 @@ function getNavigation(props: { name: "apps", href: `/event-types/${eventType.id}?tabName=apps`, icon: Icon.FiGrid, - info: `${enabledAppsNumber} ${t("active")}`, + //TODO: Handle proper translation with count handling + info: `${installedAppsNumber} apps, ${enabledAppsNumber} ${t("active")}`, }, { name: "workflows", @@ -106,6 +109,7 @@ function EventTypeSingleLayout({ team, disableBorder, enabledAppsNumber, + installedAppsNumber, enabledWorkflowsNumber, formMethods, }: Props) { @@ -136,7 +140,13 @@ function EventTypeSingleLayout({ // Define tab navigation here const EventTypeTabs = useMemo(() => { - const navigation = getNavigation({ t, eventType, enabledAppsNumber, enabledWorkflowsNumber }); + const navigation = getNavigation({ + t, + eventType, + enabledAppsNumber, + installedAppsNumber, + enabledWorkflowsNumber, + }); // If there is a team put this navigation item within the tabs if (team) navigation.splice(2, 0, { @@ -146,7 +156,7 @@ function EventTypeSingleLayout({ info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin", }); return navigation; - }, [t, eventType, enabledAppsNumber, enabledWorkflowsNumber, team]); + }, [t, eventType, installedAppsNumber, enabledAppsNumber, enabledWorkflowsNumber, team]); const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${ eventType.slug diff --git a/apps/web/components/v2/eventtype/RecurringEventController.tsx b/apps/web/components/v2/eventtype/RecurringEventController.tsx index 4ff446aa87..b4da5e5e5b 100644 --- a/apps/web/components/v2/eventtype/RecurringEventController.tsx +++ b/apps/web/components/v2/eventtype/RecurringEventController.tsx @@ -11,13 +11,11 @@ import { Label, Select, Switch } from "@calcom/ui/v2"; type RecurringEventControllerProps = { recurringEvent: RecurringEvent | null; paymentEnabled: boolean; - onRecurringEventDefined: (value: boolean) => void; }; export default function RecurringEventController({ recurringEvent, paymentEnabled, - onRecurringEventDefined, }: RecurringEventControllerProps) { const { t } = useLocale(); const [recurringEventState, setRecurringEventState] = useState(recurringEvent); diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 0c9fc6cbf0..daccef0ada 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -6,7 +6,6 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; import { Toaster } from "react-hot-toast"; -import { JSONObject } from "superjson/dist/types"; import { sdkActionManager, @@ -26,6 +25,7 @@ import useTheme from "@calcom/lib/hooks/useTheme"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import prisma from "@calcom/prisma"; import { baseEventTypeSelect } from "@calcom/prisma/selects"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { BadgeCheckIcon, Icon } from "@calcom/ui/Icon"; import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally"; @@ -208,42 +208,47 @@ export default function User(props: inferSSRProps) { } User.isThemeSupported = true; -const getEventTypesWithHiddenFromDB = async (userId: number, plan: UserPlan) => { - return await prisma.eventType.findMany({ - where: { - AND: [ - { - teamId: null, - }, - { - OR: [ - { - userId, - }, - { - users: { - some: { - id: userId, +const getEventTypesWithHiddenFromDB = async (userId: number) => { + return ( + await prisma.eventType.findMany({ + where: { + AND: [ + { + teamId: null, + }, + { + OR: [ + { + userId, + }, + { + users: { + some: { + id: userId, + }, }, }, - }, - ], + ], + }, + ], + }, + orderBy: [ + { + position: "desc", + }, + { + id: "asc", }, ], - }, - orderBy: [ - { - position: "desc", + select: { + ...baseEventTypeSelect, + metadata: true, }, - { - id: "asc", - }, - ], - select: { - metadata: true, - ...baseEventTypeSelect, - }, - }); + }) + ).map((eventType) => ({ + ...eventType, + metadata: EventTypeMetaDataSchema.parse(eventType.metadata), + })); }; export const getServerSideProps = async (context: GetServerSidePropsContext) => { @@ -309,7 +314,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => darkBrandColor: user.darkBrandColor, }; - const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id, user.plan); + const eventTypesWithHidden = isDynamicGroup ? [] : await getEventTypesWithHiddenFromDB(user.id); const dataFetchEnd = Date.now(); if (context.query.log === "1") { context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`); @@ -318,7 +323,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const eventTypes = eventTypesRaw.map((eventType) => ({ ...eventType, - metadata: (eventType.metadata || {}) as JSONObject, + metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}), })); const isSingleUser = users.length === 1; diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index 026105231a..07241b0c70 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -1,4 +1,3 @@ -import { UserPlan } from "@prisma/client"; import { GetStaticPaths, GetStaticPropsContext } from "next"; import { JSONObject } from "superjson/dist/types"; import { z } from "zod"; @@ -9,6 +8,7 @@ import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defa import { useLocale } from "@calcom/lib/hooks/useLocale"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import prisma from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { inferSSRProps } from "@lib/types/inferSSRProps"; @@ -148,7 +148,7 @@ async function getUserPageProps(context: GetStaticPropsContext) { //TODO: Use zodSchema to verify it instead of using Type Assertion const locations = eventType.locations ? (eventType.locations as LocationObject[]) : []; const eventTypeObject = Object.assign({}, eventType, { - metadata: (eventType.metadata || {}) as JSONObject, + metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}), recurringEvent: parseRecurringEvent(eventType.recurringEvent), locations: privacyFilteredLocations(locations), users: eventType.users.map((user) => ({ diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index a6becddc5d..69bcc9e2de 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -1,11 +1,11 @@ import { GetServerSidePropsContext } from "next"; -import { JSONObject } from "superjson/dist/types"; import { z } from "zod"; import { privacyFilteredLocations, LocationObject } from "@calcom/core/location"; import { parseRecurringEvent } from "@calcom/lib"; import { availiblityPageEventTypeSelect } from "@calcom/prisma"; import prisma from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { getWorkingHours } from "@lib/availability"; import { GetBookingType } from "@lib/getBooking"; @@ -100,7 +100,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => : []; const eventTypeObject = Object.assign({}, hashedLink.eventType, { - metadata: {} as JSONObject, + metadata: EventTypeMetaDataSchema.parse(hashedLink.eventType.metadata || {}), recurringEvent: parseRecurringEvent(hashedLink.eventType.recurringEvent), periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null, periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null, diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx deleted file mode 100644 index d8e9f84117..0000000000 --- a/apps/web/pages/success.tsx +++ /dev/null @@ -1,867 +0,0 @@ -import { Prisma } from "@prisma/client"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible"; -import classNames from "classnames"; -import { createEvent } from "ics"; -import { GetServerSidePropsContext } from "next"; -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 { z } from "zod"; - -import { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations"; -import { getEventName } from "@calcom/core/event"; -import dayjs from "@calcom/dayjs"; -import { - sdkActionManager, - useEmbedNonStylesConfig, - useIsBackgroundTransparent, - useIsEmbed, -} from "@calcom/embed-core/embed-iframe"; -import { parseRecurringEvent } from "@calcom/lib"; -import CustomBranding from "@calcom/lib/CustomBranding"; -import { getDefaultEvent } from "@calcom/lib/defaultEvents"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import useTheme from "@calcom/lib/hooks/useTheme"; -import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; -import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; -import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat"; -import { localStorage } from "@calcom/lib/webstorage"; -import prisma from "@calcom/prisma"; -import Button from "@calcom/ui/Button"; -import { Icon } from "@calcom/ui/Icon"; -import Logo from "@calcom/ui/Logo"; -import { EmailInput } from "@calcom/ui/form/fields"; - -import { asStringOrThrow } from "@lib/asStringOrNull"; -import { isBrandingHidden } from "@lib/isBrandingHidden"; -import { inferSSRProps } from "@lib/types/inferSSRProps"; - -import CancelBooking from "@components/booking/CancelBooking"; -import { HeadSeo } from "@components/seo/head-seo"; - -import { ssrInit } from "@server/lib/ssr"; - -function redirectToExternalUrl(url: string) { - window.parent.location.href = url; -} - -/** - * Redirects to external URL with query params from current URL. - * Query Params and Hash Fragment if present in external URL are kept intact. - */ -function RedirectionToast({ url }: { url: string }) { - const [timeRemaining, setTimeRemaining] = useState(10); - const [isToastVisible, setIsToastVisible] = useState(true); - const parsedSuccessUrl = new URL(document.URL); - const parsedExternalUrl = new URL(url); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - /* @ts-ignore */ //https://stackoverflow.com/questions/49218765/typescript-and-iterator-type-iterableiteratort-is-not-an-array-type - for (const [name, value] of parsedExternalUrl.searchParams.entries()) { - parsedSuccessUrl.searchParams.set(name, value); - } - - const urlWithSuccessParams = - parsedExternalUrl.origin + - parsedExternalUrl.pathname + - "?" + - parsedSuccessUrl.searchParams.toString() + - parsedExternalUrl.hash; - - const { t } = useLocale(); - const timerRef = useRef(null); - - useEffect(() => { - timerRef.current = window.setInterval(() => { - if (timeRemaining > 0) { - setTimeRemaining((timeRemaining) => { - return timeRemaining - 1; - }); - } else { - redirectToExternalUrl(urlWithSuccessParams); - window.clearInterval(timerRef.current as number); - } - }, 1000); - return () => { - window.clearInterval(timerRef.current as number); - }; - }, [timeRemaining, urlWithSuccessParams]); - - if (!isToastVisible) { - return null; - } - - return ( - <> -
-
-
-
-
-

- Redirecting to {url} ... - - {t("you_are_being_redirected", { url, seconds: timeRemaining })} - -

-
-
- -
-
- -
-
-
-
-
- - ); -} - -type SuccessProps = inferSSRProps; - -export default function Success(props: SuccessProps) { - const { t } = useLocale(); - const router = useRouter(); - const { location: _location, name, reschedule, listingStatus, status, isSuccessBookingPage } = router.query; - const location: ReturnType = Array.isArray(_location) - ? _location[0] || "" - : _location || ""; - - if (!location) { - // Can't use logger.error because it throws error on client. stdout isn't available to it. - console.error(`No location found `); - } - - const [is24h, setIs24h] = useState(isBrowserLocale24h()); - const { data: session } = useSession(); - - const [date, setDate] = useState(dayjs.utc(asStringOrThrow(router.query.date))); - const { eventType, bookingInfo } = props; - - const isBackgroundTransparent = useIsBackgroundTransparent(); - const isEmbed = useIsEmbed(); - const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; - const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; - const [isCancellationMode, setIsCancellationMode] = useState(false); - - const attendeeName = typeof name === "string" ? name : "Nameless"; - - const eventNameObject = { - attendeeName, - eventType: props.eventType.title, - eventName: (props.dynamicEventName as string) || props.eventType.eventName, - host: props.profile.name || "Nameless", - location: location, - t, - }; - const metadata = props.eventType?.metadata as { giphyThankYouPage: string }; - const giphyImage = metadata?.giphyThankYouPage; - - const eventName = getEventName(eventNameObject, true); - const needsConfirmation = eventType.requiresConfirmation && reschedule != "true"; - const isCancelled = status === "CANCELLED" || status === "REJECTED"; - const telemetry = useTelemetry(); - useEffect(() => { - if (top !== window) { - //page_view will be collected automatically by _middleware.ts - telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/success")); - } - }, [telemetry]); - - useEffect(() => { - const users = eventType.users; - if (!sdkActionManager) return; - // TODO: We should probably make it consistent with Webhook payload. Some data is not available here, as and when requirement comes we can add - sdkActionManager.fire("bookingSuccessful", { - eventType, - date: date.toString(), - duration: eventType.length, - organizer: { - name: users[0].name || "Nameless", - email: users[0].email || "Email-less", - timeZone: users[0].timeZone, - }, - confirmed: !needsConfirmation, - // TODO: Add payment details - }); - setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess())); - setIs24h(!!getIs24hClockFromLocalStorage()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventType, needsConfirmation]); - - function eventLink(): string { - const optional: { location?: string } = {}; - if (location) { - optional["location"] = location; - } - - const event = createEvent({ - start: [ - date.toDate().getUTCFullYear(), - (date.toDate().getUTCMonth() as number) + 1, - date.toDate().getUTCDate(), - date.toDate().getUTCHours(), - date.toDate().getUTCMinutes(), - ], - startInputType: "utc", - title: eventName, - description: props.eventType.description ? props.eventType.description : undefined, - /** formatted to required type of description ^ */ - duration: { minutes: props.eventType.length }, - ...optional, - }); - - if (event.error) { - throw event.error; - } - - return encodeURIComponent(event.value ? event.value : false); - } - - function getTitle(): string { - const titleSuffix = props.recurringBookings ? "_recurring" : ""; - if (isCancelled) { - return t("emailed_information_about_cancelled_event"); - } - 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))); - useTheme(isSuccessBookingPage ? props.profile.theme : "light"); - const title = t( - `booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}` - ); - const customInputs = bookingInfo?.customInputs; - - const locationToDisplay = getSuccessPageLocationMessage(location, t, props?.bookingInfo?.status); - - return ( -
- {userIsOwner && !isEmbed && ( - - )} - - -
-
- {eventType.successRedirectUrl ? : null}{" "} -
- -
-
-
-
- ); -} - -Success.isThemeSupported = true; - -type RecurringBookingsProps = { - eventType: SuccessProps["eventType"]; - recurringBookings: SuccessProps["recurringBookings"]; - date: dayjs.Dayjs; - is24h: boolean; - listingStatus: string; -}; - -export function RecurringBookings({ - eventType, - recurringBookings, - date, - listingStatus, -}: RecurringBookingsProps) { - const [moreEventsVisible, setMoreEventsVisible] = useState(false); - const { t } = useLocale(); - - const recurringBookingsSorted = recurringBookings - ? recurringBookings.sort((a, b) => (dayjs(a).isAfter(dayjs(b)) ? 1 : -1)) - : null; - - return recurringBookingsSorted && listingStatus === "recurring" ? ( - <> - {eventType.recurringEvent?.count && ( - - {getEveryFreqFor({ - t, - recurringEvent: eventType.recurringEvent, - recurringCount: recurringBookings?.length ?? undefined, - })} - - )} - {eventType.recurringEvent?.count && - recurringBookingsSorted.slice(0, 4).map((dateStr, idx) => ( -
- {dayjs(dateStr).format("MMMM DD, YYYY")} -
- {dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "} - - ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) - -
- ))} - {recurringBookingsSorted.length > 4 && ( - setMoreEventsVisible(!moreEventsVisible)}> - - {t("plus_more", { count: recurringBookingsSorted.length - 4 })} - - - {eventType.recurringEvent?.count && - recurringBookingsSorted.slice(4).map((dateStr, idx) => ( -
- {dayjs(dateStr).format("MMMM DD, YYYY")} -
- {dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "} - - ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) - -
- ))} -
-
- )} - - ) : ( - <> - {date.format("MMMM DD, YYYY")} -
- {date.format("LT")} - {date.add(eventType.length, "m").format("LT")}{" "} - - ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) - - - ); -} - -const getEventTypesFromDB = async (id: number) => { - const eventType = await prisma.eventType.findUnique({ - where: { - id, - }, - select: { - id: true, - title: true, - description: true, - length: true, - eventName: true, - recurringEvent: true, - requiresConfirmation: true, - userId: true, - successRedirectUrl: true, - locations: true, - users: { - select: { - id: true, - name: true, - username: true, - hideBranding: true, - plan: true, - theme: true, - brandColor: true, - darkBrandColor: true, - email: true, - timeZone: true, - }, - }, - team: { - select: { - slug: true, - name: true, - hideBranding: true, - }, - }, - metadata: true, - }, - }); - - if (!eventType) { - return eventType; - } - - return { - isDynamic: false, - ...eventType, - }; -}; - -const strToNumber = z.string().transform((val, ctx) => { - const parsed = parseInt(val); - if (isNaN(parsed)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Not a number" }); - return parsed; -}); - -const schema = z.object({ - type: strToNumber, - date: z.string().optional(), - user: z.string().optional(), - reschedule: z.string().optional(), - name: z.string().optional(), - email: z.string().optional(), - recur: z.string().optional(), - location: z.string().optional(), - eventSlug: z.string().default("15min"), - eventName: z.string().default(""), - bookingId: strToNumber, -}); - -export async function getServerSideProps(context: GetServerSidePropsContext) { - const ssr = await ssrInit(context); - const parsedQuery = schema.safeParse(context.query); - if (!parsedQuery.success) return { notFound: true }; - const { - type: eventTypeId, - recur: recurringEventIdQuery, - eventSlug: eventTypeSlug, - eventName: dynamicEventName, - bookingId, - user: username, - name, - email, - } = parsedQuery.data; - - const eventTypeRaw = !eventTypeId ? getDefaultEvent(eventTypeSlug) : await getEventTypesFromDB(eventTypeId); - if (!eventTypeRaw) { - return { - notFound: true, - }; - } - - 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: eventTypeRaw.userId, - }, - select: { - id: true, - name: true, - username: true, - hideBranding: true, - plan: true, - theme: true, - brandColor: true, - darkBrandColor: true, - email: true, - timeZone: true, - }, - }); - if (user) { - eventTypeRaw.users.push(user); - } - } - - if (!eventTypeRaw.users.length) { - return { - notFound: true, - }; - } - - const eventType = { - ...eventTypeRaw, - recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent), - }; - - const profile = { - name: eventType.team?.name || eventType.users[0]?.name || null, - email: eventType.team ? null : eventType.users[0].email || null, - theme: (!eventType.team?.name && eventType.users[0]?.theme) || null, - brandColor: eventType.team ? null : eventType.users[0].brandColor || null, - darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null, - slug: eventType.team?.slug || eventType.users[0]?.username || null, - }; - - const where: Prisma.BookingWhereInput = { - id: bookingId, - attendees: { some: { email, name } }, - }; - // Dynamic Event uses EventType from @calcom/lib/defaultEvents(a fake EventType) which doesn't have a real user/team/eventTypeId - // So, you can't look them up in DB. - if (!eventType.isDynamic) { - // A Team Event doesn't have a correct user query param as of now. It is equal to team/{eventSlug} which is not a user, so you can't look it up in DB. - if (!eventType.team) { - // username being equal to profile.slug isn't applicable for Team or Dynamic Events. - where.user = { username }; - } - where.eventTypeId = eventType.id; - } else { - // username being equal to eventSlug for Dynamic Event Booking, it can't be used for user lookup. So, just use eventTypeId which would always be null for Dynamic Event Bookings - where.eventTypeId = null; - } - - const bookingInfo = await prisma.booking.findFirst({ - where, - select: { - title: true, - uid: true, - status: true, - description: true, - customInputs: true, - user: { - select: { - id: true, - 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, - bookingInfo, - }, - }; -} diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 21bb5b749c..9c7cd95bc7 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -1,10 +1,10 @@ import { UserPlan } from "@prisma/client"; import { GetServerSidePropsContext } from "next"; -import { JSONObject } from "superjson/dist/types"; import { privacyFilteredLocations, LocationObject } from "@calcom/core/location"; import { parseRecurringEvent } from "@calcom/lib"; import prisma from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { asStringOrNull } from "@lib/asStringOrNull"; import { getWorkingHours } from "@lib/availability"; @@ -116,9 +116,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => eventType.schedule = null; const locations = eventType.locations ? (eventType.locations as LocationObject[]) : []; - console.log("locations", locations); const eventTypeObject = Object.assign({}, eventType, { - metadata: (eventType.metadata || {}) as JSONObject, + metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}), periodStartDate: eventType.periodStartDate?.toString() ?? null, periodEndDate: eventType.periodEndDate?.toString() ?? null, recurringEvent: parseRecurringEvent(eventType.recurringEvent), diff --git a/apps/web/pages/v2/apps/installed/[category].tsx b/apps/web/pages/v2/apps/installed/[category].tsx index c1e34a12b7..88c0c074cb 100644 --- a/apps/web/pages/v2/apps/installed/[category].tsx +++ b/apps/web/pages/v2/apps/installed/[category].tsx @@ -85,8 +85,8 @@ function ConnectOrDisconnectIntegrationButton(props: { } interface IntegrationsContainerProps { - variant?: "calendar" | "conferencing" | "payment"; - exclude?: App["variant"][]; + variant?: keyof typeof InstalledAppVariants; + exclude?: (keyof typeof InstalledAppVariants)[]; } const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integrations"> }) => { @@ -122,6 +122,8 @@ const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps) const emptyIcon = { calendar: Icon.FiCalendar, conferencing: Icon.FiVideo, + automation: Icon.FiShare2, + analytics: Icon.FiBarChart, payment: Icon.FiCreditCard, other: Icon.FiGrid, }; @@ -186,16 +188,20 @@ export default function InstalledApps({ category }: InferGetServerSidePropsType< {(category === InstalledAppVariants.payment || category === InstalledAppVariants.conferencing) && ( )} - {category === InstalledAppVariants.other && ( - variant !== InstalledAppVariants.other - ) as App["variant"][] - } - /> + {(category === InstalledAppVariants.automation || category === InstalledAppVariants.analytics) && ( + )} {category === InstalledAppVariants.calendar && } + {category === InstalledAppVariants.other && ( + + )} ); } diff --git a/apps/web/pages/v2/event-types/[type]/index.tsx b/apps/web/pages/v2/event-types/[type]/index.tsx index c1773936bb..31a87a9966 100644 --- a/apps/web/pages/v2/event-types/[type]/index.tsx +++ b/apps/web/pages/v2/event-types/[type]/index.tsx @@ -5,16 +5,17 @@ import { GetServerSidePropsContext } from "next"; import { useRouter } from "next/router"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import { JSONObject } from "superjson/dist/types"; import { z } from "zod"; import { StripeData } from "@calcom/app-store/stripepayment/lib/server"; -import getApps, { getLocationOptions } from "@calcom/app-store/utils"; +import getApps, { getEventTypeAppData, getLocationOptions } from "@calcom/app-store/utils"; import { LocationObject, EventLocationType } from "@calcom/core/location"; import { parseRecurringEvent, parseBookingLimit, validateBookingLimitOrder } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; +import getStripeAppData from "@calcom/lib/getStripeAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import prisma from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; import type { BookingLimit, RecurringEvent } from "@calcom/types/Calendar"; import { Form } from "@calcom/ui/form/fields"; @@ -49,8 +50,6 @@ export type FormValues = { requiresConfirmation: boolean; recurringEvent: RecurringEvent | null; schedulingType: SchedulingType | null; - price: number; - currency: string; hidden: boolean; hideCalendarNotes: boolean; hashedLink: string | undefined; @@ -75,14 +74,12 @@ export type FormValues = { beforeBufferTime: number; afterBufferTime: number; slotInterval: number | null; + metadata: z.infer; destinationCalendar: { integration: string; externalId: string; }; successRedirectUrl: string; - giphyThankYouPage: string; - blockchainId: number; - smartContractAddress: string; bookingLimits?: BookingLimit; }; @@ -97,8 +94,16 @@ export type EventTypeSetupInfered = inferSSRProps; const EventTypePage = (props: inferSSRProps) => { const { t } = useLocale(); + const { data: eventTypeApps } = trpc.useQuery([ + "viewer.apps", + { + extendsFeature: "EventType", + }, + ]); const { eventType: dbEventType, locationOptions, team, teamMembers } = props; + // TODO: It isn't a good idea to maintain state using setEventType. If we want to connect the SSR'd data to tRPC, we should useQuery(["viewer.eventTypes.get"]) with initialData + // Due to this change, when Form is saved, there is no way to propagate that info to eventType (e.g. disabling stripe app doesn't allow recurring tab to be enabled without refresh). const [eventType, setEventType] = useState(dbEventType); const animationParentRef = useRef(null); const router = useRouter(); @@ -159,9 +164,20 @@ const EventTypePage = (props: inferSSRProps) => { }, schedulingType: eventType.schedulingType, minimumBookingNotice: eventType.minimumBookingNotice, + metadata: eventType.metadata, }, }); + const appsMetadata = formMethods.getValues("metadata")?.apps; + const numberOfInstalledApps = eventTypeApps?.filter((app) => app.isInstalled).length || 0; + let numberOfActiveApps = 0; + + if (appsMetadata) { + numberOfActiveApps = Object.entries(appsMetadata).filter( + ([appId, appData]) => eventTypeApps?.find((app) => app.slug === appId)?.isInstalled && appData.enabled + ).length; + } + const tabMap = { setup: ( ) => { ), limits: , advanced: , - recurring: ( - - ), - apps: ( - - ), + recurring: , + apps: , workflows: ( ) => { return ( ) => { const { periodDates, periodCountCalendarDays, - giphyThankYouPage, beforeBufferTime, afterBufferTime, seatsPerTimeSlot, bookingLimits, recurringEvent, locations, - blockchainId, - smartContractAddress, + metadata, // We don't need to send it to the backend // eslint-disable-next-line @typescript-eslint/no-unused-vars seatsPerTimeSlotEnabled, @@ -250,11 +255,7 @@ const EventTypePage = (props: inferSSRProps) => { afterEventBuffer: afterBufferTime, bookingLimits, seatsPerTimeSlot, - metadata: { - ...(giphyThankYouPage ? { giphyThankYouPage } : {}), - ...(smartContractAddress ? { smartContractAddress } : {}), - ...(blockchainId ? { blockchainId } : { blockchainId: 1 }), - }, + metadata, }); }}>
@@ -355,6 +356,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => hashedLink: true, bookingLimits: true, successRedirectUrl: true, + currency: true, team: { select: { id: true, @@ -383,7 +385,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, userId: true, price: true, - currency: true, destinationCalendar: true, seatsPerTimeSlot: true, workflows: { @@ -428,19 +429,38 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }); const { locations, metadata, ...restEventType } = rawEventType; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const newMetadata = EventTypeMetaDataSchema.parse(metadata || {})!; + const apps = newMetadata.apps || {}; + const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata }; + newMetadata.apps = { + ...apps, + stripe: { + ...getStripeAppData(eventTypeWithParsedMetadata, true), + currency: + ( + credentials.find((integration) => integration.type === "stripe_payment") + ?.key as unknown as StripeData + )?.default_currency || "usd", + }, + giphy: getEventTypeAppData(eventTypeWithParsedMetadata, "giphy", true), + rainbow: getEventTypeAppData(eventTypeWithParsedMetadata, "rainbow", true), + }; + + // TODO: How to extract metadata schema from _EventTypeModel to be able to parse it? + // const parsedMetaData = _EventTypeModel.parse(newMetadata); + const parsedMetaData = newMetadata; + const eventType = { ...restEventType, schedule: rawEventType.schedule?.id || rawEventType.users[0].defaultScheduleId, recurringEvent: parseRecurringEvent(restEventType.recurringEvent), bookingLimits: parseBookingLimit(restEventType.bookingLimits), locations: locations as unknown as LocationObject[], - metadata: (metadata || {}) as JSONObject, + metadata: parsedMetaData, }; - const hasGiphyIntegration = !!credentials.find((credential) => credential.type === "giphy_other"); - - const hasRainbowIntegration = !!credentials.find((credential) => credential.type === "rainbow_web3"); - // backwards compat if (eventType.users.length === 0 && !eventType.team) { const fallbackUser = await prisma.user.findUnique({ @@ -456,10 +476,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const t = await getTranslation(currentUser?.locale ?? "en", "common"); const integrations = getApps(credentials); const locationOptions = getLocationOptions(integrations, t); - const hasPaymentIntegration = !!credentials.find((credential) => credential.type === "stripe_payment"); - const currency = - (credentials.find((integration) => integration.type === "stripe_payment")?.key as unknown as StripeData) - ?.default_currency || "usd"; const eventTypeObject = Object.assign({}, eventType, { periodStartDate: eventType.periodStartDate?.toString() ?? null, @@ -486,10 +502,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => locationOptions, team: eventTypeObject.team || null, teamMembers, - hasPaymentIntegration, - hasGiphyIntegration, - hasRainbowIntegration, - currency, currentUserMembership, }, }; diff --git a/apps/web/pages/v2/event-types/index.tsx b/apps/web/pages/v2/event-types/index.tsx index a361dbcf36..332bfc3297 100644 --- a/apps/web/pages/v2/event-types/index.tsx +++ b/apps/web/pages/v2/event-types/index.tsx @@ -21,12 +21,12 @@ import Dropdown, { } from "@calcom/ui/v2/core/Dropdown"; import Shell from "@calcom/ui/v2/core/Shell"; import CreateEventTypeButton from "@calcom/ui/v2/modules/event-types/CreateEventType"; +import EventTypeDescription from "@calcom/ui/v2/modules/event-types/EventTypeDescription"; import { withQuery } from "@lib/QueryCell"; import { HttpError } from "@lib/core/http/error"; import { EmbedButton, EmbedDialog } from "@components/Embed"; -import EventTypeDescription from "@components/eventtype/EventTypeDescription"; import Avatar from "@components/ui/Avatar"; import AvatarGroup from "@components/ui/AvatarGroup"; import SkeletonLoader from "@components/v2/eventtype/SkeletonLoader"; diff --git a/apps/web/pages/v2/success.tsx b/apps/web/pages/v2/success.tsx index b851f6cce9..a123ac1999 100644 --- a/apps/web/pages/v2/success.tsx +++ b/apps/web/pages/v2/success.tsx @@ -10,7 +10,9 @@ import { useEffect, useRef, useState } from "react"; import { RRule } from "rrule"; import { z } from "zod"; +import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager"; import { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations"; +import { getEventTypeAppData } from "@calcom/app-store/utils"; import { getEventName } from "@calcom/core/event"; import dayjs from "@calcom/dayjs"; import { @@ -28,7 +30,8 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat"; import { localStorage } from "@calcom/lib/webstorage"; -import prisma from "@calcom/prisma"; +import prisma, { baseUserSelect } from "@calcom/prisma"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import Button from "@calcom/ui/Button"; import { Icon } from "@calcom/ui/Icon"; import { EmailInput } from "@calcom/ui/form/fields"; @@ -170,8 +173,9 @@ export default function Success(props: SuccessProps) { location: location, t, }; - const metadata = props.eventType?.metadata as { giphyThankYouPage: string }; - const giphyImage = metadata?.giphyThankYouPage; + + const giphyAppData = getEventTypeAppData(eventType, "giphy"); + const giphyImage = giphyAppData?.thankYouPage; const eventName = getEventName(eventNameObject, true); const needsConfirmation = eventType.requiresConfirmation && reschedule != "true"; @@ -270,6 +274,7 @@ export default function Success(props: SuccessProps) {
)} +
@@ -688,6 +693,8 @@ const getEventTypesFromDB = async (id: number) => { userId: true, successRedirectUrl: true, locations: true, + price: true, + currency: true, users: { select: { id: true, @@ -717,9 +724,12 @@ const getEventTypesFromDB = async (id: number) => { return eventType; } + const metadata = EventTypeMetaDataSchema.parse(eventType.metadata); + return { isDynamic: false, ...eventType, + metadata, }; }; @@ -771,18 +781,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { where: { id: eventTypeRaw.userId, }, - select: { - id: true, - name: true, - username: true, - hideBranding: true, - plan: true, - theme: true, - brandColor: true, - darkBrandColor: true, - email: true, - timeZone: true, - }, + select: baseUserSelect, }); if (user) { eventTypeRaw.users.push(user); @@ -797,6 +796,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const eventType = { ...eventTypeRaw, + metadata: EventTypeMetaDataSchema.parse(eventTypeRaw.metadata), recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent), }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 30f96fdab1..584d59a640 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -778,14 +778,19 @@ "no_category_apps_description_calendar": "Add a calendar app to check for conflicts to prevent double bookings", "no_category_apps_description_conferencing": "Try adding a conference app to intergrate video call with your clients", "no_category_apps_description_payment": "Add a payment app to ease transaction between you and your clients", + "no_category_apps_description_analytics": "Add an analytics app for your booking pages", + "no_category_apps_description_automation": "Add an automation app to use", "no_category_apps_description_other": "Add any other type of app to do all sorts of things", "installed_app_calendar_description": "Set the calendar(s) to check for conflicts to prevent double bookings.", "installed_app_conferencing_description": "Add your favourite video conferencing apps for your meetings", "installed_app_payment_description": "Configure which payment processing services to use when charging your clients.", + "installed_app_analytics_description": "Configure which analytics apps to use for your booking pages", "installed_app_other_description": "All your installed apps from other categories.", + "installed_app_automation_description": "Configure which automations apps to use", + "analytics": "Analytics", "empty_installed_apps_headline": "No apps installed", "empty_installed_apps_description": "Apps enable you to enhance your workflow and improve your scheduling life significantly.", - "empty_installed_apps_button": "Explore the App Store", + "empty_installed_apps_button": "Explore the App Store or Install from below apps", "manage_your_connected_apps": "Manage your installed apps or change settings", "browse_apps": "Browse Apps", "features": "Features", @@ -1169,6 +1174,8 @@ "connect_conference_apps": "Connect conference apps", "connect_calendar_apps": "Connect calendar apps", "connect_payment_apps": "Connect payment apps", + "connect_automation_apps": "Connect automation apps", + "connect_analytics_apps": "Connect analytics apps", "connect_other_apps": "Connect other apps", "current_step_of_total": "Step {{currentStep}} of {{maxSteps}}", "add_variable": "Add variable", @@ -1245,7 +1252,7 @@ "webhooks_description": "Receive meeting data in real-time when something happens in Cal.com", "api_keys_description": "Generate API keys for accessing your own account", "new_api_key": "New API key", - "active": "Active", + "active": "active", "api_key_updated": "API key name updated", "api_key_update_failed": "Error updating API key name", "embeds_title": "HTML iframe embed", diff --git a/packages/app-store-cli/src/CliApp.tsx b/packages/app-store-cli/src/CliApp.tsx index 395a2487b3..277e8099ee 100644 --- a/packages/app-store-cli/src/CliApp.tsx +++ b/packages/app-store-cli/src/CliApp.tsx @@ -1,4 +1,3 @@ -import child_process from "child_process"; import fs from "fs"; import { Box, Text } from "ink"; import SelectInput from "ink-select-input"; @@ -6,6 +5,8 @@ import TextInput from "ink-text-input"; import path from "path"; import React, { FC, useEffect, useState } from "react"; +import execSync from "./execSync"; + const slugify = (str: string) => { // It is to be a valid dir name, a valid JS variable name and a valid URL path return str.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(); @@ -24,14 +25,7 @@ function getAppDirPath(slug: any) { const appStoreDir = path.resolve(__dirname, "..", "..", "app-store"); const workspaceDir = path.resolve(__dirname, "..", "..", ".."); -const execSync = (...args) => { - const result = child_process.execSync(...args).toString(); - if (process.env.DEBUG === "1") { - console.log(`$: ${args[0]}`); - console.log(result); - } - return args[0]; -}; + function absolutePath(appRelativePath) { return path.join(appStoreDir, appRelativePath); } @@ -53,6 +47,7 @@ const BaseAppFork = { slug, publisherName, publisherEmail, + extendsFeature, }) { const appDirPath = getAppDirPath(slug); let message = !editMode ? "Forking base app" : "Updating app"; @@ -106,6 +101,7 @@ const BaseAppFork = { publisher: publisherName, email: publisherEmail, description: appDescription, + extendsFeature: extendsFeature, // TODO: Use this to avoid edit and delete on the apps created outside of cli __createdUsingCli: true, ...dataFromCategory, @@ -211,10 +207,22 @@ const CreateApp = ({ noDbUpdate, slug = null, editMode = false }) => { { label: "Messaging", value: "messaging" }, { label: "Web3", value: "web3" }, { label: "Automation", value: "automation" }, + { label: "Analytics", value: "analytics" }, { label: "Other", value: "other" }, ], explainer: "This is how apps are categorized in App Store.", }, + { + label: "What kind of app would you consider it?", + name: "extendsFeature", + options: [ + { label: "User", value: "User" }, + { + label: "Event Type(Available for configuration in Apps tab for all Event Types)", + value: "EventType", + }, + ], + }, { label: "Publisher Name", name: "publisherName", type: "text", explainer: "Let users know who you are" }, { label: "Publisher Email", @@ -232,6 +240,11 @@ const CreateApp = ({ noDbUpdate, slug = null, editMode = false }) => { const appDescription = appInputData["appDescription"]; const publisherName = appInputData["publisherName"]; const publisherEmail = appInputData["publisherEmail"]; + let extendsFeature = appInputData["extendsFeature"] || []; + if (rawCategory === "analytics") { + // Analytics only means EventType Analytics as of now + extendsFeature = "EventType"; + } const [status, setStatus] = useState<"inProgress" | "done">("inProgress"); const allFieldsFilled = inputIndex === fields.length; const [progressUpdate, setProgressUpdate] = useState(""); @@ -251,6 +264,7 @@ const CreateApp = ({ noDbUpdate, slug = null, editMode = false }) => { slug, publisherName, publisherEmail, + extendsFeature, }); for (const item of it) { setProgressUpdate(item); @@ -284,7 +298,7 @@ const CreateApp = ({ noDbUpdate, slug = null, editMode = false }) => { Just wait for a few seconds for process to exit and then you are good to go. Your App code exists at ${getAppDirPath(slug)} - Tip: Go and change the logo of your app by replacing {getAppDirPath(slug) + "/static/icon.svg"} + Tip : Go and change the logo of your app by replacing {getAppDirPath(slug) + "/static/icon.svg"} App Summary: diff --git a/packages/app-store-cli/src/app-store.ts b/packages/app-store-cli/src/app-store.ts index bfb7b72b68..6237f2fc88 100644 --- a/packages/app-store-cli/src/app-store.ts +++ b/packages/app-store-cli/src/app-store.ts @@ -4,8 +4,19 @@ import { debounce } from "lodash"; import path from "path"; import prettier from "prettier"; -import prettierConfig from "../../config/prettier-preset"; +import { AppMeta } from "@calcom/types/App"; +import prettierConfig from "../../config/prettier-preset"; +import execSync from "./execSync"; + +function isFileThere(path) { + try { + fs.statSync(path); + return true; + } catch (e) { + return false; + } +} let isInWatchMode = false; if (process.argv[2] === "--watch") { isInWatchMode = true; @@ -13,12 +24,17 @@ if (process.argv[2] === "--watch") { const formatOutput = (source: string) => prettier.format(source, prettierConfig); -const getVariableName = function (appName) { +const getVariableName = function (appName: string) { return appName.replace("-", "_"); }; +const getAppId = function (app: { name: string }) { + // Handle stripe separately as it's an old app with different dirName than slug/appId + return app.name === "stripepayment" ? "stripe" : app.name; +}; + const APP_STORE_PATH = path.join(__dirname, "..", "..", "app-store"); -type App = { +type App = Partial & { name: string; path: string; }; @@ -45,7 +61,7 @@ function getAppName(candidatePath) { function generateFiles() { const browserOutput = [`import dynamic from "next/dynamic"`]; const serverOutput = []; - const appDirs: App[] = []; + const appDirs: { name: string; path: string }[] = []; fs.readdirSync(`${APP_STORE_PATH}`).forEach(function (dir) { if (dir === "ee") { @@ -74,10 +90,35 @@ function generateFiles() { function forEachAppDir(callback: (arg: App) => void) { for (let i = 0; i < appDirs.length; i++) { - callback(appDirs[i]); + const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json"); + let app; + + if (fs.existsSync(configPath)) { + app = JSON.parse(fs.readFileSync(configPath).toString()); + } else { + app = {}; + } + + callback({ + ...app, + name: appDirs[i].name, + path: appDirs[i].path, + }); } } + forEachAppDir((app) => { + const templateDestinationDir = path.join(APP_STORE_PATH, app.path, "extensions"); + const templateDestinationFilePath = path.join(templateDestinationDir, "EventTypeAppCard.tsx"); + const zodDestinationFilePath = path.join(APP_STORE_PATH, app.path, "zod.ts"); + + if (app.extendsFeature === "EventType" && !isFileThere(templateDestinationFilePath)) { + execSync(`mkdir -p ${templateDestinationDir}`); + execSync(`cp ../app-store/_templates/extensions/EventTypeAppCard.tsx ${templateDestinationFilePath}`); + execSync(`cp ../app-store/_templates/zod.ts ${zodDestinationFilePath}`); + } + }); + function getObjectExporter( objectName, { @@ -133,6 +174,20 @@ function generateFiles() { }) ); + browserOutput.push( + ...getObjectExporter("appDataSchemas", { + fileToBeImported: "zod.ts", + // Import path must have / even for windows and not \ + importBuilder: (app) => + `import { appDataSchema as ${getVariableName(app.name)}_schema } from "./${app.path.replace( + /\\/g, + "/" + )}/zod";`, + // Key must be appId as this is used by eventType metadata and lookup is by appId + entryBuilder: (app) => ` "${getAppId(app)}":${getVariableName(app.name)}_schema ,`, + }) + ); + browserOutput.push( ...getObjectExporter("InstallAppButtonMap", { fileToBeImported: "components/InstallAppButton.tsx", @@ -141,6 +196,14 @@ function generateFiles() { }) ); + browserOutput.push( + ...getObjectExporter("EventTypeAddonMap", { + fileToBeImported: path.join("extensions", "EventTypeAppCard.tsx"), + entryBuilder: (app) => + ` ${app.name}: dynamic(() =>import("./${app.path}/extensions/EventTypeAppCard")),`, + }) + ); + const banner = `/** This file is autogenerated using the command \`yarn app-store:build --watch\`. Don't modify this file manually. diff --git a/packages/app-store-cli/src/execSync.ts b/packages/app-store-cli/src/execSync.ts new file mode 100644 index 0000000000..807fbd7e31 --- /dev/null +++ b/packages/app-store-cli/src/execSync.ts @@ -0,0 +1,13 @@ +import child_process from "child_process"; + +const execSync = (cmd: string) => { + if (process.env.DEBUG === "1") { + console.log(`${process.cwd()}$: ${cmd}`); + } + const result = child_process.execSync(cmd).toString(); + if (process.env.DEBUG === "1") { + console.log(result); + } + return cmd; +}; +export default execSync; diff --git a/packages/app-store/BookingPageTagManager.tsx b/packages/app-store/BookingPageTagManager.tsx new file mode 100644 index 0000000000..3fc9940efe --- /dev/null +++ b/packages/app-store/BookingPageTagManager.tsx @@ -0,0 +1,36 @@ +import Script from "next/script"; + +import { getEventTypeAppData } from "@calcom/app-store/utils"; + +// TODO: Maintain it from config.json maybe +const trackingApps: Record> = { + fathom: { + src: "https://cdn.usefathom.com/script.js", + "data-site": "{TRACKING_ID}", + }, +}; + +export default function BookingPageTagManager({ + eventType, +}: { + eventType: Parameters[0]; +}) { + return ( + <> + {Object.entries(trackingApps).map(([appId, scriptConfig]) => { + const trackingId = getEventTypeAppData(eventType, "fathom")?.trackingId; + if (!trackingId) { + return null; + } + const parsedScriptConfig: Record = {}; + Object.entries(scriptConfig).forEach(([name, value]) => { + if (typeof value === "string") { + value = value.replace("{TRACKING_ID}", trackingId); + } + parsedScriptConfig[name] = value; + }); + return