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:
co-authored by
Peer Richelsen
parent
215e3c93d6
commit
271d4319b9
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
isSunrise: z.boolean(),
|
||||
})
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as add } from "./add";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as api from "./api";
|
||||
export { metadata } from "./_metadata";
|
||||
@@ -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 |
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
trackingId: z.string(),
|
||||
})
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
Vendored
+11
@@ -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];
|
||||
}>;
|
||||
|
||||
@@ -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,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 = {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+6
-1
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user