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 BalharaandPeer Richelsen authored and GitHub committed 2022-10-14 10:24:43 -06:00
1 parent 215e3c93d6
commit 271d4319b9
79 files changed
+1324 -1466

No files matched your search

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