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 <hariombalhara@gmail.com>

* Fix lint error

* Fix runtime error with legayAppData being undefined

* Remove duplicate automation key

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Hariom Balhara
2022-10-14 10:24:43 -06:00
committed by GitHub
co-authored by Peer Richelsen
parent 215e3c93d6
commit 271d4319b9
79 changed files with 1324 additions and 1466 deletions
+5 -5
View File
@@ -11,14 +11,14 @@ export type GateState = {
type GateProps = {
children: React.ReactNode;
gates: Gate[];
metadata: JSONObject;
appData: JSONObject;
dispatch: Dispatch<Partial<GateState>>;
};
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<GateProps> = ({ children, gates, metadata, dispatch }) => {
const Gates: React.FC<GateProps> = ({ children, gates, appData, dispatch }) => {
const [rainbowToken, setRainbowToken] = useState<string>();
useEffect(() => {
@@ -31,12 +31,12 @@ const Gates: React.FC<GateProps> = ({ 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 = (
<RainbowGate
setToken={setRainbowToken}
chainId={metadata.blockchainId as number}
tokenAddress={metadata.smartContractAddress as string}>
chainId={appData.blockchainId as number}
tokenAddress={appData.smartContractAddress as string}>
{gateWrappers}
</RainbowGate>
);
@@ -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 (
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
<HeadSeo
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
@@ -391,6 +396,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
noindex: eventType.hidden,
}}
/>
<BookingPageTagManager eventType={eventType} />
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<div>
<main
@@ -442,14 +448,14 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
</div>
</div>
)}
{eventType.price > 0 && (
{stripeAppData.price > 0 && (
<p className="-ml-2 px-2 text-sm font-medium">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
value={stripeAppData.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
currency={stripeAppData.currency.toUpperCase()}
/>
</IntlProvider>
</p>
@@ -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 (
<Gates gates={gates} metadata={eventType.metadata} dispatch={gateDispatcher}>
<Gates gates={gates} appData={rainbowAppData} dispatch={gateDispatcher}>
<Head>
<title>
{rescheduleUid
@@ -436,6 +441,7 @@ const BookingPage = ({
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<BookingPageTagManager eventType={eventType} />
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<main
className={classNames(
@@ -452,14 +458,14 @@ const BookingPage = ({
<div className="sm:flex">
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
{eventType.price > 0 && (
{stripeAppData.price > 0 && (
<p className="text-bookinglight -ml-2 px-2 text-sm ">
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
value={stripeAppData.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
currency={stripeAppData.currency.toUpperCase()}
/>
</IntlProvider>
</p>
@@ -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<Prisma.EventTypeArgs>()({
select: baseEventTypeSelect,
});
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
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 (
<>
<div className={classNames("text-gray-600 dark:text-white", className)}>
{eventType.description && (
<h2 className="max-w-[280px] overflow-hidden text-ellipsis sm:max-w-[500px]">
{eventType.description.substring(0, 100)}
{eventType.description.length > 100 && "..."}
</h2>
)}
<ul className="mt-2 flex flex-wrap space-x-1 sm:flex-nowrap ">
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiClock className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{eventType.length} {t("minutes")}
</li>
{eventType.schedulingType ? (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiUsers className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
{eventType.schedulingType === SchedulingType.COLLECTIVE && t("collective")}
</li>
) : (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiUser className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("1_on_1")}
</li>
)}
{recurringEvent?.count && recurringEvent.count > 0 && (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiRefreshCw className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("repeats_up_to", {
count: recurringEvent.count,
})}
</li>
)}
{eventType.price > 0 && (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiCreditCard className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
/>
</IntlProvider>
</li>
)}
{eventType.requiresConfirmation && (
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
<Icon.FiCheckSquare className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("requires_confirmation")}
</li>
)}
</ul>
</div>
</>
);
};
export default EventTypeDescription;
@@ -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 (
<InstallAppButton
type={app.type}
isProOnly={app.isProOnly}
wrapperClassName={classNames("[@media(max-width:260px)]:w-full", className)}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
onClick: () => {
mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug, isOmniInstall: true });
},
};
}
return (
<Button
loading={mutation.isLoading}
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon={Icon.FiPlus}
{...props}>
{t("install")}
</Button>
);
}}
/>
);
}
+117 -194
View File
@@ -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 (
<div className="mb-4 rounded-md border border-gray-200 p-8">
<div className="flex w-full">
<img src={logo} alt={name} className="mr-3 h-auto w-[42px] rounded-sm" />
<div className="flex flex-col">
<span className="font-semibold leading-none text-black">{name}</span>
<p className="pt-2 text-sm font-normal text-gray-600">{description}</p>
</div>
<div className="ml-auto flex items-center">
<Switch onCheckedChange={switchOnClick} checked={switchChecked} />
</div>
</div>
{children}
</div>
);
};
export const EventAppsTab = ({
hasPaymentIntegration,
currency,
type EventType = Pick<EventTypeSetupInfered, "eventType">["eventType"];
function AppCardWrapper({
app,
eventType,
hasGiphyIntegration,
hasRainbowIntegration,
}: Pick<
EventTypeSetupInfered,
"eventType" | "hasPaymentIntegration" | "hasGiphyIntegration" | "hasRainbowIntegration" | "currency"
>) => {
const formMethods = useFormContext<FormValues>();
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 (
<div className="pt-4 before:border-0">
<EmptyScreen
Icon={Icon.FiGrid}
headline={t("empty_installed_apps_headline")}
description={t("empty_installed_apps_description")}
buttonRaw={
<Button target="_blank" color="secondary" href="/apps">
{t("empty_installed_apps_button")}{" "}
</Button>
}
/>
</div>
);
if (!Component) {
throw new Error('No component found for "' + dirName + '"');
}
return (
<div className="pt-4 before:border-0">
{/* TODO:Strip isnt fully setup yet */}
{hasPaymentIntegration && (
<AppCard
name="Stripe"
switchChecked={requirePayment}
switchOnClick={(e) => {
if (!e) {
formMethods.setValue("price", 0);
setRequirePayment(false);
} else {
setRequirePayment(true);
}
}}
description={
<>
<div className="">
{t("require_payment")} (0.5% +{" "}
<IntlProvider locale="en">
<FormattedNumber value={0.1} style="currency" currency={currency} />
</IntlProvider>{" "}
{t("commission_per_transaction")})
</div>
</>
}
logo="/api/app-store/stripepayment/icon.svg">
<>
{recurringEventDefined ? (
<Alert severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<div className="block items-center sm:flex">
<Controller
defaultValue={eventType.price}
control={formMethods.control}
name="price"
render={({ field }) => (
<TextField
label=""
addOnLeading={<>{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}
/>
)}
/>
</div>
)
)}
</>
</AppCard>
)}
{hasGiphyIntegration && (
<AppCard
name="Giphy"
description={t("confirmation_page_gif")}
logo="/api/app-store/giphy/icon.svg"
switchOnClick={(e) => {
if (!e) {
setShowGifSelection(false);
formMethods.setValue("giphyThankYouPage", "");
} else {
setShowGifSelection(true);
}
}}
switchChecked={showGifSelection}>
{showGifSelection && (
<SelectGifInput
defaultValue={eventType.metadata["giphyThankYouPage"] as string}
onChange={(url: string) => {
formMethods.setValue("giphyThankYouPage", url);
}}
/>
)}
</AppCard>
)}
{hasRainbowIntegration && (
<AppCard
name="Rainbow"
description={t("confirmation_page_rainbow")}
logo="/api/app-store/rainbow/icon.svg"
switchOnClick={(e) => {
if (!e) {
formMethods.setValue("blockchainId", 1);
formMethods.setValue("smartContractAddress", "");
}
<ErrorBoundary message={`There is some problem with ${app.name} App`}>
<EventTypeAppContext.Provider value={[getAppData, setAppData]}>
<Component key={app.slug} app={app} eventType={eventType} />
</EventTypeAppContext.Provider>
</ErrorBoundary>
);
}
setShowRainbowSection(e);
}}
switchChecked={showRainbowSection}>
{showRainbowSection && (
<RainbowInstallForm
formMethods={formMethods}
blockchainId={(eventType.metadata.blockchainId as number) || 1}
smartContractAddress={(eventType.metadata.smartContractAddress as string) || ""}
export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
const { t } = useLocale();
const { data: eventTypeApps, isLoading } = trpc.useQuery([
"viewer.apps",
{
extendsFeature: "EventType",
},
]);
const methods = useFormContext<FormValues>();
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 (
<>
<div>
<div className="before:border-0">
{!isLoading && !installedApps?.length ? (
<EmptyScreen
Icon={Icon.FiGrid}
headline={t("empty_installed_apps_headline")}
description={t("empty_installed_apps_description")}
buttonRaw={
<Button target="_blank" color="secondary" href="/apps">
{t("empty_installed_apps_button")}{" "}
</Button>
}
/>
)}
</AppCard>
)}
</div>
) : null}
{installedApps?.map((app) => (
<AppCardWrapper
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
/>
))}
</div>
</div>
<div>
{!isLoading && notInstalledApps?.length ? (
<h2 className="mt-0 mb-2 text-lg font-semibold text-gray-900">Available Apps</h2>
) : null}
<div className="before:border-0">
{notInstalledApps?.map((app) => (
<AppCardWrapper
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
/>
))}
</div>
</div>
</>
);
};
@@ -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<EventTypeSetupInfered, "eventType" | "hasPaymentIntegration">) => {
const requirePayment = eventType.price > 0;
const [recurringEventDefined, setRecurringEventDefined] = useState(
eventType.recurringEvent?.count !== undefined
);
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupInfered, "eventType">) => {
const stripeAppData = getStripeAppData(eventType);
const requirePayment = stripeAppData.price > 0;
return (
<div className="">
<RecurringEventController
paymentEnabled={hasPaymentIntegration && requirePayment}
onRecurringEventDefined={setRecurringEventDefined}
recurringEvent={eventType.recurringEvent}
/>
<RecurringEventController paymentEnabled={requirePayment} recurringEvent={eventType.recurringEvent} />
</div>
);
};
@@ -42,6 +42,7 @@ type Props = {
team: EventTypeSetupInfered["team"];
disableBorder?: boolean;
enabledAppsNumber: number;
installedAppsNumber: number;
enabledWorkflowsNumber: number;
formMethods: UseFormReturn<FormValues>;
};
@@ -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
@@ -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 | null>(recurringEvent);
+39 -34
View File
@@ -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<typeof getServerSideProps>) {
}
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;
+2 -2
View File
@@ -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) => ({
+2 -2
View File
@@ -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,
-867
View File
@@ -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<number | null>(null);
useEffect(() => {
timerRef.current = window.setInterval(() => {
if (timeRemaining > 0) {
setTimeRemaining((timeRemaining) => {
return timeRemaining - 1;
});
} else {
redirectToExternalUrl(urlWithSuccessParams);
window.clearInterval(timerRef.current as number);
}
}, 1000);
return () => {
window.clearInterval(timerRef.current as number);
};
}, [timeRemaining, urlWithSuccessParams]);
if (!isToastVisible) {
return null;
}
return (
<>
<div className="relative z-[60] pb-2 sm:pb-5">
<div className="mx-auto w-full sm:max-w-7xl sm:px-2 lg:px-8">
<div className="border border-green-600 bg-green-500 p-2 sm:p-3">
<div className="flex flex-wrap items-center justify-between">
<div className="flex w-0 flex-1 items-center">
<p className="truncate font-medium text-white sm:mx-3">
<span className="md:hidden">Redirecting to {url} ...</span>
<span className="hidden md:inline">
{t("you_are_being_redirected", { url, seconds: timeRemaining })}
</span>
</p>
</div>
<div className="order-3 mt-2 w-full flex-shrink-0 sm:order-2 sm:mt-0 sm:w-auto">
<button
onClick={() => {
redirectToExternalUrl(urlWithSuccessParams);
}}
className="flex w-full items-center justify-center rounded-sm border border-transparent bg-white px-4 py-2 text-sm font-medium text-green-600 hover:bg-green-50">
{t("continue")}
</button>
</div>
<div className="order-2 flex-shrink-0 sm:order-3 sm:ml-2">
<button
type="button"
onClick={() => {
setIsToastVisible(false);
window.clearInterval(timerRef.current as number);
}}
className="-mr-1 flex rounded-md p-2 hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-white">
<Icon.FiX className="h-6 w-6 text-white" />
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}
type SuccessProps = inferSSRProps<typeof getServerSideProps>;
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<typeof getEventLocationValue> = 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 (
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
{userIsOwner && !isEmbed && (
<div className="mt-2 ml-4 -mb-4">
<Link href={eventType.recurringEvent?.count ? "/bookings/recurring" : "/bookings/upcoming"}>
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-transparent dark:hover:text-white">
<Icon.FiChevronLeft className="h-5 w-5" /> {t("back_to_bookings")}
</a>
</Link>
</div>
)}
<HeadSeo title={title} description={title} />
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
{eventType.successRedirectUrl ? <RedirectionToast url={eventType.successRedirectUrl} /> : null}{" "}
<div
className={classNames(
shouldAlignCentrally ? "text-center" : "",
"flex items-end justify-center px-4 pt-4 pb-20 sm:block sm:p-0"
)}>
<div
className={classNames("my-4 transition-opacity sm:my-0", isEmbed ? "" : " inset-0")}
aria-hidden="true">
<div
className={classNames(
"inline-block transform overflow-hidden rounded-md border sm:my-8 sm:max-w-lg",
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 bg-white dark:border-neutral-700",
"px-8 pt-5 pb-4 text-left align-bottom transition-all sm:w-full sm:py-6 sm:align-middle"
)}
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline">
<div>
<div
className={classNames(
"mx-auto flex items-center justify-center",
!giphyImage && !isCancelled ? "h-12 w-12 rounded-full bg-green-100" : "",
isCancelled ? "h-12 w-12 rounded-full bg-red-100" : ""
)}>
{giphyImage && !needsConfirmation && (
// eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} alt="Gif from Giphy" />
)}
{!giphyImage && !needsConfirmation && !isCancelled && (
<Icon.FiCheck className="h-8 w-8 text-green-600" />
)}
{needsConfirmation && !isCancelled && <Icon.FiClock className="h-8 w-8 text-green-600" />}
{isCancelled && <Icon.FiX className="h-8 w-8 text-red-600" />}
</div>
<div className="mt-3 text-center sm:mt-5">
<h3
className="text-2xl font-semibold leading-6 text-neutral-900 dark:text-white"
id="modal-headline">
{needsConfirmation && !isCancelled
? props.recurringBookings
? t("submitted_recurring")
: t("submitted")
: isCancelled
? t("event_cancelled")
: props.recurringBookings
? t("meeting_is_scheduled_recurring")
: t("meeting_is_scheduled")}
</h3>
<div className="mt-3">
<p className="text-sm text-neutral-600 dark:text-gray-300">{getTitle()}</p>
</div>
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-4 grid grid-cols-3 border-t border-b py-4 text-left dark:text-gray-300">
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6">{eventName}</div>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6">
<RecurringBookings
eventType={props.eventType}
recurringBookings={props.recurringBookings}
listingStatus={(listingStatus as string) || "recurring"}
date={date}
is24h={is24h}
/>
</div>
{(bookingInfo?.user || bookingInfo?.attendees) && (
<>
<div className="font-medium">{t("who")}</div>
<div className="col-span-2 mb-6">
{bookingInfo?.user && (
<div className="mb-3">
<p>{bookingInfo.user.name}</p>
<p className="text-bookinglight">{bookingInfo.user.email}</p>
</div>
)}
{bookingInfo?.attendees.map((attendee, index) => (
<div
key={attendee.name}
className={index === bookingInfo.attendees.length - 1 ? "" : "mb-3"}>
<p>{attendee.name}</p>
<p className="text-bookinglight">{attendee.email}</p>
</div>
))}
</div>
</>
)}
{locationToDisplay && (
<>
<div className="mt-3 font-medium">{t("where")}</div>
<div className="col-span-2 mt-3">
{locationToDisplay.startsWith("http") ? (
<a title="Meeting Link" href={locationToDisplay}>
{locationToDisplay}
</a>
) : (
locationToDisplay
)}
</div>
</>
)}
{bookingInfo?.description && (
<>
<div className="mt-9 font-medium">{t("additional_notes")}</div>
<div className="col-span-2 mb-2 mt-9">
<p>{bookingInfo.description}</p>
</div>
</>
)}
{customInputs &&
Object.keys(customInputs).map((key) => {
const customInput = customInputs[key as keyof typeof customInputs];
return (
<>
{customInput !== "" && (
<>
<div className="mt-2 pr-3 font-medium">{key}</div>
<div className="col-span-2 mt-2 mb-2">
{typeof customInput === "boolean" ? (
<p>{customInput ? "true" : "false"}</p>
) : (
<p>{customInput}</p>
)}
</div>
</>
)}
</>
);
})}
</div>
</div>
</div>
{!needsConfirmation &&
!isCancelled &&
(!isCancellationMode ? (
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-2 grid-cols-3 border-b py-4 text-left sm:grid">
<span className="font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
{t("need_to_make_a_change")}
</span>
<div
className={classNames(
"col-span-2 items-center dark:text-gray-50",
!props.recurringBookings ? "flex" : ""
)}>
<button className="underline" onClick={() => setIsCancellationMode(true)}>
{t("cancel")}
</button>
{!props.recurringBookings && (
<>
<div className="mx-2">{t("or_lowercase")}</div>
<div className="underline">
<Link href={"/reschedule/" + bookingInfo?.uid}>{t("reschedule")}</Link>
</div>
</>
)}
</div>
</div>
) : (
<CancelBooking
booking={{ uid: bookingInfo?.uid, title: bookingInfo?.title }}
profile={{ name: props.profile.name, slug: props.profile.slug }}
recurringEvent={eventType.recurringEvent}
team={eventType?.team?.name}
setIsCancellationMode={setIsCancellationMode}
theme={isSuccessBookingPage ? props.profile.theme : "light"}
/>
))}
{userIsOwner && !needsConfirmation && !isCancellationMode && !isCancelled && (
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-2 grid-cols-3 border-b py-4 text-left sm:grid">
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
{t("add_to_calendar")}
</span>
<div className="justify-left mt-1 flex flex-grow text-left sm:mt-0">
<Link
href={
`https://calendar.google.com/calendar/r/eventedit?dates=${date
.utc()
.format("YYYYMMDDTHHmmss[Z]")}/${date
.add(props.eventType.length, "minute")
.utc()
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
props.eventType.description
}` +
(typeof location === "string" ? "&location=" + encodeURIComponent(location) : "") +
(props.eventType.recurringEvent
? "&recur=" +
encodeURIComponent(new RRule(props.eventType.recurringEvent).toString())
: "")
}>
<a className="mr-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white">
<svg
className="-mt-1.5 inline-block h-4 w-4"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<title>Google</title>
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
</svg>
</a>
</Link>
<Link
href={
encodeURI(
"https://outlook.live.com/calendar/0/deeplink/compose?body=" +
props.eventType.description +
"&enddt=" +
date.add(props.eventType.length, "minute").utc().format() +
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
date.utc().format() +
"&subject=" +
eventName
) + (location ? "&location=" + location : "")
}>
<a
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
target="_blank">
<svg
className="mr-1 -mt-1.5 inline-block h-4 w-4"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<title>Microsoft Outlook</title>
<path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z" />
</svg>
</a>
</Link>
<Link
href={
encodeURI(
"https://outlook.office.com/calendar/0/deeplink/compose?body=" +
props.eventType.description +
"&enddt=" +
date.add(props.eventType.length, "minute").utc().format() +
"&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" +
date.utc().format() +
"&subject=" +
eventName
) + (location ? "&location=" + location : "")
}>
<a
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
target="_blank">
<svg
className="mr-1 -mt-1.5 inline-block h-4 w-4"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<title>Microsoft Office</title>
<path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z" />
</svg>
</a>
</Link>
<Link href={"data:text/calendar," + eventLink()}>
<a
className="mx-2 h-10 w-10 rounded-sm border border-neutral-200 px-3 py-2 dark:border-neutral-700 dark:text-white"
download={props.eventType.title + ".ics"}>
<svg
version="1.1"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 1000"
className="mr-1 -mt-1.5 inline-block h-4 w-4">
<title>{t("other")}</title>
<path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z" />
</svg>
</a>
</Link>
</div>
</div>
)}
{session === null && !(userIsOwner || props.hideBranding) && (
<div className="border-bookinglightest text-booking-lighter dark:border-darkgray-200 pt-4 text-center text-xs dark:text-white">
<a href="https://cal.com/signup">{t("create_booking_link_with_calcom")}</a>
<form
onSubmit={(e) => {
e.preventDefault();
const target = e.target as typeof e.target & {
email: { value: string };
};
router.push(`https://cal.com/signup?email=${target.email.value}`);
}}
className="mt-4 flex">
<EmailInput
name="email"
id="email"
defaultValue={router.query.email}
className="focus:border-brand border-bookinglightest dark:border-darkgray-200 mt-0 block w-full rounded-sm border-gray-300 shadow-sm focus:ring-black dark:bg-black dark:text-white sm:text-sm"
placeholder="rick.astley@cal.com"
/>
<Button size="lg" type="submit" className="min-w-max" color="primary">
{t("try_for_free")}
</Button>
</form>
</div>
)}
</div>
<div className="-mt-4 flex justify-center md:-mt-12">
<Logo animated />
</div>
</div>
</div>
</div>
</main>
</div>
);
}
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 && (
<span className="font-medium">
{getEveryFreqFor({
t,
recurringEvent: eventType.recurringEvent,
recurringCount: recurringBookings?.length ?? undefined,
})}
</span>
)}
{eventType.recurringEvent?.count &&
recurringBookingsSorted.slice(0, 4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
{dayjs(dateStr).format("MMMM DD, YYYY")}
<br />
{dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
))}
{recurringBookingsSorted.length > 4 && (
<Collapsible open={moreEventsVisible} onOpenChange={() => setMoreEventsVisible(!moreEventsVisible)}>
<CollapsibleTrigger
type="button"
className={classNames("flex w-full", moreEventsVisible ? "hidden" : "")}>
{t("plus_more", { count: recurringBookingsSorted.length - 4 })}
</CollapsibleTrigger>
<CollapsibleContent>
{eventType.recurringEvent?.count &&
recurringBookingsSorted.slice(4).map((dateStr, idx) => (
<div key={idx} className="mb-2">
{dayjs(dateStr).format("MMMM DD, YYYY")}
<br />
{dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</>
) : (
<>
{date.format("MMMM DD, YYYY")}
<br />
{date.format("LT")} - {date.add(eventType.length, "m").format("LT")}{" "}
<span className="text-bookinglight">
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
</span>
</>
);
}
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,
},
};
}
+2 -3
View File
@@ -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),
+16 -10
View File
@@ -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) && (
<IntegrationsContainer variant={category} />
)}
{category === InstalledAppVariants.other && (
<IntegrationsContainer
exclude={
Object.keys(InstalledAppVariants).filter(
(variant) => variant !== InstalledAppVariants.other
) as App["variant"][]
}
/>
{(category === InstalledAppVariants.automation || category === InstalledAppVariants.analytics) && (
<IntegrationsContainer variant={category} />
)}
{category === InstalledAppVariants.calendar && <CalendarListContainer />}
{category === InstalledAppVariants.other && (
<IntegrationsContainer
exclude={[
InstalledAppVariants.conferencing,
InstalledAppVariants.calendar,
InstalledAppVariants.analytics,
InstalledAppVariants.automation,
]}
/>
)}
</InstalledAppsLayout>
);
}
+54 -42
View File
@@ -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<typeof EventTypeMetaDataSchema>;
destinationCalendar: {
integration: string;
externalId: string;
};
successRedirectUrl: string;
giphyThankYouPage: string;
blockchainId: number;
smartContractAddress: string;
bookingLimits?: BookingLimit;
};
@@ -97,8 +94,16 @@ export type EventTypeSetupInfered = inferSSRProps<typeof getServerSideProps>;
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
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<typeof getServerSideProps>) => {
},
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: (
<EventSetupTab
@@ -182,18 +198,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
),
limits: <EventLimitsTab eventType={eventType} />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
recurring: (
<EventRecurringTab eventType={eventType} hasPaymentIntegration={props.hasPaymentIntegration} />
),
apps: (
<EventAppsTab
currency={props.currency}
eventType={eventType}
hasPaymentIntegration={props.hasPaymentIntegration}
hasGiphyIntegration={props.hasGiphyIntegration}
hasRainbowIntegration={props.hasRainbowIntegration}
/>
),
recurring: <EventRecurringTab eventType={eventType} />,
apps: <EventAppsTab eventType={eventType} />,
workflows: (
<EventWorkflowsTab
eventType={eventType}
@@ -204,7 +210,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
return (
<EventTypeSingleLayout
enabledAppsNumber={[props.hasGiphyIntegration, props.hasPaymentIntegration].filter(Boolean).length}
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={numberOfInstalledApps}
enabledWorkflowsNumber={eventType.workflows.length}
eventType={eventType}
team={team}
@@ -218,15 +225,13 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
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<typeof getServerSideProps>) => {
afterEventBuffer: afterBufferTime,
bookingLimits,
seatsPerTimeSlot,
metadata: {
...(giphyThankYouPage ? { giphyThankYouPage } : {}),
...(smartContractAddress ? { smartContractAddress } : {}),
...(blockchainId ? { blockchainId } : { blockchainId: 1 }),
},
metadata,
});
}}>
<div ref={animationParentRef} className="space-y-6">
@@ -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,
},
};
+1 -1
View File
@@ -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";
+15 -15
View File
@@ -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) {
</div>
)}
<HeadSeo title={title} description={title} />
<BookingPageTagManager eventType={eventType} />
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
@@ -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),
};
@@ -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",
+24 -10
View File
@@ -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 }) => {
<Text bold italic>
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"}
</Text>
<Text bold italic>
App Summary:
+68 -5
View File
@@ -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<AppMeta> & {
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.
+13
View File
@@ -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;
@@ -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<string, Record<string, unknown>> = {
fathom: {
src: "https://cdn.usefathom.com/script.js",
"data-site": "{TRACKING_ID}",
},
};
export default function BookingPageTagManager({
eventType,
}: {
eventType: Parameters<typeof getEventTypeAppData>[0];
}) {
return (
<>
{Object.entries(trackingApps).map(([appId, scriptConfig]) => {
const trackingId = getEventTypeAppData(eventType, "fathom")?.trackingId;
if (!trackingId) {
return null;
}
const parsedScriptConfig: Record<string, unknown> = {};
Object.entries(scriptConfig).forEach(([name, value]) => {
if (typeof value === "string") {
value = value.replace("{TRACKING_ID}", trackingId);
}
parsedScriptConfig[name] = value;
});
return <Script key={appId} {...parsedScriptConfig} defer />;
})}
</>
);
}
@@ -0,0 +1,28 @@
import React from "react";
import { z, ZodType } from "zod";
export type GetAppData = (key: string) => unknown;
export type SetAppData = (key: string, value: unknown) => void;
// eslint-disable-next-line @typescript-eslint/no-empty-function
const EventTypeAppContext = React.createContext<[GetAppData, SetAppData]>([() => ({}), () => {}]);
export type SetAppDataGeneric<TAppData extends ZodType> = <
TKey extends keyof z.infer<TAppData>,
TValue extends z.infer<TAppData>[TKey]
>(
key: TKey,
value: TValue
) => void;
export type GetAppDataGeneric<TAppData extends ZodType> = <TKey extends keyof z.infer<TAppData>>(
key: TKey
) => z.infer<TAppData>[TKey];
export const useAppContextWithSchema = <TAppData extends ZodType>() => {
type GetAppData = GetAppDataGeneric<TAppData>;
type SetAppData = SetAppDataGeneric<TAppData>;
// TODO: Not able to do it without type assertion here
const context = React.useContext(EventTypeAppContext) as [GetAppData, SetAppData];
return context;
};
export default EventTypeAppContext;
@@ -0,0 +1,57 @@
import Link from "next/link";
import { inferQueryOutput } from "@calcom/trpc/react";
import { Switch } from "@calcom/ui/v2";
import OmniInstallAppButton from "@calcom/web/components/v2/apps/OmniInstallAppButton";
import { SetAppDataGeneric } from "../EventTypeAppContext";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export default function AppCard({
app,
description,
switchOnClick,
switchChecked,
children,
setAppData,
}: {
app: inferQueryOutput<"viewer.apps">[number];
description?: React.ReactNode;
switchChecked?: boolean;
switchOnClick?: (e: boolean) => void;
children?: React.ReactNode;
setAppData: SetAppDataGeneric<typeof eventTypeAppCardZod>;
}) {
return (
<div className="mb-4 mt-2 rounded-md border border-gray-200 p-8 text-sm">
<div className="flex w-full">
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
<Link href={"/apps/" + app.slug}>
<a className="mr-3 h-auto w-10 rounded-sm">
<img src={app?.logo} alt={app?.name} />
</a>
</Link>
<div className="flex flex-col">
<span className="font-semibold leading-none text-black">{app?.name}</span>
<p className="pt-2 text-sm font-normal text-gray-600">{description || app?.description}</p>
</div>
{app?.isInstalled ? (
<div className="ml-auto flex items-center">
<Switch
onCheckedChange={(enabled) => {
if (switchOnClick) {
switchOnClick(enabled);
}
setAppData("enabled", enabled);
}}
checked={switchChecked}
/>
</div>
) : (
<OmniInstallAppButton className="ml-auto flex items-center" appId={app?.slug} />
)}
</div>
{app?.isInstalled && switchChecked ? <div className="mt-4">{children}</div> : null}
</div>
);
}
@@ -0,0 +1,43 @@
import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { Icon } from "@calcom/ui";
import { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const isSunrise = getAppData("isSunrise");
const [enabled, setEnabled] = useState(getAppData("enabled"));
return (
<AppCard
setAppData={setAppData}
app={app}
switchOnClick={(e) => {
if (!e) {
setEnabled(false);
setAppData("isSunrise", false);
} else {
setEnabled(true);
setAppData("isSunrise", true);
}
}}
switchChecked={enabled}>
<div className="mt-2 text-sm">
<div className="flex">
<span className="mr-2">{isSunrise ? <Icon.FiSunrise /> : <Icon.FiSunset />}</span>I am an AppCard
for Event with Title: {eventType.title}
</div>{" "}
<div className="mt-2">
Edit <span className="italic">packages/app-store/{app.slug}/extensions/EventTypeAppCard.tsx</span>{" "}
to play with me
</div>
</div>
</AppCard>
);
};
export default EventTypeAppCard;
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
isSunrise: z.boolean(),
})
);
+35 -27
View File
@@ -7,37 +7,45 @@ import { App } from "@calcom/types/App";
import getInstalledAppPath from "./getInstalledAppPath";
function useAddAppMutation(_type: App["type"] | null, options?: Parameters<typeof useMutation>[2]) {
const mutation = useMutation<unknown, Error, { type?: App["type"]; variant?: string; slug?: string } | "">(
async (variables) => {
let type: string | null | undefined;
if (variables === "") {
type = _type;
} else {
type = variables.type;
}
const state: IntegrationOAuthCallbackState = {
returnTo:
WEBAPP_URL +
getInstalledAppPath(
{ variant: variables && variables.variant, slug: variables && variables.slug },
location.search
),
};
const stateStr = encodeURIComponent(JSON.stringify(state));
const searchParams = `?state=${stateStr}`;
const mutation = useMutation<
unknown,
Error,
{ type?: App["type"]; variant?: string; slug?: string; isOmniInstall?: boolean } | ""
>(async (variables) => {
let type: string | null | undefined;
let isOmniInstall;
if (variables === "") {
type = _type;
} else {
isOmniInstall = variables.isOmniInstall;
type = variables.type;
}
const state: IntegrationOAuthCallbackState = {
returnTo:
WEBAPP_URL +
getInstalledAppPath(
{ variant: variables && variables.variant, slug: variables && variables.slug },
location.search
),
};
const stateStr = encodeURIComponent(JSON.stringify(state));
const searchParams = `?state=${stateStr}`;
const res = await fetch(`/api/integrations/${type}/add` + searchParams);
const res = await fetch(`/api/integrations/${type}/add` + searchParams);
if (!res.ok) {
const errorBody = await res.json();
throw new Error(errorBody.message || "Something went wrong");
}
if (!res.ok) {
const errorBody = await res.json();
throw new Error(errorBody.message || "Something went wrong");
}
const json = await res.json();
const json = await res.json();
// Skip redirection only if it is an OmniInstall and redirect URL isn't of some other origin
// This allows installation of apps like Stripe to still redirect to their authentication pages.
// TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks
if (!(isOmniInstall && !json.url.startsWith(window.location.origin))) {
window.location.href = json.url;
},
options
);
}
}, options);
return mutation;
}
+21 -1
View File
@@ -11,10 +11,14 @@ import { metadata as campfire_meta } from "./campfire/_metadata";
import { metadata as closecomothercalendar_meta } from "./closecomothercalendar/_metadata";
import { metadata as dailyvideo_meta } from "./dailyvideo/_metadata";
import { metadata as routing_forms_meta } from "./ee/routing-forms/_metadata";
import { appDataSchema as routing_forms_schema } from "./ee/routing-forms/zod";
import { metadata as exchange2013calendar_meta } from "./exchange2013calendar/_metadata";
import { metadata as exchange2016calendar_meta } from "./exchange2016calendar/_metadata";
import { metadata as exchangecalendar_meta } from "./exchangecalendar/_metadata";
import { metadata as fathom_meta } from "./fathom/_metadata";
import { appDataSchema as fathom_schema } from "./fathom/zod";
import { metadata as giphy_meta } from "./giphy/_metadata";
import { appDataSchema as giphy_schema } from "./giphy/zod";
import { metadata as googlecalendar_meta } from "./googlecalendar/_metadata";
import { metadata as googlevideo_meta } from "./googlevideo/_metadata";
import { metadata as hubspotothercalendar_meta } from "./hubspotothercalendar/_metadata";
@@ -26,10 +30,12 @@ import { metadata as office365calendar_meta } from "./office365calendar/_metadat
import { metadata as office365video_meta } from "./office365video/_metadata";
import { metadata as ping_meta } from "./ping/_metadata";
import { metadata as rainbow_meta } from "./rainbow/_metadata";
import { appDataSchema as rainbow_schema } from "./rainbow/zod";
import { metadata as raycast_meta } from "./raycast/_metadata";
import { metadata as riverside_meta } from "./riverside/_metadata";
import { metadata as slackmessaging_meta } from "./slackmessaging/_metadata";
import { metadata as stripepayment_meta } from "./stripepayment/_metadata";
import { appDataSchema as stripepayment_schema } from "./stripepayment/zod";
import { metadata as tandemvideo_meta } from "./tandemvideo/_metadata";
import { metadata as typeform_meta } from "./typeform/_metadata";
import { metadata as vital_meta } from "./vital/_metadata";
@@ -49,6 +55,7 @@ export const appStoreMetadata = {
exchange2013calendar: exchange2013calendar_meta,
exchange2016calendar: exchange2016calendar_meta,
exchangecalendar: exchangecalendar_meta,
fathom: fathom_meta,
giphy: giphy_meta,
googlecalendar: googlecalendar_meta,
googlevideo: googlevideo_meta,
@@ -73,6 +80,14 @@ export const appStoreMetadata = {
zapier: zapier_meta,
zoomvideo: zoomvideo_meta,
};
export const appDataSchemas = {
"routing-forms": routing_forms_schema,
fathom: fathom_schema,
giphy: giphy_schema,
rainbow: rainbow_schema,
stripe: stripepayment_schema,
};
export const InstallAppButtonMap = {
applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")),
around: dynamic(() => import("./around/components/InstallAppButton")),
@@ -81,7 +96,6 @@ export const InstallAppButtonMap = {
exchange2013calendar: dynamic(() => import("./exchange2013calendar/components/InstallAppButton")),
exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")),
exchangecalendar: dynamic(() => import("./exchangecalendar/components/InstallAppButton")),
giphy: dynamic(() => import("./giphy/components/InstallAppButton")),
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
hubspotothercalendar: dynamic(() => import("./hubspotothercalendar/components/InstallAppButton")),
huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")),
@@ -99,3 +113,9 @@ export const InstallAppButtonMap = {
zapier: dynamic(() => import("./zapier/components/InstallAppButton")),
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
};
export const EventTypeAddonMap = {
fathom: dynamic(() => import("./fathom/extensions/EventTypeAppCard")),
giphy: dynamic(() => import("./giphy/extensions/EventTypeAppCard")),
rainbow: dynamic(() => import("./rainbow/extensions/EventTypeAppCard")),
stripepayment: dynamic(() => import("./stripepayment/extensions/EventTypeAppCard")),
};
@@ -12,6 +12,7 @@ export const apiHandlers = {
exchange2013calendar: import("./exchange2013calendar/api"),
exchange2016calendar: import("./exchange2016calendar/api"),
exchangecalendar: import("./exchangecalendar/api"),
fathom: import("./fathom/api"),
giphy: import("./giphy/api"),
googlecalendar: import("./googlecalendar/api"),
hubspotothercalendar: import("./hubspotothercalendar/api"),
@@ -93,7 +93,7 @@ const Actions = ({
type="button"
tooltip={t("delete")}
/>
{typeformApp ? (
{typeformApp?.isInstalled ? (
<FormActionsDropdown form={form}>
<FormAction
routingForm={form}
@@ -122,7 +122,7 @@ export default function RoutingForms({
StartIcon={Icon.FiCopy}>
{t("duplicate")}
</FormAction>
{typeformApp ? (
{typeformApp?.isInstalled ? (
<FormAction
routingForm={form}
action="copyRedirectUrl"
@@ -39,3 +39,7 @@ export const zodRoutes = z
z.null(),
])
.optional();
// TODO: This is a requirement right now that zod.ts file (if it exists) must have appDataSchema export(which is only required by apps having EventTypeAppCard interface)
// This is a temporary solution and will be removed in future
export const appDataSchema = z.any();
@@ -0,0 +1,6 @@
// It's the shared zod for all EventType apps for their data in eventType.metadata.apps
import { z } from "zod";
export const eventTypeAppCardZod = z.object({
enabled: z.boolean().optional(),
});
+10
View File
@@ -0,0 +1,10 @@
---
items:
- /api/app-store/fathom/1.jpg
---
<Slider items={items} />
Fathom Analytics provides simple, privacy-focused website analytics. We're a GDPR-compliant, Google Analytics alternative.
Use the Fathom app to track analytics of your bookings.
+10
View File
@@ -0,0 +1,10 @@
import type { AppMeta } from "@calcom/types/App";
import config from "./config.json";
export const metadata = {
category: "analytics",
...config,
} as AppMeta;
export default metadata;
+17
View File
@@ -0,0 +1,17 @@
import { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import { createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
// Instead of passing appType and slug from here, api/integrations/[..args] should be able to derive and pass these directly to createCredential
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {} }),
};
export default handler;
+1
View File
@@ -0,0 +1 @@
export { default as add } from "./add";
+16
View File
@@ -0,0 +1,16 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Fathom",
"slug": "fathom",
"type": "fathom_analytics",
"imageSrc": "/api/app-store/fathom/icon.svg",
"logo": "/api/app-store/fathom/icon.svg",
"url": "https://cal.com/apps/fathom",
"variant": "analytics",
"categories": ["analytics"],
"publisher": "Cal.com, Inc.",
"email": "help@cal.com",
"extendsFeature": "EventType",
"description": "Fathom Analytics provides simple, privacy-focused website analytics. We're a GDPR-compliant, Google Analytics alternative.",
"__createdUsingCli": true
}
@@ -0,0 +1,39 @@
import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { Icon } from "@calcom/ui";
import { Input, TextField } from "@calcom/ui/v2";
import { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const trackingId = getAppData("trackingId");
const [enabled, setEnabled] = useState(getAppData("enabled"));
return (
<AppCard
setAppData={setAppData}
app={app}
switchOnClick={(e) => {
if (!e) {
setEnabled(false);
} else {
setEnabled(true);
}
}}
switchChecked={enabled}>
<TextField
name="Tracking ID"
value={trackingId}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}
/>
</AppCard>
);
};
export default EventTypeAppCard;
+2
View File
@@ -0,0 +1,2 @@
export * as api from "./api";
export { metadata } from "./_metadata";
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/fathom",
"version": "0.0.0",
"main": "./index.ts",
"description": "Fathom Analytics provides simple, privacy-focused website analytics. We're a GDPR-compliant, Google Analytics alternative.",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

@@ -0,0 +1,6 @@
<svg width="41" height="41" viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.74882 26.8433C-2.10667 13.5032 0.721199 7.43732 14.0623 2.58183C27.4034 -2.27366 33.4683 0.554207 38.3238 13.8953C43.1792 27.2364 40.3514 33.3013 27.0103 38.1568C13.6692 43.0122 7.60431 40.1844 2.74882 26.8433" fill="#333740"/>
<path d="M14.3263 12.664H13.4861C12.7288 12.6379 12.0306 12.8283 11.3914 13.2352C11.065 13.461 10.8129 13.7515 10.6352 14.1064C10.4382 14.5172 10.3441 14.9503 10.3531 15.4058V27.4605C10.3531 27.5756 10.3939 27.6738 10.4754 27.7551C10.5569 27.8364 10.6552 27.8769 10.7703 27.8767H12.4108C12.5259 27.8769 12.6242 27.8364 12.7057 27.7551C12.7871 27.6738 12.8279 27.5756 12.8279 27.4605V18.9679H14.3284C14.4435 18.9682 14.5418 18.9277 14.6232 18.8464C14.7047 18.7651 14.7455 18.6669 14.7455 18.5518V17.1454C14.7458 17.0301 14.7051 16.9316 14.6236 16.8501C14.5421 16.7686 14.4437 16.728 14.3283 16.7282H12.8279V15.6599C12.8198 15.4801 12.8438 15.3047 12.8999 15.1337C12.9168 15.0838 12.9442 15.0405 12.9819 15.0037C13.0405 14.9567 13.1072 14.93 13.182 14.9237C13.3252 14.906 13.4689 14.8994 13.6131 14.9037H14.3263C14.4415 14.904 14.5398 14.8634 14.6212 14.7821C14.7027 14.7009 14.7435 14.6027 14.7435 14.4875V13.0811C14.7438 12.9658 14.7031 12.8674 14.6216 12.7859C14.5401 12.7043 14.4417 12.6637 14.3263 12.664" fill="white"/>
<path d="M24.2485 20.3892C24.2795 19.2764 23.8793 18.3498 23.0481 17.6094C22.132 16.8697 21.0856 16.5239 19.9091 16.572C18.7087 16.536 17.6277 16.8778 16.6662 17.5974C15.7995 18.2708 15.3413 19.1557 15.2917 20.2522C15.286 20.371 15.3242 20.4734 15.4063 20.5594C15.4884 20.6455 15.5889 20.6884 15.7079 20.6883H17.4424C17.6966 20.6881 17.835 20.5614 17.8575 20.3082C17.8966 19.8961 18.084 19.569 18.4197 19.3269C18.8657 19.0152 19.3605 18.8699 19.9041 18.8908C20.4289 18.8601 20.8997 19.0048 21.3166 19.3249C21.6374 19.6069 21.7898 19.9614 21.7737 20.3882V20.4472C21.7752 20.4956 21.7702 20.5433 21.7587 20.5903L21.7427 20.6263C21.7125 20.6575 21.6765 20.6792 21.6347 20.6913C21.4008 20.7672 21.1614 20.8162 20.9165 20.8383C20.5644 20.8854 20.1072 20.9384 19.537 21.0094C18.4936 21.0895 17.5003 21.3563 16.5571 21.8096C16.1059 22.0564 15.7468 22.3995 15.4798 22.839C15.1911 23.3514 15.0551 23.9016 15.0717 24.4895C15.0643 25.0213 15.168 25.5304 15.3828 26.0169C15.6956 26.694 16.1864 27.1975 16.8552 27.5274C17.5594 27.8671 18.3023 28.0288 19.0839 28.0126C19.9141 28.0443 20.6867 27.8486 21.4016 27.4254C21.5327 27.3424 21.6567 27.2501 21.7737 27.1483V27.4604C21.7737 27.5755 21.8145 27.6737 21.896 27.755C21.9774 27.8363 22.0757 27.8768 22.1909 27.8765H22.9911L24.2495 23.1931L24.2485 20.3892ZM21.7737 23.5542C21.795 24.1282 21.6103 24.6256 21.2196 25.0466C20.6787 25.5342 20.0452 25.751 19.319 25.6968C18.8476 25.7251 18.4095 25.6177 18.0046 25.3747C17.8798 25.2888 17.7821 25.1787 17.7115 25.0446C17.6414 24.8996 17.6081 24.7465 17.6114 24.5855V24.5745C17.6062 24.3904 17.6509 24.2194 17.7455 24.0613C17.9172 23.8186 18.1433 23.6479 18.4237 23.5492C18.7961 23.4055 19.1806 23.3101 19.577 23.2631C20.1702 23.1801 20.7774 23.091 21.2866 22.992C21.4676 22.957 21.6287 22.919 21.7797 22.879L21.7737 23.5542Z" fill="white"/>
<path d="M31.2176 11.4795C31.1343 11.3703 31.0239 11.3156 30.8865 11.3154H28.7959C28.5837 11.3165 28.4503 11.4195 28.3958 11.6245L24.2485 27.0493L24.0484 27.8115L23.7674 28.8578C23.732 28.9897 23.7557 29.11 23.8385 29.2187C23.9212 29.3273 24.0309 29.3821 24.1675 29.383H26.2581C26.4699 29.3821 26.6033 29.2794 26.6582 29.0749L31.2897 11.8416C31.3251 11.7091 31.301 11.5884 31.2176 11.4795" fill="#A299FF"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
trackingId: z.string(),
})
);
+1
View File
@@ -20,6 +20,7 @@ export const metadata = {
url: "https://cal.com/apps/giphy",
variant: "other",
verified: true,
extendsFeature: "EventType",
email: "help@cal.com",
} as AppMeta;
@@ -1,19 +0,0 @@
import useAddAppMutation from "../../_utils/useAddAppMutation";
import { InstallAppButtonProps } from "../../types";
export default function InstallAppButton(props: InstallAppButtonProps) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO: deprecate App types in favor of DB slugs
const mutation = useAddAppMutation("giphy");
return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}
@@ -1,2 +1 @@
export { default as InstallAppButton } from "./InstallAppButton";
export { default as SelectGifInput } from "./SelectGifInput";
@@ -0,0 +1,38 @@
import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import { SelectGifInput } from "@calcom/app-store/giphy/components";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const thankYouPage = getAppData("thankYouPage");
const [showGifSelection, setShowGifSelection] = useState(getAppData("enabled"));
const { t } = useLocale();
return (
<AppCard
setAppData={setAppData}
app={app}
description={t("confirmation_page_gif")}
switchOnClick={(e) => {
setShowGifSelection(e);
}}
switchChecked={showGifSelection}>
{showGifSelection && (
<SelectGifInput
defaultValue={thankYouPage}
onChange={(url: string) => {
setAppData("thankYouPage", url);
}}
/>
)}
</AppCard>
);
};
export default EventTypeAppCard;
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
thankYouPage: z.string().optional(),
})
);
@@ -1,19 +1,18 @@
import type { UseFormReturn } from "react-hook-form";
import { z } from "zod";
import { SetAppDataGeneric } from "@calcom/app-store/EventTypeAppContext";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SUPPORTED_CHAINS_FOR_FORM } from "@calcom/rainbow/utils/ethereum";
import type { FormValues } from "@calcom/web/pages/v2/event-types/[type]";
import Select from "@calcom/ui/v2/core/form/select";
import Select from "@components/ui/form/Select";
import { appDataSchema } from "../zod";
type RainbowInstallFormProps = {
formMethods: UseFormReturn<FormValues>;
blockchainId: number;
smartContractAddress: string;
};
setAppData: SetAppDataGeneric<typeof appDataSchema>;
} & Pick<z.infer<typeof appDataSchema>, "smartContractAddress" | "blockchainId">;
const RainbowInstallForm: React.FC<RainbowInstallFormProps> = ({
formMethods,
setAppData,
blockchainId,
smartContractAddress,
}) => {
@@ -31,14 +30,9 @@ const RainbowInstallForm: React.FC<RainbowInstallFormProps> = ({
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
onChange={(e) => {
formMethods.setValue("blockchainId", (e && e.value) || 1);
setAppData("blockchainId", (e && e.value) || 1);
}}
defaultValue={
SUPPORTED_CHAINS_FOR_FORM.find((e) => e.value === blockchainId) || {
value: 1,
label: "Ethereum",
}
}
defaultValue={SUPPORTED_CHAINS_FOR_FORM.find((e) => e.value === blockchainId)}
options={SUPPORTED_CHAINS_FOR_FORM || [{ value: 1, label: "Ethereum" }]}
/>
</div>
@@ -55,7 +49,9 @@ const RainbowInstallForm: React.FC<RainbowInstallFormProps> = ({
className="block w-full rounded-sm border-gray-300 text-sm "
placeholder={t("Example: 0x71c7656ec7ab88b098defb751b7401b5f6d8976f")}
defaultValue={(smartContractAddress || "") as string}
{...formMethods.register("smartContractAddress")}
onChange={(e) => {
setAppData("smartContractAddress", e.target.value);
}}
/>
</div>
</div>
+1
View File
@@ -8,6 +8,7 @@
"url": "https://cal.com/apps/rainbow",
"variant": "web3",
"categories": ["web3"],
"extendsFeature": "EventType",
"publisher": "hexcowboy",
"email": "",
"description": "Web3 integration for token gating on Fungible Tokens, NFTs, and DAOs.",
@@ -0,0 +1,35 @@
import React, { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import RainbowInstallForm from "@calcom/app-store/rainbow/components/RainbowInstallForm";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const blockchainId = getAppData("blockchainId");
const smartContractAddress = getAppData("smartContractAddress");
const [showRainbowSection, setShowRainbowSection] = useState(getAppData("enabled"));
return (
<AppCard
setAppData={setAppData}
app={app}
switchOnClick={(e) => {
setShowRainbowSection(e);
}}
switchChecked={showRainbowSection}>
{showRainbowSection && (
<RainbowInstallForm
setAppData={setAppData}
blockchainId={blockchainId}
smartContractAddress={(smartContractAddress as string) || ""}
/>
)}
</AppCard>
);
};
export default EventTypeAppCard;
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
smartContractAddress: z.string().optional(),
blockchainId: z.number().optional(),
})
);
@@ -23,6 +23,7 @@ export const metadata = {
url: "https://cal.com/",
docsUrl: "https://stripe.com/docs",
variant: "payment",
extendsFeature: "EventType",
verified: true,
email: "help@cal.com",
} as AppMeta;
@@ -0,0 +1,76 @@
import { useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, TextField } from "@calcom/ui/v2";
import { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const [getAppData, setAppData] = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
const { t } = useLocale();
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
const getCurrencySymbol = (locale: string, currency: string) =>
(0)
.toLocaleString(locale, {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
.replace(/\d/g, "")
.trim();
return (
<AppCard
setAppData={setAppData}
app={app}
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);
}}
description={
<>
<div className="">
{t("require_payment")} (0.5% +{" "}
<IntlProvider locale="en">
<FormattedNumber value={0.1} style="currency" currency={currency} />
</IntlProvider>{" "}
{t("commission_per_transaction")})
</div>
</>
}>
<>
{recurringEventDefined ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<div className="mt-2 block items-center sm:flex">
<TextField
label=""
addOnLeading={<>{currency ? getCurrencySymbol("en", currency) : ""}</>}
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) => {
setAppData("price", e.target.valueAsNumber * 100);
}}
value={price > 0 ? price / 100 : undefined}
/>
</div>
)
)}
</>
</AppCard>
);
};
export default EventTypeAppCard;
+9 -10
View File
@@ -5,7 +5,9 @@ import { z } from "zod";
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@calcom/emails";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import prisma from "@calcom/prisma";
import { EventTypeModel } from "@calcom/prisma/zod";
import type { CalendarEvent } from "@calcom/types/Calendar";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
@@ -49,10 +51,7 @@ const stripeCredentialSchema = z.object({
export async function handlePayment(
evt: CalendarEvent,
selectedEventType: {
price: number;
currency: string;
},
selectedEventType: Pick<z.infer<typeof EventTypeModel>, "price" | "currency" | "metadata">,
stripeCredential: { key: Prisma.JsonValue },
booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
@@ -63,13 +62,13 @@ export async function handlePayment(
) {
const appKeys = await getAppKeysFromSlug("stripe");
const { payment_fee_fixed, payment_fee_percentage } = stripeKeysSchema.parse(appKeys);
const paymentFee = Math.round(selectedEventType.price * payment_fee_percentage + payment_fee_fixed);
const stripeAppData = getStripeAppData(selectedEventType);
const paymentFee = Math.round(stripeAppData.price * payment_fee_percentage + payment_fee_fixed);
const { stripe_user_id, stripe_publishable_key } = stripeCredentialSchema.parse(stripeCredential.key);
const params: Stripe.PaymentIntentCreateParams = {
amount: selectedEventType.price,
currency: selectedEventType.currency,
amount: stripeAppData.price,
currency: stripeAppData.currency,
payment_method_types: ["card"],
application_fee_amount: paymentFee,
};
@@ -85,9 +84,9 @@ export async function handlePayment(
id: booking.id,
},
},
amount: selectedEventType.price,
amount: stripeAppData.price,
fee: paymentFee,
currency: selectedEventType.currency,
currency: stripeAppData.currency,
success: false,
refunded: false,
data: Object.assign({}, paymentIntent, {
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
price: z.number(),
currency: z.string(),
})
);
+11
View File
@@ -1,3 +1,8 @@
import React from "react";
import { z } from "zod";
import { _EventTypeModel } from "@calcom/prisma/zod";
import { inferQueryOutput } from "@calcom/trpc/react";
import { ButtonBaseProps } from "@calcom/ui/Button";
import { ButtonBaseProps as v2ButtonBaseProps } from "@calcom/ui/v2/core/Button";
@@ -17,3 +22,9 @@ export interface InstallAppButtonProps {
) => JSX.Element;
onChanged?: () => unknown;
}
export type EventTypeAppCardComponent = React.FC<{
// Limit what data should be accessible to apps
eventType: Pick<z.infer<typeof _EventTypeModel>, "id", "title" | "description" | "teamId" | "length">;
app: inferQueryOutput<"viewer.apps">[number];
}>;
+50 -1
View File
@@ -1,13 +1,19 @@
import { Prisma } from "@prisma/client";
import { TFunction } from "next-i18next";
import { z } from "zod";
import { defaultLocations, EventLocationType, LocationType } from "@calcom/app-store/locations";
import { defaultLocations, EventLocationType } from "@calcom/app-store/locations";
import { EventTypeModel } from "@calcom/prisma/zod";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { App, AppMeta } from "@calcom/types/App";
// If you import this file on any app it should produce circular dependency
// import appStore from "./index";
import { appStoreMetadata } from "./apps.browser.generated";
export type EventTypeApps = NonNullable<NonNullable<z.infer<typeof EventTypeMetaDataSchema>>["apps"]>;
export type EventTypeAppsList = keyof EventTypeApps;
const ALL_APPS_MAP = Object.keys(appStoreMetadata).reduce((store, key) => {
store[key] = appStoreMetadata[key as keyof typeof appStoreMetadata];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -29,6 +35,8 @@ export enum InstalledAppVariants {
"conferencing" = "conferencing",
"calendar" = "calendar",
"payment" = "payment",
"analytics" = "analytics",
"automation" = "automation",
"other" = "other",
}
@@ -78,6 +86,7 @@ function getApps(userCredentials: CredentialData[]) {
credentials.push({
id: +new Date().getTime(),
type: appMeta.type,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
key: appMeta.key!,
userId: +new Date().getTime(),
appId: appMeta.slug,
@@ -129,4 +138,44 @@ export function getAppType(name: string): string {
return "Unknown";
}
export const getEventTypeAppData = <T extends EventTypeAppsList>(
eventType: Pick<z.infer<typeof EventTypeModel>, "price" | "currency" | "metadata">,
appId: T,
forcedGet?: boolean
): EventTypeApps[T] => {
const metadata = eventType.metadata;
const appMetadata = metadata?.apps && metadata.apps[appId];
if (appMetadata) {
const allowDataGet = forcedGet ? true : appMetadata.enabled;
return allowDataGet ? appMetadata : null;
}
// Backward compatibility for existing event types.
// TODO: After the new AppStore EventType App flow is stable, write a migration to migrate metadata to new format which will let us remove this compatibility code
// Migration isn't being done right now, to allow a revert if needed
const legacyAppsData = {
stripe: {
enabled: eventType.price > 0,
// Price default is 0 in DB. So, it would always be non nullish.
price: eventType.price,
// Currency default is "usd" in DB.So, it would also be available always
currency: eventType.currency,
},
rainbow: {
enabled: !!(eventType.metadata?.smartContractAddress && eventType.metadata?.blockchainId),
smartContractAddress: eventType.metadata?.smartContractAddress || "",
blockchainId: eventType.metadata?.blockchainId || 0,
},
giphy: {
enabled: !!eventType.metadata?.giphyThankYouPage,
thankYouPage: eventType.metadata?.giphyThankYouPage || "",
},
} as const;
// TODO: This assertion helps typescript hint that only one of the app's data can be returned
const legacyAppData = legacyAppsData[appId as Extract<T, keyof typeof legacyAppsData>];
const allowDataGet = forcedGet ? true : legacyAppData?.enabled;
return allowDataGet ? legacyAppData : null;
};
export default getApps;
@@ -8,6 +8,7 @@ import { v5 as uuidv5 } from "uuid";
import { getLocationValueForDB, LocationObject } from "@calcom/app-store/locations";
import { handleEthSignature } from "@calcom/app-store/rainbow/utils/ethereum";
import { handlePayment } from "@calcom/app-store/stripepayment/lib/server";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
import EventManager from "@calcom/core/EventManager";
import { getEventName } from "@calcom/core/event";
@@ -25,6 +26,7 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import { HttpError } from "@calcom/lib/http-error";
import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
@@ -32,6 +34,7 @@ import { checkBookingLimits, getLuckyUser } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma, { userSelect } from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { extendedBookingCreateBody, requiredCustomInputSchema } from "@calcom/prisma/zod-utils";
import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
@@ -173,6 +176,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
return {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
locations: (eventType.locations ?? []) as LocationObject[],
};
@@ -252,6 +256,8 @@ async function handler(req: NextApiRequest & { userId?: number }) {
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
const stripeAppData = getStripeAppData(eventType);
// Check if required custom inputs exist
if (eventType.customInputs) {
eventType.customInputs.forEach((customInput) => {
@@ -354,12 +360,12 @@ async function handler(req: NextApiRequest & { userId?: number }) {
users = availableUsers;
}
}
console.log("available users", users);
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
// @TODO: use the returned address somewhere in booking creation?
// const address: string | undefined = await ...
await handleEthSignature(eventType.metadata, reqBody.ethSignature);
await handleEthSignature(rainbowAppData, reqBody.ethSignature);
const [organizerUser] = users;
const tOrganizer = await getTranslation(organizerUser.locale ?? "en", "common");
@@ -587,7 +593,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
// Otherwise, an owner rescheduling should be always accepted.
const userReschedulingIsOwner = originalRescheduledBooking?.user?.id === userId;
const isConfirmedByDefault =
(!eventType.requiresConfirmation && !eventType.price) || userReschedulingIsOwner;
(!eventType.requiresConfirmation && !stripeAppData.price) || userReschedulingIsOwner;
const newBookingData: Prisma.BookingCreateInput = {
uid,
title: evt.title,
@@ -661,7 +667,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
}
}
if (typeof eventType.price === "number" && eventType.price > 0) {
if (typeof stripeAppData.price === "number" && stripeAppData.price > 0) {
/* Validate if there is any stripe_payment credential for this user */
/* note: removes custom error message about stripe */
await prisma.credential.findFirstOrThrow({
@@ -753,7 +759,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
} else if (!eventType.requiresConfirmation && !eventType.price) {
} else if (!eventType.requiresConfirmation && !stripeAppData.price) {
// Use EventManager to conditionally use all needed integrations.
const createManager = await eventManager.create(evt);
@@ -796,8 +802,8 @@ async function handler(req: NextApiRequest & { userId?: number }) {
}
if (
!Number.isNaN(eventType.price) &&
eventType.price > 0 &&
!Number.isNaN(stripeAppData.price) &&
stripeAppData.price > 0 &&
!originalRescheduledBooking?.paid &&
!!booking
) {
@@ -853,7 +859,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
eventTitle: eventType.title,
eventDescription: eventType.description,
requiresConfirmation: eventType.requiresConfirmation || null,
price: eventType.price,
price: stripeAppData.price,
currency: eventType.currency,
length: eventType.length,
};
@@ -9,6 +9,7 @@ import getStripe from "@calcom/app-store/stripepayment/lib/client";
import dayjs from "@calcom/dayjs";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
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 { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
@@ -25,6 +26,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
const [timezone, setTimezone] = useState<string | null>(null);
useTheme(props.profile.theme);
const isEmbed = useIsEmbed();
const stripeAppData = getStripeAppData(props.eventType);
useEffect(() => {
let embedIframeWidth = 0;
const _timezone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
@@ -113,9 +115,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
<div className="col-span-2 mb-6">
<IntlProvider locale="en">
<FormattedNumber
value={props.eventType.price / 100.0}
value={stripeAppData.price / 100.0}
style="currency"
currency={props.eventType.currency.toUpperCase()}
currency={stripeAppData.currency.toUpperCase()}
/>
</IntlProvider>
</div>
@@ -3,6 +3,7 @@ import { z } from "zod";
import { PaymentData } from "@calcom/app-store/stripepayment/lib/server";
import prisma from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { ssrInit } from "@server/lib/ssr";
@@ -50,6 +51,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventName: true,
requiresConfirmation: true,
userId: true,
metadata: true,
users: {
select: {
name: true,
@@ -104,7 +106,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
user,
eventType,
eventType: {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
},
booking,
trpcState: ssr.dehydrate(),
payment,
+3 -1
View File
@@ -3,6 +3,8 @@ import { PeriodType, Prisma, SchedulingType, UserPlan } from "@prisma/client";
import { DailyLocationType } from "@calcom/app-store/locations";
import { userSelect } from "@calcom/prisma/selects";
import { _EventTypeModel } from "@calcom/prisma/zod";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
type User = Prisma.UserGetPayload<typeof userSelect>;
@@ -85,7 +87,7 @@ const commons = {
userId: 0,
workflows: [],
users: [user],
metadata: {},
metadata: EventTypeMetaDataSchema.parse({}),
};
const min15Event = {
+11
View File
@@ -0,0 +1,11 @@
import { getEventTypeAppData } from "@calcom/app-store/utils";
export default function getStripeAppData(
eventType: Parameters<typeof getEventTypeAppData>[0],
forcedGet?: boolean
) {
const stripeAppData = getEventTypeAppData(eventType, "stripe", forcedGet);
// This is the current expectation of system to have price and currency set always(using DB Level defaults).
// Newly added apps code should assume that their app data might not be set.
return stripeAppData || { enabled: false, price: 0, currency: "usd" };
}
+7 -4
View File
@@ -1,9 +1,9 @@
import { Prisma, UserPlan } from "@prisma/client";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
export type TeamWithMembers = Awaited<ReturnType<typeof getTeamWithMembers>>;
export async function getTeamWithMembers(id?: number, slug?: string) {
const userSelect = Prisma.validator<Prisma.UserSelect>()({
username: true,
@@ -13,7 +13,6 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
plan: true,
bio: true,
});
const teamSelect = Prisma.validator<Prisma.TeamSelect>()({
id: true,
name: true,
@@ -36,6 +35,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
users: {
select: userSelect,
},
metadata: true,
...baseEventTypeSelect,
},
},
@@ -47,7 +47,6 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
});
if (!team) return null;
const memberships = await prisma.membership.findMany({
where: {
teamId: team.id,
@@ -65,7 +64,11 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
};
});
return { ...team, members };
const eventTypes = team.eventTypes.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
}));
return { ...team, eventTypes, members };
}
// also returns team
export async function isTeamAdmin(userId: number, teamId: number) {
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AppCategories" ADD VALUE 'analytics';
+4
View File
@@ -71,9 +71,12 @@ model EventType {
schedulingType SchedulingType?
schedule Schedule? @relation(fields: [scheduleId], references: [id])
scheduleId Int?
// price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column.
price Int @default(0)
// currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column.
currency String @default("usd")
slotInterval Int?
/// @zod.custom(imports.EventTypeMetaDataSchema)
metadata Json?
/// @zod.custom(imports.successRedirectUrl)
successRedirectUrl String?
@@ -481,6 +484,7 @@ enum AppCategories {
video
web3
automation
analytics
}
model App {
+40 -12
View File
@@ -2,68 +2,96 @@
{
"/*": "This file is auto-generated and updated by `yarn app-store create/edit`. Don't edit it manually",
"dirName": "routing-forms",
"categories": ["other"],
"categories": [
"other"
],
"slug": "routing-forms",
"type": "routing-forms_other"
},
{
"dirName": "whereby",
"categories": ["video"],
"categories": [
"video"
],
"slug": "whereby",
"type": "whereby_video"
},
{
"dirName": "around",
"categories": ["video"],
"categories": [
"video"
],
"slug": "around",
"type": "around_video"
},
{
"dirName": "riverside",
"categories": ["video"],
"categories": [
"video"
],
"slug": "riverside",
"type": "riverside_video"
},
{
"dirName": "typeform",
"categories": ["other"],
"categories": [
"other"
],
"slug": "typeform",
"type": "typeform_other"
},
{
"dirName": "ping",
"categories": ["video"],
"categories": [
"video"
],
"slug": "ping",
"type": "ping_video"
},
{
"dirName": "campfire",
"categories": ["video"],
"categories": [
"video"
],
"slug": "campfire",
"type": "campfire_video"
},
{
"dirName": "rainbow",
"categories": ["web3"],
"categories": [
"web3"
],
"slug": "rainbow",
"type": "rainbow_web3"
},
{
"dirName": "raycast",
"categories": ["other"],
"categories": [
"other"
],
"slug": "raycast",
"type": "raycast_other"
},
{
"dirName": "n8n",
"categories": ["automation"],
"categories": [
"automation"
],
"slug": "n8n",
"type": "n8n_automation"
},
{
"dirName": "exchangecalendar",
"categories": ["calendar"],
"categories": [
"calendar"
],
"slug": "exchange",
"type": "exchange_calendar"
},
{
"dirName": "fathom",
"categories": ["analytics"],
"slug": "fathom",
"type": "fathom_analytics"
}
]
]
+10
View File
@@ -10,6 +10,7 @@ import type {
ZodTypeAny,
} from "zod";
import { appDataSchemas } from "@calcom/app-store/apps.browser.generated";
import dayjs from "@calcom/dayjs";
import { slugify } from "@calcom/lib/slugify";
@@ -24,6 +25,15 @@ export enum Frequency {
SECONDLY = 6,
}
export const EventTypeMetaDataSchema = z
.object({
smartContractAddress: z.string().optional(),
blockchainId: z.number().optional(),
giphyThankYouPage: z.string().optional(),
apps: z.object(appDataSchemas).partial().optional(),
})
.nullable();
export const eventTypeLocations = z.array(
z.object({
// TODO: Couldn't find a way to make it a union of types from App Store locations
+77 -31
View File
@@ -9,6 +9,7 @@ import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/custom
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { getEventTypeAppData } from "@calcom/app-store/utils";
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { DailyLocationType } from "@calcom/core/location";
@@ -17,6 +18,7 @@ import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import jackson from "@calcom/lib/jackson";
import {
@@ -36,8 +38,8 @@ import {
deleteWebUser as syncServicesDeleteWebUser,
updateWebUser as syncServicesUpdateWebUser,
} from "@calcom/lib/sync/SyncServiceManager";
import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import prisma, { baseEventTypeSelect, baseUserSelect, bookingMinimalSelect } from "@calcom/prisma";
import { userMetadata, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
import { TRPCError } from "@trpc/server";
@@ -272,17 +274,12 @@ const loggedInViewerRouter = createProtectedRouter()
async resolve({ ctx }) {
const { prisma } = ctx;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
position: true,
successRedirectUrl: true,
hashedLink: true,
destinationCalendar: true,
team: true,
metadata: true,
users: {
select: {
id: true,
username: true,
name: true,
},
select: baseUserSelect,
},
...baseEventTypeSelect,
});
@@ -352,21 +349,30 @@ const loggedInViewerRouter = createProtectedRouter()
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const userEventTypes = user.eventTypes.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
}));
// backwards compatibility, TMP:
const typesRaw = await prisma.eventType.findMany({
where: {
userId: ctx.user.id,
},
select: eventTypeSelect,
orderBy: [
{
position: "desc",
const typesRaw = (
await prisma.eventType.findMany({
where: {
userId: ctx.user.id,
},
{
id: "asc",
},
],
});
select: eventTypeSelect,
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
})
).map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
}));
type EventTypeGroup = {
teamId?: number | null;
@@ -378,11 +384,11 @@ const loggedInViewerRouter = createProtectedRouter()
membershipCount: number;
readOnly: boolean;
};
eventTypes: typeof user.eventTypes[number][];
eventTypes: typeof userEventTypes;
};
let eventTypeGroups: EventTypeGroup[] = [];
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const eventTypesHashMap = userEventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
const oldItem = hashMap[newItem.id];
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
@@ -414,10 +420,12 @@ const loggedInViewerRouter = createProtectedRouter()
membershipCount: membership.team.members.length,
readOnly: membership.role === MembershipRole.MEMBER,
},
eventTypes: membership.team.eventTypes,
eventTypes: membership.team.eventTypes.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
})),
}))
);
return {
viewer: {
plan: user.plan,
@@ -764,15 +772,37 @@ const loggedInViewerRouter = createProtectedRouter()
const appId = input.appId;
const { credentials } = user;
const apps = getApps(credentials);
const appFromDb = apps.find((app) => app.credential?.appId === appId);
const appFromDb = apps.find((app) => app.slug === appId);
if (!appFromDb) {
return null;
throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find app ${appId}` });
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { credential: _, credentials: _1, ...app } = appFromDb;
return app;
return {
isInstalled: appFromDb.credentials.length,
...app,
};
},
})
.query("apps", {
input: z.object({
extendsFeature: z.literal("EventType"),
}),
async resolve({ ctx, input }) {
const { user } = ctx;
const { credentials } = user;
const apps = getApps(credentials);
return apps
.filter((app) => app.extendsFeature?.includes(input.extendsFeature))
.map((app) => ({
...app,
isInstalled: !!app.credentials.length,
}));
},
})
.query("appCredentialsByType", {
input: z.object({
appType: z.string(),
@@ -1188,9 +1218,12 @@ const loggedInViewerRouter = createProtectedRouter()
locations: true,
destinationCalendar: true,
price: true,
currency: true,
metadata: true,
},
});
// TODO: Improve this uninstallation cleanup per event by keeping a relation of EventType to App which has the data.
for (const eventType of eventTypes) {
if (eventType.locations) {
// If it's a video, replace the location with Cal video
@@ -1263,9 +1296,13 @@ const loggedInViewerRouter = createProtectedRouter()
}
}
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
const stripeAppData = getStripeAppData({ ...eventType, metadata });
// If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings
if (credential.app?.categories.includes(AppCategories.payment)) {
if (eventType.price) {
if (stripeAppData.price) {
await prisma.$transaction(async () => {
await prisma.eventType.update({
where: {
@@ -1273,7 +1310,16 @@ const loggedInViewerRouter = createProtectedRouter()
},
data: {
hidden: true,
price: 0,
metadata: {
...metadata,
apps: {
...metadata?.apps,
stripe: {
...metadata?.apps?.stripe,
price: 0,
},
},
},
},
});
@@ -7,7 +7,7 @@ import { DailyLocationType } from "@calcom/app-store/locations";
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
import { validateBookingLimitOrder } from "@calcom/lib";
import { _DestinationCalendarModel, _EventTypeCustomInputModel, _EventTypeModel } from "@calcom/prisma/zod";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema, stringOrNumber } from "@calcom/prisma/zod-utils";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
import { TRPCError } from "@trpc/server";
@@ -77,6 +77,9 @@ const EventTypeUpdateInput = _EventTypeModel
hashedLink: z.string(),
})
.partial()
.extend({
metadata: EventTypeMetaDataSchema.optional(),
})
.merge(
_EventTypeModel
/** Required fields */
@@ -267,7 +270,10 @@ export const eventTypesRouter = createProtectedRouter()
hashedLink,
...rest
} = input;
const data: Prisma.EventTypeUpdateInput = rest;
const data: Prisma.EventTypeUpdateInput = {
...rest,
metadata: rest.metadata === null ? Prisma.DbNull : rest.metadata,
};
data.locations = locations ?? undefined;
if (periodType) {
data.periodType = handlePeriodType(periodType);
+6 -1
View File
@@ -54,6 +54,7 @@ export interface App {
| `${string}_web3`
| `${string}_other`
| `${string}_automation`
| `${string}_analytics`
| `${string}_other_calendar`;
/**
@@ -84,7 +85,11 @@ export interface App {
/** The category to which this app belongs, currently we have `calendar`, `payment` or `video` */
categories?: string[];
/**
* `User` is the broadest category. `EventType` is when you want to add features to EventTypes.
* See https://app.gitbook.com/o/6snd8PyPYMhg0wUw6CeQ/s/VXRprBTuMlihk37NQgUU/~/changes/6xkqZ4qvJ3Xh9k8UaWaZ/engineering/product-specs/app-store#user-apps for more details
*/
extendsFeature?: "EventType" | "User";
/** An absolute url to the app logo */
logo: string;
/** Company or individual publishing this app */
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { ErrorInfo } from "react";
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ children: React.ReactNode; message?: string },
{ error: Error | null; errorInfo: ErrorInfo | null }
> {
constructor(props: { children: React.ReactNode } | Readonly<{ children: React.ReactNode }>) {
@@ -20,7 +20,7 @@ class ErrorBoundary extends React.Component<
// Error path
return (
<div>
<h2>Something went wrong.</h2>
<h2>{this.props.message || "Something went wrong."}</h2>
<details style={{ whiteSpace: "pre-wrap" }}>
{this.state.error && this.state.error.toString()}
</details>
+1 -1
View File
@@ -527,7 +527,7 @@ function useShouldDisplayNavigationItem(item: NavigationItemType) {
const { data: routingForms } = trpc.useQuery(["viewer.appById", { appId: "routing-forms" }], {
enabled: status === "authenticated" && requiredCredentialNavigationItems.includes(item.name),
});
return !requiredCredentialNavigationItems.includes(item.name) || !!routingForms;
return !requiredCredentialNavigationItems.includes(item.name) || routingForms?.isInstalled;
}
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, router }) => {
+1 -1
View File
@@ -3,9 +3,9 @@ import type { Credential } from "@prisma/client";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { App } from "@calcom/types/App";
import { Icon } from "@calcom/ui/Icon";
import { showToast } from "@calcom/ui/v2";
import Button from "@calcom/ui/v2/core/Button";
interface AppCardProps {
@@ -26,6 +26,16 @@ const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
href: "/apps/installed/payment",
icon: Icon.FiCreditCard,
},
{
name: "Automation",
href: "/apps/installed/automation",
icon: Icon.FiShare2,
},
{
name: "Analytics",
href: "/apps/installed/analytics",
icon: Icon.FiBarChart,
},
{
name: "other",
href: "/apps/installed/other",
@@ -1,21 +1,23 @@
import { Prisma, SchedulingType } from "@prisma/client";
import { useMemo } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { z } from "zod";
import { classNames, parseRecurringEvent } from "@calcom/lib";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { baseEventTypeSelect } from "@calcom/prisma";
import { EventTypeModel } from "@calcom/prisma/zod";
import { Icon } from "@calcom/ui";
import Badge from "@calcom/ui/v2/core/Badge";
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
select: baseEventTypeSelect,
});
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
export type EventTypeDescriptionProps = {
eventType: EventType;
eventType: Pick<
z.infer<typeof EventTypeModel>,
Exclude<keyof typeof baseEventTypeSelect, "recurringEvent"> | "metadata"
> & {
recurringEvent: Prisma.JsonValue;
};
className?: string;
};
@@ -27,6 +29,8 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
[eventType.recurringEvent]
);
const stripeAppData = getStripeAppData(eventType);
return (
<>
<div className={classNames("dark:text-darkgray-800 text-neutral-500", className)}>
@@ -65,14 +69,14 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
</Badge>
</li>
)}
{eventType.price > 0 && (
{stripeAppData.price > 0 && (
<li>
<Badge variant="gray" size="lg" StartIcon={Icon.FiCreditCard}>
<IntlProvider locale="en">
<FormattedNumber
value={eventType.price / 100.0}
value={stripeAppData.price / 100.0}
style="currency"
currency={eventType.currency.toUpperCase()}
currency={stripeAppData.currency.toUpperCase()}
/>
</IntlProvider>
</Badge>