diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 4ff36ca97a8997..ae2313c733796d 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -21,6 +21,7 @@ export type AppPageProps = { isGlobal?: AppType["isGlobal"]; logo: string; slug: string; + dirName: string | undefined; variant: string; body: React.ReactNode; categories: string[]; @@ -68,6 +69,7 @@ export const AppPage = ({ dependencies, concurrentMeetings, paid, + dirName, }: AppPageProps) => { const { t, i18n } = useLocale(); const hasDescriptionItems = descriptionItems && descriptionItems.length > 0; @@ -223,6 +225,7 @@ export const AppPage = ({ multiInstall concurrentMeetings={concurrentMeetings} paid={paid} + dirName={dirName} {...props} /> ); @@ -262,6 +265,7 @@ export const AppPage = ({ credentials={appDbQuery.data?.credentials} concurrentMeetings={concurrentMeetings} paid={paid} + dirName={dirName} {...props} /> ); diff --git a/apps/web/components/apps/InstallAppButtonChild.tsx b/apps/web/components/apps/InstallAppButtonChild.tsx index 274f9dfcf6c60a..52900a523b6ac4 100644 --- a/apps/web/components/apps/InstallAppButtonChild.tsx +++ b/apps/web/components/apps/InstallAppButtonChild.tsx @@ -1,7 +1,14 @@ +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; + import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { doesAppSupportTeamInstall } from "@calcom/app-store/utils"; import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner"; import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; +import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; +import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; +import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; @@ -26,7 +33,9 @@ export const InstallAppButtonChild = ({ multiInstall, credentials, concurrentMeetings, + dirName, paid, + onClick, ...props }: { userAdminTeams?: UserAdminTeams; @@ -36,8 +45,10 @@ export const InstallAppButtonChild = ({ credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"]; concurrentMeetings?: boolean; paid?: AppFrontendPayload["paid"]; + dirName: string | undefined; } & ButtonProps) => { const { t } = useLocale(); + const router = useRouter(); const mutation = useAddAppMutation(null, { onSuccess: (data) => { @@ -49,6 +60,18 @@ export const InstallAppButtonChild = ({ }, }); const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false; + const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata]; + const redirectToAppOnboarding = useMemo(() => shouldRedirectToAppOnboarding(appMetadata), [appMetadata]); + + const _onClick = (e: React.MouseEvent) => { + if (redirectToAppOnboarding) { + router.push( + getAppOnboardingUrl({ slug: addAppMutationInput.slug, step: AppOnboardingSteps.ACCOUNTS_STEP }) + ); + } else if (onClick) { + onClick(e); + } + }; // Paid apps don't support team installs at the moment // Also, cal.ai(the only paid app at the moment) doesn't support team install either @@ -56,6 +79,7 @@ export const InstallAppButtonChild = ({ return ( + ); + } return ( diff --git a/apps/web/components/apps/installation/AccountsStepCard.tsx b/apps/web/components/apps/installation/AccountsStepCard.tsx new file mode 100644 index 00000000000000..4361e6b928d52f --- /dev/null +++ b/apps/web/components/apps/installation/AccountsStepCard.tsx @@ -0,0 +1,97 @@ +import type { FC } from "react"; +import React, { useState } from "react"; + +import { classNames } from "@calcom/lib"; +import { CAL_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { Team, User } from "@calcom/prisma/client"; +import { Avatar, StepCard } from "@calcom/ui"; + +type AccountSelectorProps = { + avatar?: string; + name: string; + alreadyInstalled: boolean; + onClick: () => void; + loading: boolean; + testId: string; +}; + +const AccountSelector: FC = ({ + avatar, + alreadyInstalled, + name, + onClick, + loading, + testId, +}) => { + const { t } = useLocale(); + const [selected, setSelected] = useState(false); + return ( +
{ + if (!alreadyInstalled && !loading) { + setSelected(true); + onClick(); + } + }}> + +
+ {name} + {alreadyInstalled ? {t("already_installed")} : ""} +
+
+ ); +}; + +export type PersonalAccountProps = Pick & { alreadyInstalled: boolean }; + +export type TeamsProp = (Pick & { + alreadyInstalled: boolean; +})[]; + +type AccountStepCardProps = { + teams: TeamsProp; + personalAccount: PersonalAccountProps; + onSelect: (id?: number) => void; + loading: boolean; +}; + +export const AccountsStepCard: FC = ({ teams, personalAccount, onSelect, loading }) => { + const { t } = useLocale(); + return ( + +
{t("install_app_on")}
+
+ onSelect()} + loading={loading} + /> + {teams.map((team) => ( + onSelect(team.id)} + loading={loading} + /> + ))} +
+
+ ); +}; diff --git a/apps/web/components/apps/installation/ConfigureStepCard.tsx b/apps/web/components/apps/installation/ConfigureStepCard.tsx new file mode 100644 index 00000000000000..118ad0cc47154c --- /dev/null +++ b/apps/web/components/apps/installation/ConfigureStepCard.tsx @@ -0,0 +1,223 @@ +import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]"; +import { X } from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; +import type { FC } from "react"; +import React, { forwardRef, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { useForm } from "react-hook-form"; +import type { z } from "zod"; + +import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface"; +import type { EventTypeAppsList } from "@calcom/app-store/utils"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { AppCategories } from "@calcom/prisma/enums"; +import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { Button, Form } from "@calcom/ui"; + +import useAppsData from "@lib/hooks/useAppsData"; + +type TFormType = { + metadata: z.infer; +}; + +type ConfigureStepCardProps = { + slug: string; + userName: string; + categories: AppCategories[]; + credentialId?: number; + loading?: boolean; + formPortalRef: React.RefObject; + eventTypes: TEventType[] | undefined; + setConfigureStep: Dispatch>; + handleSetUpLater: () => void; +}; + +type EventTypeAppSettingsFormProps = Pick< + ConfigureStepCardProps, + "slug" | "userName" | "categories" | "credentialId" | "loading" +> & { + eventType: TEventType; + handleDelete: () => void; + onSubmit: (values: z.infer) => void; +}; + +type EventTypeAppSettingsWrapperProps = Pick< + ConfigureStepCardProps, + "slug" | "userName" | "categories" | "credentialId" +> & { + eventType: TEventType; +}; + +const EventTypeAppSettingsWrapper: FC = ({ + slug, + eventType, + categories, + credentialId, +}) => { + const { getAppDataGetter, getAppDataSetter } = useAppsData(); + + useEffect(() => { + const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId); + appDataSetter("enabled", true); + }, []); + + return ( + + ); +}; + +const EventTypeAppSettingsForm = forwardRef( + function EventTypeAppSettingsForm(props, ref) { + const { handleDelete, onSubmit, eventType, loading } = props; + + const formMethods = useForm({ + defaultValues: { + metadata: eventType?.metadata, + }, + }); + + return ( +
{ + const data = formMethods.getValues("metadata"); + onSubmit(data); + }}> +
+
+
+ {eventType.title}{" "} + + /{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug} + +
+ + !loading && handleDelete()} + /> + +
+
+
+ ); + } +); + +export const ConfigureStepCard: FC = ({ + loading, + formPortalRef, + eventTypes, + setConfigureStep, + handleSetUpLater, + ...props +}) => { + const { t } = useLocale(); + const { control, getValues } = useFormContext(); + const { fields, update } = useFieldArray({ + control, + name: "eventTypes", + keyName: "fieldId", + }); + + const submitRefs = useRef>>([]); + submitRefs.current = fields.map( + (_ref, index) => (submitRefs.current[index] = React.createRef()) + ); + const mainForSubmitRef = useRef(null); + const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState( + fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false })) + ); + const [submit, setSubmit] = useState(false); + const allUpdated = updatedEventTypesStatus.every((item) => item.updated); + + useEffect(() => { + setUpdatedEventTypesStatus((prev) => + prev.filter((state) => fields.some((field) => field.id === state.id && field.selected)) + ); + if (!fields.some((field) => field.selected)) { + setConfigureStep(false); + } + }, [fields]); + + useEffect(() => { + if (submit && allUpdated && mainForSubmitRef.current) { + mainForSubmitRef.current?.click(); + setSubmit(false); + } + }, [submit, allUpdated, getValues, mainForSubmitRef]); + + return ( + formPortalRef?.current && + createPortal( +
+
+ {fields.map((field, index) => { + return ( + field.selected && ( + { + const eventMetadataDb = eventTypes?.find( + (eventType) => eventType.id == field.id + )?.metadata; + update(index, { ...field, selected: false, metadata: eventMetadataDb }); + }} + onSubmit={(data) => { + update(index, { ...field, metadata: data }); + setUpdatedEventTypesStatus((prev) => + prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item)) + ); + }} + ref={submitRefs.current[index]} + {...props} + /> + ) + ); + })} +
+ + + + +
+ +
+
, + formPortalRef?.current + ) + ); +}; diff --git a/apps/web/components/apps/installation/EventTypesStepCard.tsx b/apps/web/components/apps/installation/EventTypesStepCard.tsx new file mode 100644 index 00000000000000..c11fe9627a3e35 --- /dev/null +++ b/apps/web/components/apps/installation/EventTypesStepCard.tsx @@ -0,0 +1,130 @@ +import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]"; +import type { Dispatch, SetStateAction } from "react"; +import type { FC } from "react"; +import React from "react"; +import { useFieldArray, useFormContext } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { ScrollableArea, Badge, Button } from "@calcom/ui"; + +type EventTypesCardProps = { + userName: string; + setConfigureStep: Dispatch>; + handleSetUpLater: () => void; +}; + +export const EventTypesStepCard: FC = ({ + setConfigureStep, + userName, + handleSetUpLater, +}) => { + const { t } = useLocale(); + const { control } = useFormContext(); + const { fields, update } = useFieldArray({ + control, + name: "eventTypes", + keyName: "fieldId", + }); + + return ( +
+
+ +
    + {fields.map((field, index) => ( + update(index, { ...field, selected: !field.selected })} + userName={userName} + key={field.fieldId} + {...field} + /> + ))} +
+
+
+ + + +
+ +
+
+ ); +}; + +type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void }; + +const EventTypeCard: FC = ({ + title, + description, + id, + metadata, + length, + selected, + slug, + handleSelect, + team, + userName, +}) => { + const parsedMetaData = EventTypeMetaDataSchema.safeParse(metadata); + const durations = + parsedMetaData.success && + parsedMetaData.data?.multipleDuration && + Boolean(parsedMetaData.data?.multipleDuration.length) + ? [length, ...parsedMetaData.data?.multipleDuration?.filter((duration) => duration !== length)].sort() + : [length]; + return ( +
handleSelect()}> + + +
+ ); +}; diff --git a/apps/web/components/apps/installation/StepHeader.tsx b/apps/web/components/apps/installation/StepHeader.tsx new file mode 100644 index 00000000000000..e79dbc2ea6d77b --- /dev/null +++ b/apps/web/components/apps/installation/StepHeader.tsx @@ -0,0 +1,19 @@ +import type { FC, ReactNode } from "react"; + +type StepHeaderProps = { + children?: ReactNode; + title: string; + subtitle: string; +}; +export const StepHeader: FC = ({ children, title, subtitle }) => { + return ( +
+
+

{title}

+ +

{subtitle}

+
+ {children} +
+ ); +}; diff --git a/apps/web/components/eventtype/EventAppsTab.tsx b/apps/web/components/eventtype/EventAppsTab.tsx index 10160ae34a4038..9625c791d4c32a 100644 --- a/apps/web/components/eventtype/EventAppsTab.tsx +++ b/apps/web/components/eventtype/EventAppsTab.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import type { EventTypeSetupProps } from "pages/event-types/[type]"; import { useFormContext } from "react-hook-form"; -import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext"; import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface"; import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; @@ -13,6 +12,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Alert, Button, EmptyScreen } from "@calcom/ui"; +import useAppsData from "@lib/hooks/useAppsData"; + export type EventType = Pick["eventType"] & EventTypeAppCardComponentProps["eventType"]; @@ -28,51 +29,8 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => { eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || []; const notInstalledApps = eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || []; - const allAppsData = formMethods.watch("metadata")?.apps || {}; - - const setAllAppsData = (_allAppsData: typeof allAppsData) => { - formMethods.setValue( - "metadata", - { - ...formMethods.getValues("metadata"), - apps: _allAppsData, - }, - { shouldDirty: true } - ); - }; - - 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 eventTypeFormMetadata = formMethods.getValues("metadata"); - const getAppDataSetter = ( - appId: EventTypeAppsList, - appCategories: string[], - credentialId?: number - ): 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 = formMethods.getValues("metadata")?.apps || {}; - const appData = allAppsDataFromForm[appId]; - setAllAppsData({ - ...allAppsDataFromForm, - [appId]: { - ...appData, - [key]: value, - credentialId, - appCategories, - }, - }); - }; - }; + const { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata } = useAppsData(); const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({ eventType, diff --git a/apps/web/lib/hooks/useAppsData.ts b/apps/web/lib/hooks/useAppsData.ts new file mode 100644 index 00000000000000..b5496a867a0c1f --- /dev/null +++ b/apps/web/lib/hooks/useAppsData.ts @@ -0,0 +1,58 @@ +import type { FormValues } from "pages/event-types/[type]"; +import { useFormContext } from "react-hook-form"; + +import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext"; +import type { EventTypeAppsList } from "@calcom/app-store/utils"; + +const useAppsData = () => { + const formMethods = useFormContext(); + const allAppsData = formMethods.watch("metadata")?.apps || {}; + + const setAllAppsData = (_allAppsData: typeof allAppsData) => { + formMethods.setValue( + "metadata", + { + ...formMethods.getValues("metadata"), + apps: _allAppsData, + }, + { shouldDirty: true } + ); + }; + + 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 eventTypeFormMetadata = formMethods.getValues("metadata"); + + const getAppDataSetter = ( + appId: EventTypeAppsList, + appCategories: string[], + credentialId?: number + ): 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 = formMethods.getValues("metadata")?.apps || {}; + const appData = allAppsDataFromForm[appId]; + setAllAppsData({ + ...allAppsDataFromForm, + [appId]: { + ...appData, + [key]: value, + credentialId, + appCategories, + }, + }); + }; + }; + + return { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata }; +}; + +export default useAppsData; diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx index 8eee52c3f57585..1927fc8671501a 100644 --- a/apps/web/pages/apps/[slug]/index.tsx +++ b/apps/web/pages/apps/[slug]/index.tsx @@ -53,6 +53,7 @@ function SingleAppPage(props: inferSSRProps) { slug={data.slug} variant={data.variant} type={data.type} + dirName={data.dirName} logo={data.logo} categories={data.categories ?? [data.category]} author={data.publisher} diff --git a/apps/web/pages/apps/installation/[[...step]].tsx b/apps/web/pages/apps/installation/[[...step]].tsx new file mode 100644 index 00000000000000..1b5ba5ced0e37c --- /dev/null +++ b/apps/web/pages/apps/installation/[[...step]].tsx @@ -0,0 +1,513 @@ +import type { GetServerSidePropsContext } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import Head from "next/head"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Toaster } from "react-hot-toast"; +import { z } from "zod"; + +import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types"; +import { getLocale } from "@calcom/features/auth/lib/getLocale"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; +import { getAppOnboardingRedirectUrl } from "@calcom/lib/apps/getAppOnboardingRedirectUrl"; +import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; +import { CAL_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import prisma from "@calcom/prisma"; +import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { trpc } from "@calcom/trpc/react"; +import type { AppMeta } from "@calcom/types/App"; +import { Form, Steps, showToast } from "@calcom/ui"; + +import { HttpError } from "@lib/core/http/error"; + +import PageWrapper from "@components/PageWrapper"; +import type { PersonalAccountProps, TeamsProp } from "@components/apps/installation/AccountsStepCard"; +import { AccountsStepCard } from "@components/apps/installation/AccountsStepCard"; +import { ConfigureStepCard } from "@components/apps/installation/ConfigureStepCard"; +import { EventTypesStepCard } from "@components/apps/installation/EventTypesStepCard"; +import { StepHeader } from "@components/apps/installation/StepHeader"; + +export type TEventType = EventTypeAppSettingsComponentProps["eventType"] & + Pick & { + selected: boolean; + }; + +export type TEventTypesForm = { + eventTypes: TEventType[]; +}; + +const STEPS = [ + AppOnboardingSteps.ACCOUNTS_STEP, + AppOnboardingSteps.EVENT_TYPES_STEP, + AppOnboardingSteps.CONFIGURE_STEP, +] as const; +const MAX_NUMBER_OF_STEPS = STEPS.length; + +type StepType = (typeof STEPS)[number]; + +type StepObj = Record< + StepType, + { + getTitle: (appName: string) => string; + getDescription: (appName: string) => string; + stepNumber: number; + } +>; + +type OnboardingPageProps = { + appMetadata: AppMeta; + step: StepType; + teams: TeamsProp; + personalAccount: PersonalAccountProps; + eventTypes?: TEventType[]; + userName: string; + credentialId?: number; +}; + +const OnboardingPage = ({ + step, + teams, + personalAccount, + appMetadata, + eventTypes, + userName, + credentialId, +}: OnboardingPageProps) => { + const { t } = useLocale(); + const pathname = usePathname(); + const router = useRouter(); + + const STEPS_MAP: StepObj = { + [AppOnboardingSteps.ACCOUNTS_STEP]: { + getTitle: () => `${t("select_account_header")}`, + getDescription: (appName) => `${t("select_account_description", { appName })}`, + stepNumber: 1, + }, + [AppOnboardingSteps.EVENT_TYPES_STEP]: { + getTitle: () => `${t("select_event_types_header")}`, + getDescription: (appName) => `${t("select_event_types_description", { appName })}`, + stepNumber: 2, + }, + [AppOnboardingSteps.CONFIGURE_STEP]: { + getTitle: (appName) => `${t("configure_app_header", { appName })}`, + getDescription: () => `${t("configure_app_description")}`, + stepNumber: 3, + }, + } as const; + const [configureStep, setConfigureStep] = useState(false); + const [isSelectingAccount, setIsSelectingAccount] = useState(false); + + const currentStep: AppOnboardingSteps = useMemo(() => { + if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) { + return AppOnboardingSteps.CONFIGURE_STEP; + } + return step; + }, [step, configureStep]); + const stepObj = STEPS_MAP[currentStep]; + + const utils = trpc.useContext(); + + const formPortalRef = useRef(null); + + const formMethods = useForm({ + defaultValues: { + eventTypes, + }, + }); + + useEffect(() => { + eventTypes && formMethods.setValue("eventTypes", eventTypes); + }, [eventTypes]); + + const updateMutation = trpc.viewer.eventTypes.update.useMutation({ + onSuccess: async (data) => { + showToast(t("event_type_updated_successfully", { eventTypeTitle: data.eventType?.title }), "success"); + }, + async onSettled() { + await utils.viewer.eventTypes.get.invalidate(); + }, + onError: (err) => { + let message = ""; + if (err instanceof HttpError) { + const message = `${err.statusCode}: ${err.message}`; + showToast(message, "error"); + } + + if (err.data?.code === "UNAUTHORIZED") { + message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`; + } + + if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") { + message = `${err.data.code}: ${t(err.message)}`; + } + + if (err.data?.code === "INTERNAL_SERVER_ERROR") { + message = t("unexpected_error_try_again"); + } + + showToast(message ? t(message) : t(err.message), "error"); + }, + }); + + const handleSelectAccount = async (teamId?: number) => { + try { + setIsSelectingAccount(true); + if (appMetadata.isOAuth) { + const state = JSON.stringify({ + appOnboardingRedirectUrl: getAppOnboardingRedirectUrl(appMetadata.slug, teamId), + teamId, + }); + + const res = await fetch( + `/api/integrations/${ + appMetadata.slug == "stripe" ? "stripepayment" : appMetadata.slug + }/add?state=${state}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); + const oAuthUrl = (await res.json())?.url; + router.push(oAuthUrl); + return; + } else { + await fetch(`/api/integrations/${appMetadata.slug}/add${teamId ? `?teamId=${teamId}` : ""}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + router.push( + getAppOnboardingUrl({ + slug: appMetadata.slug, + step: AppOnboardingSteps.EVENT_TYPES_STEP, + teamId, + }) + ); + } + } catch (error) { + setIsSelectingAccount(false); + router.push(`/apps`); + } + }; + + const handleSetUpLater = () => { + router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`); + }; + + return ( +
+ + + {t("install")} {appMetadata?.name ?? ""} + + + +
+
+
+
{ + const mutationPromises = values?.eventTypes + .filter((eventType) => eventType.selected) + .map((value: TEventType) => { + // Prevent two payment apps to be enabled + // Ok to cast type here because this metadata will be updated as the event type metadata + if ( + checkForMultiplePaymentApps(value.metadata as z.infer) + ) + throw new Error(t("event_setup_multiple_payment_apps_error")); + if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) { + throw new Error(t("seats_and_no_show_fee_error")); + } + return updateMutation.mutateAsync({ + id: value.id, + metadata: value.metadata, + }); + }); + try { + await Promise.all(mutationPromises); + router.push("/event-types"); + } catch (err) { + console.error(err); + } + }}> + + + + {currentStep === AppOnboardingSteps.ACCOUNTS_STEP && ( + + )} + {currentStep === AppOnboardingSteps.EVENT_TYPES_STEP && + eventTypes && + Boolean(eventTypes?.length) && ( + + )} + {currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && ( + + )} + +
+
+
+ +
+ ); +}; + +// Redirect Error map to give context on edge cases, this is for the devs, never shown to users +const ERROR_MESSAGES = { + appNotFound: "App not found", + userNotAuthed: "User is not logged in", + userNotFound: "User from session not found", + appNotExtendsEventType: "App does not extend EventTypes", + userNotInTeam: "User is not in provided team", +} as const; + +const getUser = async (userId: number) => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + id: true, + avatar: true, + name: true, + username: true, + teams: { + where: { + accepted: true, + team: { + members: { + some: { + userId, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + select: { + team: { + select: { + id: true, + name: true, + logo: true, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new Error(ERROR_MESSAGES.userNotFound); + } + return user; +}; + +const getAppBySlug = async (appSlug: string) => { + const app = await prisma.app.findUnique({ + where: { slug: appSlug, enabled: true }, + select: { slug: true, keys: true, enabled: true, dirName: true }, + }); + if (!app) throw new Error(ERROR_MESSAGES.appNotFound); + return app; +}; + +const getEventTypes = async (userId: number, teamId?: number) => { + const eventTypes = ( + await prisma.eventType.findMany({ + select: { + id: true, + description: true, + durationLimits: true, + metadata: true, + length: true, + title: true, + position: true, + recurringEvent: true, + requiresConfirmation: true, + team: { select: { slug: true } }, + schedulingType: true, + teamId: true, + users: { select: { username: true } }, + seatsPerTimeSlot: true, + slug: true, + }, + where: teamId ? { teamId } : { userId, teamId: null }, + }) + ).sort((eventTypeA, eventTypeB) => { + return eventTypeB.position - eventTypeA.position; + }); + if (eventTypes.length === 0) { + return []; + } + return eventTypes.map((item) => ({ + ...item, + URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`, + selected: false, + })); +}; + +const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => { + const appInstalls = await prisma.credential.findMany({ + where: { + OR: [ + { + appId: appSlug, + userId: userId, + }, + teamIds && Boolean(teamIds.length) + ? { + appId: appSlug, + teamId: { in: teamIds }, + } + : {}, + ], + }, + }); + return appInstalls; +}; + +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + try { + let eventTypes: TEventType[] | null = null; + const { req, res, query, params } = context; + const stepsEnum = z.enum(STEPS); + const parsedAppSlug = z.coerce.string().parse(query?.slug); + const parsedStepParam = z.coerce.string().parse(params?.step); + const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId); + const _ = stepsEnum.parse(parsedStepParam); + const session = await getServerSession({ req, res }); + const locale = await getLocale(context.req); + const app = await getAppBySlug(parsedAppSlug); + const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata]; + const hasEventTypes = appMetadata?.extendsFeature === "EventType"; + + if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed); + if (!hasEventTypes) { + throw new Error(ERROR_MESSAGES.appNotExtendsEventType); + } + + const user = await getUser(session.user.id); + + const userAcceptedTeams = user.teams.map((team) => ({ ...team.team })); + const hasTeams = Boolean(userAcceptedTeams.length); + + const appInstalls = await getAppInstallsBySlug( + parsedAppSlug, + user.id, + userAcceptedTeams.map(({ id }) => id) + ); + + if (parsedTeamIdParam) { + const isUserMemberOfTeam = userAcceptedTeams.some((team) => team.id === parsedTeamIdParam); + if (!isUserMemberOfTeam) { + throw new Error(ERROR_MESSAGES.userNotInTeam); + } + } + + if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) { + eventTypes = await getEventTypes(user.id, parsedTeamIdParam); + if (eventTypes.length === 0) { + return { + redirect: { + permanent: false, + destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`, + }, + }; + } + } + + const personalAccount = { + id: user.id, + name: user.name, + avatar: user.avatar, + alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id), + }; + + const teamsWithIsAppInstalled = hasTeams + ? userAcceptedTeams.map((team) => ({ + ...team, + alreadyInstalled: appInstalls.some( + (install) => Boolean(install.teamId) && install.teamId === team.id + ), + })) + : []; + let credentialId = null; + if (parsedTeamIdParam) { + credentialId = + appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null; + } else { + credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null; + } + return { + props: { + ...(await serverSideTranslations(locale, ["common"])), + app, + appMetadata, + step: parsedStepParam, + teams: teamsWithIsAppInstalled, + personalAccount, + eventTypes, + teamId: parsedTeamIdParam ?? null, + userName: user.username, + credentialId, + } as OnboardingPageProps, + }; + } catch (err) { + if (err instanceof z.ZodError) { + return { redirect: { permanent: false, destination: "/apps" } }; + } + + if (err instanceof Error) { + switch (err.message) { + case ERROR_MESSAGES.userNotAuthed: + return { redirect: { permanent: false, destination: "/auth/login" } }; + case ERROR_MESSAGES.userNotFound: + return { redirect: { permanent: false, destination: "/auth/login" } }; + default: + return { redirect: { permanent: false, destination: "/apps" } }; + } + } + } +}; + +OnboardingPage.PageWrapper = PageWrapper; + +export default OnboardingPage; diff --git a/apps/web/playwright/apps/analytics/analyticsApps.e2e.ts b/apps/web/playwright/apps/analytics/analyticsApps.e2e.ts index d9d0137957baf9..3fa89085d16531 100644 --- a/apps/web/playwright/apps/analytics/analyticsApps.e2e.ts +++ b/apps/web/playwright/apps/analytics/analyticsApps.e2e.ts @@ -1,28 +1,20 @@ -import { loginUser } from "../../fixtures/regularBookings"; import { test } from "../../lib/fixtures"; const ALL_APPS = ["fathom", "matomo", "plausible", "ga4", "gtm", "metapixel"]; -test.describe("Check analytics Apps", () => { - test.beforeEach(async ({ page, users }) => { - await loginUser(users); - await page.goto("/apps/"); - }); +test.describe.configure({ mode: "parallel" }); +test.afterEach(({ users }) => users.deleteAll()); - test("Check analytics Apps", async ({ appsPage, page }) => { +test.describe("Check analytics Apps ", () => { + test("Check analytics Apps by skipping the configure step", async ({ appsPage, page, users }) => { + const user = await users.create(); + await user.apiLogin(); + await page.goto("/apps/"); await appsPage.goToAppsCategory("analytics"); - await appsPage.installApp("fathom"); - await appsPage.goBackToAppsPage(); - await appsPage.installApp("matomo"); - await appsPage.goBackToAppsPage(); - await appsPage.installApp("plausible"); - await appsPage.goBackToAppsPage(); - await appsPage.installApp("ga4"); - await appsPage.goBackToAppsPage(); - await appsPage.installApp("gtm"); - await appsPage.goBackToAppsPage(); - await appsPage.installApp("metapixel"); - await appsPage.goBackToAppsPage(); + for (const app of ALL_APPS) { + await appsPage.installAppSkipConfigure(app); + await appsPage.goBackToAppsPage(); + } await page.goto("/event-types"); await appsPage.goToEventType("30 min"); await appsPage.goToAppsTab(); @@ -32,4 +24,20 @@ test.describe("Check analytics Apps", () => { } await appsPage.verifyAppsInfo(6); }); + + test("Check analytics Apps using the new flow", async ({ appsPage, page, users }) => { + const user = await users.create(); + await user.apiLogin(); + const eventTypes = await user.getUserEventsAsOwner(); + const eventTypesIds = eventTypes.map((item) => item.id); + + for (const app of ALL_APPS) { + await page.goto("/apps/categories/analytics"); + await appsPage.installApp(app, eventTypesIds); + } + + for (const id of eventTypesIds) { + await appsPage.verifyAppsInfoNew(ALL_APPS, id); + } + }); }); diff --git a/apps/web/playwright/fixtures/apps.ts b/apps/web/playwright/fixtures/apps.ts index 61c649684ab1bf..179b3c05ff77e9 100644 --- a/apps/web/playwright/fixtures/apps.ts +++ b/apps/web/playwright/fixtures/apps.ts @@ -1,14 +1,48 @@ import { expect, type Page } from "@playwright/test"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding"; + export function createAppsFixture(page: Page) { return { goToAppsCategory: async (category: string) => { await page.getByTestId(`app-store-category-${category}`).nth(1).click(); await page.goto("apps/categories/analytics"); }, - installApp: async (app: string) => { + installAppSkipConfigure: async (app: string) => { await page.getByTestId(`app-store-app-card-${app}`).click(); await page.getByTestId("install-app-button").click(); + const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata]; + if (shouldRedirectToAppOnboarding(appMetadata)) { + await page.click('[data-testid="install-app-button-personal"]'); + await page.waitForURL(`apps/installation/event-types?slug=${app}`); + await page.click('[data-testid="set-up-later"]'); + } + }, + installApp: async (app: string, eventTypeIds: number[]) => { + await page.getByTestId(`app-store-app-card-${app}`).click(); + (await page.waitForSelector('[data-testid="install-app-button"]')).click(); + + const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata]; + if (shouldRedirectToAppOnboarding(appMetadata)) { + await page.click('[data-testid="install-app-button-personal"]'); + await page.waitForURL(`apps/installation/event-types?slug=${app}`); + + for (const id of eventTypeIds) { + await page.click(`[data-testid="select-event-type-${id}"]`); + } + + await page.click(`[data-testid="save-event-types"]`); + + // adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it + if (app === "gtm") { + await page.waitForLoadState("domcontentloaded"); + for (let index = 0; index < eventTypeIds.length; index++) { + await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id"); + } + } + await page.click(`[data-testid="configure-step-save"]`); + } }, goBackToAppsPage: async () => { await page.getByTestId("add-apps").click(); @@ -20,10 +54,17 @@ export function createAppsFixture(page: Page) { await page.getByTestId("vertical-tab-apps").click(); }, activeApp: async (app: string) => { - await page.getByTestId(`${app}-app-switch`).click(); + await page.locator(`[data-testid='${app}-app-switch']`).click(); }, verifyAppsInfo: async (activeApps: number) => { await expect(page.locator(`text=6 apps, ${activeApps} active`)).toBeVisible(); }, + verifyAppsInfoNew: async (apps: string[], eventTypeId: number) => { + await page.goto(`event-types/${eventTypeId}?tabName=apps`); + await page.waitForLoadState("domcontentloaded"); + for (const app of apps) { + await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible(); + } + }, }; } diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 8050f9338d159f..75d69830ff1611 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -1,4 +1,5 @@ import type { Page, WorkerInfo } from "@playwright/test"; +import { expect } from "@playwright/test"; import type Prisma from "@prisma/client"; import type { Team } from "@prisma/client"; import { Prisma as PrismaType } from "@prisma/client"; @@ -34,6 +35,30 @@ const userIncludes = PrismaType.validator()({ routingForms: true, }); +type InstallStripeParamsSkipTrue = { + eventTypeIds?: number[]; + skip: true; +}; + +type InstallStripeParamsSkipFalse = { + skip: false; + eventTypeIds: number[]; +}; +type InstallStripeParamsUnion = InstallStripeParamsSkipTrue | InstallStripeParamsSkipFalse; +type InstallStripeTeamPramas = InstallStripeParamsUnion & { + page: Page; + teamId: number; +}; +type InstallStripePersonalPramas = InstallStripeParamsUnion & { + page: Page; +}; + +type InstallStripeParams = InstallStripeParamsUnion & { + redirectUrl: string; + buttonSelector: string; + page: Page; +}; + const userWithEventTypes = PrismaType.validator()({ include: userIncludes, }); @@ -650,6 +675,12 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { userId: user.id, }, }), + getUserEventsAsOwner: async () => + prisma.eventType.findMany({ + where: { + userId: user.id, + }, + }), getFirstTeamEvent: async (teamId: number) => { return prisma.eventType.findFirstOrThrow({ where: { @@ -657,12 +688,15 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { }, }); }, - getPaymentCredential: async () => getPaymentCredential(store.page), setupEventWithPrice: async (eventType: Pick, slug: string) => setupEventWithPrice(eventType, slug, store.page), bookAndPayEvent: async (eventType: Pick) => bookAndPayEvent(user, eventType, store.page), makePaymentUsingStripe: async () => makePaymentUsingStripe(store.page), + installStripePersonal: async (params: InstallStripeParamsUnion) => + installStripePersonal({ page: store.page, ...params }), + installStripeTeam: async (params: InstallStripeParamsUnion & { teamId: number }) => + installStripeTeam({ page: store.page, ...params }), // ths is for developemnt only aimed to inject debugging messages in the metadata field of the user debug: async (message: string | Record) => { await prisma.user.update({ @@ -908,18 +942,49 @@ export async function makePaymentUsingStripe(page: Page) { await page.click('button:has-text("Pay now")'); } -export async function getPaymentCredential(page: Page) { - await page.goto("/apps/stripe"); +const installStripePersonal = async (params: InstallStripePersonalPramas) => { + const redirectUrl = `apps/installation/event-types?slug=stripe`; + const buttonSelector = '[data-testid="install-app-button-personal"]'; + await installStripe({ redirectUrl, buttonSelector, ...params }); +}; +const installStripeTeam = async ({ teamId, ...params }: InstallStripeTeamPramas) => { + const redirectUrl = `apps/installation/event-types?slug=stripe&teamId=${teamId}`; + const buttonSelector = `[data-testid="install-app-button-team${teamId}"]`; + await installStripe({ redirectUrl, buttonSelector, ...params }); +}; +const installStripe = async ({ + page, + skip, + eventTypeIds, + redirectUrl, + buttonSelector, +}: InstallStripeParams) => { + await page.goto("/apps/stripe"); /** We start the Stripe flow */ - await Promise.all([ - page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"), - page.click('[data-testid="install-app-button"]'), - ]); - - await Promise.all([ - page.waitForURL("/apps/installed/payment?hl=stripe"), - /** We skip filling Stripe forms (testing mode only) */ - page.click('[id="skip-account-app"]'), - ]); -} + await page.click('[data-testid="install-app-button"]'); + await page.click(buttonSelector); + + await page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"); + /** We skip filling Stripe forms (testing mode only) */ + await page.click('[id="skip-account-app"]'); + await page.waitForURL(redirectUrl); + if (skip) { + await page.click('[data-testid="set-up-later"]'); + return; + } + for (const id of eventTypeIds) { + await page.click(`[data-testid="select-event-type-${id}"]`); + } + await page.click(`[data-testid="save-event-types"]`); + for (let index = 0; index < eventTypeIds.length; index++) { + await page.locator('[data-testid="stripe-price-input"]').nth(index).fill(`1${index}`); + } + await page.click(`[data-testid="configure-step-save"]`); + await page.waitForURL(`event-types`); + for (let index = 0; index < eventTypeIds.length; index++) { + await page.goto(`event-types/${eventTypeIds[index]}?tabName=apps`); + await expect(page.getByTestId(`stripe-app-switch`)).toBeChecked(); + await expect(page.getByTestId(`stripe-price-input`)).toHaveValue(`1${index}`); + } +}; diff --git a/apps/web/playwright/hash-my-url.e2e.ts b/apps/web/playwright/hash-my-url.e2e.ts index b8e67d47c0d4f3..d490c7ce45a65e 100644 --- a/apps/web/playwright/hash-my-url.e2e.ts +++ b/apps/web/playwright/hash-my-url.e2e.ts @@ -55,8 +55,8 @@ test.describe("hash my url", () => { // Ensure that private URL is enabled after modifying the event type. // Additionally, if the slug is changed, ensure that the private URL is updated accordingly. await page.getByTestId("vertical-tab-event_setup_tab_title").click(); - await page.locator("[data-testid=event-title]").fill("somethingrandom"); - await page.locator("[data-testid=event-slug]").fill("somethingrandom"); + await page.locator("[data-testid=event-title]").first().fill("somethingrandom"); + await page.locator("[data-testid=event-slug]").first().fill("somethingrandom"); await page.locator("[data-testid=update-eventtype]").click(); await page.getByTestId("toast-success").waitFor(); await page.waitForLoadState("networkidle"); diff --git a/apps/web/playwright/integrations-stripe.e2e.ts b/apps/web/playwright/integrations-stripe.e2e.ts index 19291b634566b8..ca8d35e193b7d8 100644 --- a/apps/web/playwright/integrations-stripe.e2e.ts +++ b/apps/web/playwright/integrations-stripe.e2e.ts @@ -20,7 +20,7 @@ const IS_STRIPE_ENABLED = !!( process.env.PAYMENT_FEE_PERCENTAGE ); -test.describe("Stripe integration", () => { +test.describe("Stripe integration skip true", () => { // eslint-disable-next-line playwright/no-skipped-test test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed"); @@ -28,22 +28,20 @@ test.describe("Stripe integration", () => { test("Can add Stripe integration", async ({ page, users }) => { const user = await users.create(); await user.apiLogin(); - await page.goto("/apps/installed"); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); await expect(page.locator(`h3:has-text("Stripe")`)).toBeVisible(); await page.getByRole("list").getByRole("button").click(); await expect(page.getByRole("button", { name: "Remove App" })).toBeVisible(); }); }); - test("when enabling Stripe, credentialId is included", async ({ page, users }) => { const user = await users.create(); await user.apiLogin(); await page.goto("/apps/installed"); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; await user.setupEventWithPrice(eventType, "stripe"); @@ -68,7 +66,6 @@ test.describe("Stripe integration", () => { expect(stripeAppMetadata).toHaveProperty("credentialId"); expect(typeof stripeAppMetadata?.credentialId).toBe("number"); }); - test("when enabling Stripe, team credentialId is included", async ({ page, users }) => { const ownerObj = { username: "pro-user", name: "pro-user" }; const teamMatesObj = [ @@ -85,25 +82,10 @@ test.describe("Stripe integration", () => { }); await owner.apiLogin(); const { team } = await owner.getFirstTeamMembership(); - const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); const teamEvent = await owner.getFirstTeamEvent(team.id); - await page.goto("/apps/stripe"); - - /** We start the Stripe flow */ - await Promise.all([ - page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"), - page.click('[data-testid="install-app-button"]'), - page.click('[data-testid="anything else"]'), - ]); - - await Promise.all([ - page.waitForURL("/apps/installed/payment?hl=stripe"), - /** We skip filling Stripe forms (testing mode only) */ - page.click('[id="skip-account-app"]'), - ]); - + await owner.installStripeTeam({ skip: true, teamId: team.id }); await owner.setupEventWithPrice(teamEvent, "stripe"); // Need to wait for the DB to be updated with the metadata @@ -126,14 +108,13 @@ test.describe("Stripe integration", () => { expect(stripeAppMetadata).toHaveProperty("credentialId"); expect(typeof stripeAppMetadata?.credentialId).toBe("number"); }); - test("Can book a paid booking", async ({ page, users }) => { const user = await users.create(); const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; await user.apiLogin(); await page.goto("/apps/installed"); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); await user.setupEventWithPrice(eventType, "stripe"); await user.bookAndPayEvent(eventType); // success @@ -146,7 +127,7 @@ test.describe("Stripe integration", () => { await user.apiLogin(); await page.goto("/apps/installed"); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); await user.setupEventWithPrice(eventType, "stripe"); // booking process without payment @@ -170,7 +151,7 @@ test.describe("Stripe integration", () => { await user.apiLogin(); await page.goto("/apps/installed"); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); await user.setupEventWithPrice(eventType, "stripe"); await user.bookAndPayEvent(eventType); @@ -193,7 +174,7 @@ test.describe("Stripe integration", () => { await user.apiLogin(); await page.goto("/apps/installed"); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); await user.setupEventWithPrice(eventType, "stripe"); await user.bookAndPayEvent(eventType); @@ -213,7 +194,7 @@ test.describe("Stripe integration", () => { await user.apiLogin(); await page.goto("/apps/installed"); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); await user.setupEventWithPrice(eventType, "stripe"); await user.bookAndPayEvent(eventType); await user.confirmPendingPayment(); @@ -247,7 +228,7 @@ test.describe("Stripe integration", () => { const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; await user.apiLogin(); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); // Edit currency inside event type page await page.goto(`/event-types/${eventType?.id}?tabName=apps`); @@ -291,3 +272,238 @@ test.describe("Stripe integration", () => { }); }); }); + +test.describe("Stripe integration with the new app install flow skip flase", () => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed"); + + test("when enabling Stripe, credentialId is included skip false", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + await page.goto("/apps/installed"); + const eventTypes = await user.getUserEventsAsOwner(); + const eventTypeIds = eventTypes.map((item) => item.id); + + // await installStripe(page, "personal", false, eventTypeIds); + await user.installStripePersonal({ skip: false, eventTypeIds }); + + const eventTypeMetadatas = await prisma.eventType.findMany({ + where: { + id: { + in: eventTypeIds, + }, + }, + select: { + metadata: true, + }, + }); + + for (const eventTypeMetadata of eventTypeMetadatas) { + const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata); + const stripeAppMetadata = metadata?.apps?.stripe; + expect(stripeAppMetadata).toHaveProperty("credentialId"); + expect(typeof stripeAppMetadata?.credentialId).toBe("number"); + } + }); + test("when enabling Stripe, team credentialId is included skip false", async ({ page, users }) => { + const ownerObj = { username: "pro-user", name: "pro-user" }; + const teamMatesObj = [ + { name: "teammate-1" }, + { name: "teammate-2" }, + { name: "teammate-3" }, + { name: "teammate-4" }, + ]; + + const owner = await users.create(ownerObj, { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.COLLECTIVE, + }); + await owner.apiLogin(); + const { team } = await owner.getFirstTeamMembership(); + + const teamEvent = await owner.getFirstTeamEvent(team.id); + + await owner.installStripeTeam({ skip: false, teamId: team.id, eventTypeIds: [teamEvent.id] }); + + // Check event type metadata to see if credentialId is included + const eventTypeMetadata = await prisma.eventType.findFirst({ + where: { + id: teamEvent.id, + }, + select: { + metadata: true, + }, + }); + + const metadata = EventTypeMetaDataSchema.parse(eventTypeMetadata?.metadata); + + const stripeAppMetadata = metadata?.apps?.stripe; + + expect(stripeAppMetadata).toHaveProperty("credentialId"); + expect(typeof stripeAppMetadata?.credentialId).toBe("number"); + }); + test("Can book a paid booking skip false", async ({ page, users }) => { + const user = await users.create(); + const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; + await user.apiLogin(); + await page.goto("/apps/installed"); + + await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] }); + await user.bookAndPayEvent(eventType); + // success + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + }); + test("Pending payment booking should not be confirmed by default skip false", async ({ page, users }) => { + const user = await users.create(); + const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; + await user.apiLogin(); + await page.goto("/apps/installed"); + + await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] }); + + // booking process without payment + await page.goto(`${user.username}/${eventType?.slug}`); + await selectFirstAvailableTimeSlotNextMonth(page); + // --- fill form + await page.fill('[name="name"]', "Stripe Stripeson"); + await page.fill('[name="email"]', "test@example.com"); + + await Promise.all([page.waitForURL("/payment/*"), page.press('[name="email"]', "Enter")]); + + await page.goto(`/bookings/upcoming`); + + await expect(page.getByText("Unconfirmed")).toBeVisible(); + await expect(page.getByText("Pending payment").last()).toBeVisible(); + }); + + test("Paid booking should be able to be rescheduled skip false", async ({ page, users }) => { + const user = await users.create(); + const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; + await user.apiLogin(); + await page.goto("/apps/installed"); + + await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] }); + await user.bookAndPayEvent(eventType); + + // Rescheduling the event + await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]); + + await selectFirstAvailableTimeSlotNextMonth(page); + + await Promise.all([ + page.waitForURL("/payment/*"), + page.click('[data-testid="confirm-reschedule-button"]'), + ]); + + await user.makePaymentUsingStripe(); + }); + + test("Paid booking should be able to be cancelled skip false", async ({ page, users }) => { + const user = await users.create(); + const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; + await user.apiLogin(); + await page.goto("/apps/installed"); + + await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] }); + await user.bookAndPayEvent(eventType); + + await page.click('[data-testid="cancel"]'); + await page.click('[data-testid="confirm_cancel"]'); + + await expect(await page.locator('[data-testid="cancelled-headline"]').first()).toBeVisible(); + }); + + test.describe("When event is paid and confirmed skip false", () => { + let user: Awaited>; + let eventType: Prisma.EventType; + + test.beforeEach(async ({ page, users }) => { + user = await users.create(); + eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; + await user.apiLogin(); + await page.goto("/apps/installed"); + + await user.installStripePersonal({ skip: false, eventTypeIds: [eventType.id] }); + await user.bookAndPayEvent(eventType); + await user.confirmPendingPayment(); + }); + + test("Payment should confirm pending payment booking skip false", async ({ page, users }) => { + await page.goto("/bookings/upcoming"); + + const paidBadge = page.locator('[data-testid="paid_badge"]').first(); + + await expect(paidBadge).toBeVisible(); + expect(await paidBadge.innerText()).toBe("Paid"); + }); + + test("Paid and confirmed booking should be able to be rescheduled skip false", async ({ + page, + users, + }) => { + await Promise.all([page.waitForURL("/booking/*"), page.click('[data-testid="reschedule-link"]')]); + + await selectFirstAvailableTimeSlotNextMonth(page); + + await page.click('[data-testid="confirm-reschedule-button"]'); + + await expect(page.getByText("This meeting is scheduled")).toBeVisible(); + }); + + todo("Payment should trigger a BOOKING_PAID webhook"); + }); + + test.describe("Change stripe presented currency skip false", () => { + test("Should be able to change currency skip false", async ({ page, users }) => { + const user = await users.create(); + const eventType = user.eventTypes.find((e) => e.slug === "paid") as Prisma.EventType; + await user.apiLogin(); + + await page.goto("/apps/stripe"); + /** We start the Stripe flow */ + await page.click('[data-testid="install-app-button"]'); + await page.click('[data-testid="install-app-button-personal"]'); + + await page.waitForURL("https://connect.stripe.com/oauth/v2/authorize?*"); + /** We skip filling Stripe forms (testing mode only) */ + await page.click('[id="skip-account-app"]'); + await page.waitForURL(`apps/installation/event-types?slug=stripe`); + await page.click(`[data-testid="select-event-type-${eventType.id}"]`); + await page.click(`[data-testid="save-event-types"]`); + await page.locator('[data-testid="stripe-price-input"]').fill(`200`); + + // Select currency in dropdown + await page.getByTestId("stripe-currency-select").click(); + await page.locator("#react-select-2-input").fill("mexi"); + await page.locator("#react-select-2-option-81").click(); + + await page.click(`[data-testid="configure-step-save"]`); + await page.waitForURL(`event-types`); + + // Book event + await page.goto(`${user.username}/${eventType?.slug}`); + + // Confirm MXN currency it's displayed use expect + await expect(await page.getByText("MX$200.00")).toBeVisible(); + + await selectFirstAvailableTimeSlotNextMonth(page); + + // Confirm again in book form page + await expect(await page.getByText("MX$200.00")).toBeVisible(); + + // --- fill form + await page.fill('[name="name"]', "Stripe Stripeson"); + await page.fill('[name="email"]', "stripe@example.com"); + + // Confirm booking + await page.click('[data-testid="confirm-book-button"]'); + + // wait for url to be payment + await page.waitForURL("/payment/*"); + + // Confirm again in book form page + await expect(await page.getByText("MX$200.00")).toBeVisible(); + }); + }); +}); diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index d36262b956d021..9a419431b4e12c 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -119,7 +119,7 @@ test.describe("Reschedule Tests", async () => { test.skip(!IS_STRIPE_ENABLED, "Skipped as Stripe is not installed"); const user = await users.create(); await user.apiLogin(); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const eventType = user.eventTypes.find((e) => e.slug === "paid")!; const booking = await bookings.create(user.id, user.username, eventType.id, { @@ -160,7 +160,7 @@ test.describe("Reschedule Tests", async () => { test("Paid rescheduling should go to success page", async ({ page, users, bookings, payments }) => { const user = await users.create(); await user.apiLogin(); - await user.getPaymentCredential(); + await user.installStripePersonal({ skip: true }); await users.logout(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const eventType = user.eventTypes.find((e) => e.slug === "paid")!; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index baa0df45dad86c..69d05a0a922b93 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2405,6 +2405,13 @@ "disconnect_account_hint": "Disconnecting your connected account will change the way you log in. You will only be able to login to your account using email + password", "cookie_consent_checkbox": "I consent to our privacy policy and cookie usage", "make_a_call": "Make a Call", + "select_account_header": "Select Account", + "select_account_description": "Install {{appName}} on your personal account or on a team account.", + "select_event_types_header": "Select Event Types", + "select_event_types_description": "On which event type do you want to install {{appName}}?", + "configure_app_header": "Configure {{appName}}", + "configure_app_description": "Finalise the App setup. You can change these settings later.", + "already_installed": "already installed", "ooo_reasons_unspecified": "Unspecified", "ooo_reasons_vacation": "Vacation", "ooo_reasons_travel": "Travel", diff --git a/packages/app-store-cli/src/build.ts b/packages/app-store-cli/src/build.ts index 68702b77104bc4..91c461b548c951 100644 --- a/packages/app-store-cli/src/build.ts +++ b/packages/app-store-cli/src/build.ts @@ -328,6 +328,14 @@ function generateFiles() { lazyImport: true, }) ); + browserOutput.push( + ...getExportedObject("EventTypeSettingsMap", { + importConfig: { + fileToBeImported: "components/EventTypeAppSettingsInterface.tsx", + }, + lazyImport: true, + }) + ); const banner = `/** This file is autogenerated using the command \`yarn app-store:build --watch\`. diff --git a/packages/app-store/_components/DynamicComponent.tsx b/packages/app-store/_components/DynamicComponent.tsx index 30e00222f855a4..e13a5a4292164e 100644 --- a/packages/app-store/_components/DynamicComponent.tsx +++ b/packages/app-store/_components/DynamicComponent.tsx @@ -8,7 +8,7 @@ export function DynamicComponent { + const { slug, ...rest } = props; + return ; +}; diff --git a/packages/app-store/_utils/oauth/decodeOAuthState.ts b/packages/app-store/_utils/oauth/decodeOAuthState.ts index c300a808c66c36..2a432997ef114b 100644 --- a/packages/app-store/_utils/oauth/decodeOAuthState.ts +++ b/packages/app-store/_utils/oauth/decodeOAuthState.ts @@ -7,6 +7,9 @@ export function decodeOAuthState(req: NextApiRequest) { return undefined; } const state: IntegrationOAuthCallbackState = JSON.parse(req.query.state); + if (state.appOnboardingRedirectUrl) { + state.appOnboardingRedirectUrl = decodeURIComponent(state.appOnboardingRedirectUrl); + } return state; } diff --git a/packages/app-store/alby/components/EventTypeAppCardInterface.tsx b/packages/app-store/alby/components/EventTypeAppCardInterface.tsx index 3bc5e0e3fbb8ff..70b497522e1f3c 100644 --- a/packages/app-store/alby/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/alby/components/EventTypeAppCardInterface.tsx @@ -1,20 +1,15 @@ import { usePathname, useSearchParams } from "next/navigation"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; -import { currencyOptions } from "@calcom/app-store/alby/lib/currencyOptions"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Alert, Select, TextField } from "@calcom/ui"; -import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol"; import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps"; import type { appDataSchema } from "../zod"; -import { PaypalPaymentOptions as paymentOptions } from "../zod"; - -type Option = { value: string; label: string }; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, @@ -22,38 +17,19 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventTypeFormMetadata, }) { const searchParams = useSearchParams(); + const { t } = useLocale(); /** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/ const pathname = usePathname(); const asPath = useMemo( () => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`, [pathname, searchParams] ); - const { getAppData, setAppData } = useAppContextWithSchema(); - const price = getAppData("price"); - const currency = getAppData("currency"); - const [selectedCurrency, setSelectedCurrency] = useState( - currencyOptions.find((c) => c.value === currency) || currencyOptions[0] - ); - const paymentOption = getAppData("paymentOption"); - const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || { - label: paymentOptions[0].label, - value: paymentOptions[0].value, - }; - const seatsEnabled = !!eventType.seatsPerTimeSlot; + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); - const { t } = useLocale(); - const recurringEventDefined = eventType.recurringEvent?.count !== undefined; const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata); const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled; - // make sure a currency is selected - useEffect(() => { - if (!currency && requirePayment) { - setAppData("currency", selectedCurrency.value); - } - }, [currency, selectedCurrency, setAppData, requirePayment]); - return ( Add bitcoin lightning payments to your events} disableSwitch={shouldDisableSwitch} switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}> - <> - {recurringEventDefined ? ( - - ) : ( - requirePayment && ( - <> -
- } - addOnSuffix={selectedCurrency.unit || selectedCurrency.value} - type="number" - required - className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm" - placeholder="Price" - onChange={(e) => { - setAppData("price", Number(e.target.value)); - if (currency) { - setAppData("currency", currency); - } - }} - value={price && price > 0 ? price : undefined} - /> -
-
- - { + if (e) { + setSelectedCurrency(e); + setAppData("currency", e.value); + } + }} + /> +
+ +
+ + + defaultValue={ + paymentOptionSelectValue + ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } + : { ...paymentOptions[0], label: t(paymentOptions[0].label) } + } + options={paymentOptions.map((option) => { + return { ...option, label: t(option.label) || option.label }; + })} + onChange={(input) => { + if (input) setAppData("paymentOption", input.value); + }} + className="mb-1 h-[38px] w-full" + isDisabled={seatsEnabled} + /> +
+ {seatsEnabled && paymentOption === "HOLD" && ( + + )} + + ) + )} + + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/alby/config.json b/packages/app-store/alby/config.json index 632ef314471ca7..318d26addfec93 100644 --- a/packages/app-store/alby/config.json +++ b/packages/app-store/alby/config.json @@ -14,5 +14,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "event-type-app-card", - "dirName": "alby" + "dirName": "alby", + "isOAuth": false } diff --git a/packages/app-store/amie/config.json b/packages/app-store/amie/config.json index 74a9210dc5080e..ac87eed277ba69 100644 --- a/packages/app-store/amie/config.json +++ b/packages/app-store/amie/config.json @@ -12,5 +12,6 @@ "description": "The joyful productivity app\r\r", "__createdUsingCli": true, "dependencies": ["google-calendar"], - "dirName": "amie" + "dirName": "amie", + "isOAuth": false } diff --git a/packages/app-store/applecalendar/_metadata.ts b/packages/app-store/applecalendar/_metadata.ts index 1b468204a3b8a9..b5947fad57ec18 100644 --- a/packages/app-store/applecalendar/_metadata.ts +++ b/packages/app-store/applecalendar/_metadata.ts @@ -17,6 +17,7 @@ export const metadata = { url: "https://cal.com/", email: "help@cal.com", dirName: "applecalendar", + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index ffc97b935a9b14..cec2a72e076ac8 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -42,3 +42,16 @@ export const EventTypeAddonMap = { import("./templates/event-type-app-card/components/EventTypeAppCardInterface") ), }; +export const EventTypeSettingsMap = { + alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")), + basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")), + fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")), + ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")), + giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")), + gtm: dynamic(() => import("./gtm/components/EventTypeAppSettingsInterface")), + metapixel: dynamic(() => import("./metapixel/components/EventTypeAppSettingsInterface")), + paypal: dynamic(() => import("./paypal/components/EventTypeAppSettingsInterface")), + plausible: dynamic(() => import("./plausible/components/EventTypeAppSettingsInterface")), + qr_code: dynamic(() => import("./qr_code/components/EventTypeAppSettingsInterface")), + stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppSettingsInterface")), +}; diff --git a/packages/app-store/around/config.json b/packages/app-store/around/config.json index 0cc2358e49b963..186802b88207a2 100644 --- a/packages/app-store/around/config.json +++ b/packages/app-store/around/config.json @@ -20,5 +20,6 @@ "urlRegExp": "^http(s)?:\\/\\/(www\\.)?around.co\\/[a-zA-Z0-9]*", "organizerInputPlaceholder": "https://www.around.co/rick" } - } + }, + "isOAuth": false } diff --git a/packages/app-store/basecamp3/api/callback.ts b/packages/app-store/basecamp3/api/callback.ts index c2dbb9fad18408..fb121835f57c92 100644 --- a/packages/app-store/basecamp3/api/callback.ts +++ b/packages/app-store/basecamp3/api/callback.ts @@ -5,6 +5,7 @@ import prisma from "@calcom/prisma"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; import appConfig from "../config.json"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -86,5 +87,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); + const state = decodeOAuthState(req); + + if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { + return res.redirect(state.appOnboardingRedirectUrl); + } + res.redirect(getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug })); } diff --git a/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx b/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx index 98a5518ab8edeb..d359812bea528d 100644 --- a/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx @@ -1,76 +1,29 @@ -import { useState, useEffect } 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 { trpc } from "@calcom/trpc/react"; -import { Select } from "@calcom/ui"; +import useIsAppEnabled from "../../_utils/useIsAppEnabled"; import type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; -const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { - const { getAppData } = useAppContextWithSchema(); - const [enabled, setEnabled] = useState(getAppData("enabled")); - const [projects, setProjects] = useState(); - const [selectedProject, setSelectedProject] = useState(); - const { data } = trpc.viewer.appBasecamp3.projects.useQuery(); - const setProject = trpc.viewer.appBasecamp3.projectMutation.useMutation(); - - useEffect( - function refactorMeWithoutEffect() { - setSelectedProject({ - value: data?.projects.currentProject, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - label: data?.projects?.find((project: any) => project.id === data?.currentProject)?.name, - }); - setProjects( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data?.projects?.map((project: any) => { - return { - value: project.id, - label: project.name, - }; - }) - ); - }, - [data] - ); +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); + const { enabled, updateEnabled } = useIsAppEnabled(app); return ( { - if (!e) { - setEnabled(false); - } else { - setEnabled(true); - } + updateEnabled(e); }} switchChecked={enabled}> -
-
-
-

Link a Basecamp project to this event:

-
- { + if (project) { + setProject.mutate({ projectId: project?.value.toString() }); + setSelectedProject(project); + } + }} + value={selectedProject} + /> +
+
+ Please note that as of now you can only link one of your projects to + cal.com +
+
+ ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/basecamp3/config.json b/packages/app-store/basecamp3/config.json index a0cad118dc101a..af9d241373beae 100644 --- a/packages/app-store/basecamp3/config.json +++ b/packages/app-store/basecamp3/config.json @@ -13,5 +13,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "event-type-app-card", - "dirName": "basecamp3" + "dirName": "basecamp3", + "isOAuth": true } diff --git a/packages/app-store/cal-ai/config.json b/packages/app-store/cal-ai/config.json index 6ec6551057294c..c13c045312fa0d 100644 --- a/packages/app-store/cal-ai/config.json +++ b/packages/app-store/cal-ai/config.json @@ -14,6 +14,7 @@ "__createdUsingCli": true, "__template": "basic", "dirName": "cal-ai", + "isOAuth": false, "paid": { "priceInUsd": 8, "priceId": "price_1O1ziDH8UDiwIftkDHp3MCTP", diff --git a/packages/app-store/caldavcalendar/_metadata.ts b/packages/app-store/caldavcalendar/_metadata.ts index e342f74fb286e7..1ea69be584db58 100644 --- a/packages/app-store/caldavcalendar/_metadata.ts +++ b/packages/app-store/caldavcalendar/_metadata.ts @@ -17,6 +17,7 @@ export const metadata = { url: "https://cal.com/", email: "ali@cal.com", dirName: "caldavcalendar", + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/campfire/config.json b/packages/app-store/campfire/config.json index 7f8e6700669932..2fb381f1ff9335 100644 --- a/packages/app-store/campfire/config.json +++ b/packages/app-store/campfire/config.json @@ -19,5 +19,6 @@ "organizerInputPlaceholder": "https://party.campfire.to/your-team", "urlRegExp": "^http(s)?:\\/\\/(www\\.)?party.campfire.to\\/[a-zA-Z0-9]*" } - } + }, + "isOAuth": false } diff --git a/packages/app-store/closecom/config.json b/packages/app-store/closecom/config.json index 58a46a86e9c077..5224a8ab02ea8d 100644 --- a/packages/app-store/closecom/config.json +++ b/packages/app-store/closecom/config.json @@ -11,5 +11,6 @@ "publisher": "Cal.com, Inc.", "email": "help@cal.com", "description": "Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today.", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/cron/config.json b/packages/app-store/cron/config.json index 8e0b71952b84c7..d5c8dae3880f24 100644 --- a/packages/app-store/cron/config.json +++ b/packages/app-store/cron/config.json @@ -13,5 +13,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "link-as-an-app", - "dependencies": ["google-calendar"] + "dependencies": ["google-calendar"], + "isOAuth": false } diff --git a/packages/app-store/dailyvideo/_metadata.ts b/packages/app-store/dailyvideo/_metadata.ts index b416739784cd26..38c6b5df782376 100644 --- a/packages/app-store/dailyvideo/_metadata.ts +++ b/packages/app-store/dailyvideo/_metadata.ts @@ -26,6 +26,7 @@ export const metadata = { }, key: { apikey: process.env.DAILY_API_KEY }, dirName: "dailyvideo", + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/discord/config.json b/packages/app-store/discord/config.json index 34682ccce90d0d..1449a0169c8efb 100644 --- a/packages/app-store/discord/config.json +++ b/packages/app-store/discord/config.json @@ -21,5 +21,6 @@ "description": "Copy your server invite link and start scheduling calls in Discord! Discord is a VoIP and instant messaging social platform. Users have the ability to communicate with voice calls, video calls, text messaging, media and files in private chats or as part of communities.", "isTemplate": false, "__createdUsingCli": true, - "__template": "event-type-location-video-static" + "__template": "event-type-location-video-static", + "isOAuth": false } diff --git a/packages/app-store/eightxeight/config.json b/packages/app-store/eightxeight/config.json index a809d0ff549ed9..c0baba41f8a385 100644 --- a/packages/app-store/eightxeight/config.json +++ b/packages/app-store/eightxeight/config.json @@ -22,5 +22,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "event-type-location-video-static", - "dirName": "eightxeight" + "dirName": "eightxeight", + "isOAuth": false } diff --git a/packages/app-store/element-call/config.json b/packages/app-store/element-call/config.json index e27a228af1b174..7de9af060afec2 100644 --- a/packages/app-store/element-call/config.json +++ b/packages/app-store/element-call/config.json @@ -22,5 +22,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "event-type-location-video-static", - "dirName": "element-call" + "dirName": "element-call", + "isOAuth": false } diff --git a/packages/app-store/exchange2013calendar/_metadata.ts b/packages/app-store/exchange2013calendar/_metadata.ts index 3d7c0734af3f12..8859f4fdf2402a 100644 --- a/packages/app-store/exchange2013calendar/_metadata.ts +++ b/packages/app-store/exchange2013calendar/_metadata.ts @@ -18,6 +18,7 @@ export const metadata = { url: "https://cal.com/", email: "help@cal.com", dirName: "exchange2013calendar", + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/exchange2016calendar/_metadata.ts b/packages/app-store/exchange2016calendar/_metadata.ts index 138b5e5442dedd..a85140816114a4 100644 --- a/packages/app-store/exchange2016calendar/_metadata.ts +++ b/packages/app-store/exchange2016calendar/_metadata.ts @@ -18,6 +18,7 @@ export const metadata = { url: "https://cal.com/", email: "help@cal.com", dirName: "exchange2016calendar", + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/exchangecalendar/config.json b/packages/app-store/exchangecalendar/config.json index 113fef68a688a0..c55b9bd13aa396 100644 --- a/packages/app-store/exchangecalendar/config.json +++ b/packages/app-store/exchangecalendar/config.json @@ -12,5 +12,6 @@ "publisher": "Cal.com", "email": "help@cal.com", "description": "Fetch Microsoft Exchange calendars and availabilities using Exchange Web Services (EWS).", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/facetime/config.json b/packages/app-store/facetime/config.json index 874468fbf60139..ca7c28ad3cd97d 100644 --- a/packages/app-store/facetime/config.json +++ b/packages/app-store/facetime/config.json @@ -21,5 +21,6 @@ "urlRegExp": "^https?:\\/\\/facetime\\.apple\\.com\\/join.+$" } }, - "isTemplate": false + "isTemplate": false, + "isOAuth": false } diff --git a/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx b/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx index 09899e57f7a22d..10e0a0f8a6eff1 100644 --- a/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/fathom/components/EventTypeAppCardInterface.tsx @@ -2,13 +2,12 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; -import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { getAppData, setAppData, disabled } = useAppContextWithSchema(); - const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( @@ -20,14 +19,12 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ }} switchChecked={enabled} teamId={eventType.team?.id || undefined}> - { - setAppData("trackingId", e.target.value); - }} + getAppData={getAppData} + setAppData={setAppData} />
); diff --git a/packages/app-store/fathom/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/fathom/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 00000000000000..ea2a79002cd37d --- /dev/null +++ b/packages/app-store/fathom/components/EventTypeAppSettingsInterface.tsx @@ -0,0 +1,25 @@ +import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types"; +import { TextField } from "@calcom/ui"; + +const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({ + getAppData, + setAppData, + disabled, + slug, +}) => { + const trackingId = getAppData("trackingId"); + + return ( + { + setAppData("trackingId", e.target.value); + }} + /> + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/fathom/config.json b/packages/app-store/fathom/config.json index b491e51160f473..029bbaf649e9d4 100644 --- a/packages/app-store/fathom/config.json +++ b/packages/app-store/fathom/config.json @@ -23,5 +23,6 @@ } }, "description": "Fathom Analytics provides simple, privacy-focused website analytics. We're a GDPR-compliant, Google Analytics alternative.", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx b/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx index 09899e57f7a22d..10e0a0f8a6eff1 100644 --- a/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/ga4/components/EventTypeAppCardInterface.tsx @@ -2,13 +2,12 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; -import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { getAppData, setAppData, disabled } = useAppContextWithSchema(); - const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( @@ -20,14 +19,12 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ }} switchChecked={enabled} teamId={eventType.team?.id || undefined}> - { - setAppData("trackingId", e.target.value); - }} + getAppData={getAppData} + setAppData={setAppData} />
); diff --git a/packages/app-store/ga4/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/ga4/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 00000000000000..0ad1148d49afa3 --- /dev/null +++ b/packages/app-store/ga4/components/EventTypeAppSettingsInterface.tsx @@ -0,0 +1,23 @@ +import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types"; +import { TextField } from "@calcom/ui"; + +const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({ + getAppData, + setAppData, + disabled, +}) => { + const trackingId = getAppData("trackingId"); + + return ( + { + setAppData("trackingId", e.target.value); + }} + /> + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/ga4/config.json b/packages/app-store/ga4/config.json index b0bdb496d51063..30d6e8eb06b3eb 100644 --- a/packages/app-store/ga4/config.json +++ b/packages/app-store/ga4/config.json @@ -24,5 +24,6 @@ ] } }, - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/giphy/_metadata.ts b/packages/app-store/giphy/_metadata.ts index 050f4ab2341d33..5be513f2a7ddc7 100644 --- a/packages/app-store/giphy/_metadata.ts +++ b/packages/app-store/giphy/_metadata.ts @@ -17,6 +17,7 @@ export const metadata = { extendsFeature: "EventType", email: "help@cal.com", dirName: "giphy", + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx b/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx index b285eeedef2068..b48ce29e34fe1c 100644 --- a/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/giphy/components/EventTypeAppCardInterface.tsx @@ -1,15 +1,14 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; -import { SelectGifInput } from "@calcom/app-store/giphy/components"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { getAppData, setAppData, disabled } = useAppContextWithSchema(); - const thankYouPage = getAppData("thankYouPage"); const { enabled: showGifSelection, updateEnabled: setShowGifSelection } = useIsAppEnabled(app); const { t } = useLocale(); @@ -23,15 +22,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ }} switchChecked={showGifSelection} teamId={eventType.team?.id || undefined}> - {showGifSelection && ( - { - setAppData("thankYouPage", url); - }} - /> - )} + ); }; diff --git a/packages/app-store/giphy/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/giphy/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 00000000000000..0cb80174439eab --- /dev/null +++ b/packages/app-store/giphy/components/EventTypeAppSettingsInterface.tsx @@ -0,0 +1,23 @@ +import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types"; + +import SelectGifInput from "./SelectGifInput"; + +const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({ + getAppData, + setAppData, + disabled, +}) => { + const thankYouPage = getAppData("thankYouPage"); + + return ( + { + setAppData("thankYouPage", url); + }} + /> + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/googlecalendar/_metadata.ts b/packages/app-store/googlecalendar/_metadata.ts index dbdb83103522ba..4701bde4356681 100644 --- a/packages/app-store/googlecalendar/_metadata.ts +++ b/packages/app-store/googlecalendar/_metadata.ts @@ -18,6 +18,7 @@ export const metadata = { url: "https://cal.com/", email: "help@cal.com", dirName: "googlecalendar", + isOAuth: true, } as AppMeta; export default metadata; diff --git a/packages/app-store/googlevideo/_metadata.ts b/packages/app-store/googlevideo/_metadata.ts index 1fc1bbc03d52e7..c835c79d224c8e 100644 --- a/packages/app-store/googlevideo/_metadata.ts +++ b/packages/app-store/googlevideo/_metadata.ts @@ -27,6 +27,7 @@ export const metadata = { }, dirName: "googlevideo", dependencies: ["google-calendar"], + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx b/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx index 09899e57f7a22d..10e0a0f8a6eff1 100644 --- a/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/gtm/components/EventTypeAppCardInterface.tsx @@ -2,13 +2,12 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; -import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { getAppData, setAppData, disabled } = useAppContextWithSchema(); - const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( @@ -20,14 +19,12 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ }} switchChecked={enabled} teamId={eventType.team?.id || undefined}> - { - setAppData("trackingId", e.target.value); - }} + getAppData={getAppData} + setAppData={setAppData} /> ); diff --git a/packages/app-store/gtm/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/gtm/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 00000000000000..ace8121c1f94e9 --- /dev/null +++ b/packages/app-store/gtm/components/EventTypeAppSettingsInterface.tsx @@ -0,0 +1,24 @@ +import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types"; +import { TextField } from "@calcom/ui"; + +const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({ + getAppData, + setAppData, + disabled, +}) => { + const trackingId = getAppData("trackingId"); + + return ( + { + setAppData("trackingId", e.target.value); + }} + /> + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/gtm/config.json b/packages/app-store/gtm/config.json index b2faf7243dbad5..f535c680e86c5b 100644 --- a/packages/app-store/gtm/config.json +++ b/packages/app-store/gtm/config.json @@ -21,5 +21,6 @@ }, "isTemplate": false, "__createdUsingCli": true, - "__template": "booking-pages-tag" + "__template": "booking-pages-tag", + "isOAuth": false } diff --git a/packages/app-store/hubspot/_metadata.ts b/packages/app-store/hubspot/_metadata.ts index ea5379a2336e7d..7212d9635ddc9c 100644 --- a/packages/app-store/hubspot/_metadata.ts +++ b/packages/app-store/hubspot/_metadata.ts @@ -17,6 +17,7 @@ export const metadata = { title: "HubSpot CRM", email: "help@cal.com", dirName: "hubspot", + isOAuth: true, } as AppMeta; export default metadata; diff --git a/packages/app-store/huddle01video/_metadata.ts b/packages/app-store/huddle01video/_metadata.ts index 9a0f3d60034d30..65f90fd31a1ec3 100644 --- a/packages/app-store/huddle01video/_metadata.ts +++ b/packages/app-store/huddle01video/_metadata.ts @@ -28,6 +28,7 @@ export const metadata = { key: { apikey: randomString(12) }, dirName: "huddle01video", concurrentMeetings: true, + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/intercom/api/callback.ts b/packages/app-store/intercom/api/callback.ts index 6665aba5b6b528..64c05ceb5d0a22 100644 --- a/packages/app-store/intercom/api/callback.ts +++ b/packages/app-store/intercom/api/callback.ts @@ -8,6 +8,7 @@ import prisma from "@calcom/prisma"; import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; import getInstalledAppPath from "../../_utils/getInstalledAppPath"; import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; const log = logger.getSubLogger({ prefix: [`[[intercom/api/callback]`] }); @@ -85,6 +86,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) req ); + const state = decodeOAuthState(req); + + if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { + return res.redirect(state.appOnboardingRedirectUrl); + } + res.redirect( getSafeRedirectUrl(`${WEBAPP_URL}/apps/installed/automation?hl=intercom`) ?? getInstalledAppPath({ variant: "automation", slug: "intercom" }) diff --git a/packages/app-store/intercom/config.json b/packages/app-store/intercom/config.json index 37c68318183c7b..ef9a2955c24702 100644 --- a/packages/app-store/intercom/config.json +++ b/packages/app-store/intercom/config.json @@ -13,5 +13,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "basic", - "dirName": "intercom" + "dirName": "intercom", + "isOAuth": true } diff --git a/packages/app-store/jitsivideo/_metadata.ts b/packages/app-store/jitsivideo/_metadata.ts index 6f6b0424316423..2a5b71700e5186 100644 --- a/packages/app-store/jitsivideo/_metadata.ts +++ b/packages/app-store/jitsivideo/_metadata.ts @@ -25,6 +25,7 @@ export const metadata = { }, dirName: "jitsivideo", concurrentMeetings: true, + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/larkcalendar/_metadata.ts b/packages/app-store/larkcalendar/_metadata.ts index 050e2ed7d87909..ea00f1cde6982c 100644 --- a/packages/app-store/larkcalendar/_metadata.ts +++ b/packages/app-store/larkcalendar/_metadata.ts @@ -16,6 +16,7 @@ export const metadata = { url: "https://larksuite.com/", email: "alan@larksuite.com", dirName: "larkcalendar", + isOAuth: true, } as AppMeta; export default metadata; diff --git a/packages/app-store/make/config.json b/packages/app-store/make/config.json index bc2a1349daba05..9d51eab49c8294 100644 --- a/packages/app-store/make/config.json +++ b/packages/app-store/make/config.json @@ -14,5 +14,6 @@ "__createdUsingCli": true, "__template": "basic", "imageSrc": "icon.svg", - "dirName": "make" + "dirName": "make", + "isOAuth": false } diff --git a/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx b/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx index 38ee45f2145738..8bab69cac92ea2 100644 --- a/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/metapixel/components/EventTypeAppCardInterface.tsx @@ -2,13 +2,12 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; -import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { getAppData, setAppData, disabled } = useAppContextWithSchema(); - const trackingId = getAppData("trackingId"); const { enabled, updateEnabled } = useIsAppEnabled(app); return ( @@ -18,14 +17,12 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ switchOnClick={updateEnabled} switchChecked={enabled} teamId={eventType.team?.id || undefined}> - { - setAppData("trackingId", e.target.value); - }} + getAppData={getAppData} + setAppData={setAppData} /> ); diff --git a/packages/app-store/metapixel/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/metapixel/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 00000000000000..14b3103673489f --- /dev/null +++ b/packages/app-store/metapixel/components/EventTypeAppSettingsInterface.tsx @@ -0,0 +1,23 @@ +import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types"; +import { TextField } from "@calcom/ui"; + +const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({ + getAppData, + setAppData, + disabled, +}) => { + const trackingId = getAppData("trackingId"); + + return ( + { + setAppData("trackingId", e.target.value); + }} + /> + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/metapixel/config.json b/packages/app-store/metapixel/config.json index 7d055c3b496879..a56804c0e8ba06 100644 --- a/packages/app-store/metapixel/config.json +++ b/packages/app-store/metapixel/config.json @@ -21,5 +21,6 @@ } ] } - } + }, + "isOAuth": false } diff --git a/packages/app-store/mirotalk/config.json b/packages/app-store/mirotalk/config.json index c0cea23d8e1c46..d1dbcf09fa92c4 100644 --- a/packages/app-store/mirotalk/config.json +++ b/packages/app-store/mirotalk/config.json @@ -22,5 +22,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "event-type-location-video-static", - "dirName": "mirotalk" + "dirName": "mirotalk", + "isOAuth": false } diff --git a/packages/app-store/n8n/config.json b/packages/app-store/n8n/config.json index 1b6cec2091a0da..2770acacc55e01 100644 --- a/packages/app-store/n8n/config.json +++ b/packages/app-store/n8n/config.json @@ -10,5 +10,6 @@ "publisher": "Cal.com, Inc.", "email": "help@cal.com", "description": "Automate without limits. The workflow automation platform that doesn't box you in, that you never outgrow", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/office365calendar/_metadata.ts b/packages/app-store/office365calendar/_metadata.ts index 6389322c547c7b..98ded5de7343a4 100644 --- a/packages/app-store/office365calendar/_metadata.ts +++ b/packages/app-store/office365calendar/_metadata.ts @@ -16,6 +16,7 @@ export const metadata = { dirName: "office365calendar", url: "https://cal.com/", email: "help@cal.com", + isOAuth: true, } as AppMeta; export default metadata; diff --git a/packages/app-store/office365video/api/callback.ts b/packages/app-store/office365video/api/callback.ts index 8d13922560799d..cc1da5656d4177 100644 --- a/packages/app-store/office365video/api/callback.ts +++ b/packages/app-store/office365video/api/callback.ts @@ -104,6 +104,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: "msteams", type: "office365_video" }, responseBody, req); + if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { + return res.redirect(state.appOnboardingRedirectUrl); + } + return res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "msteams" }) ); diff --git a/packages/app-store/office365video/config.json b/packages/app-store/office365video/config.json index 4880ce02e64643..9b663ad63d60ad 100644 --- a/packages/app-store/office365video/config.json +++ b/packages/app-store/office365video/config.json @@ -22,5 +22,6 @@ } }, "dirName": "office365video", - "concurrentMeetings": true + "concurrentMeetings": true, + "isOAuth": true } diff --git a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx index 02fcb22c5bf0a9..3dc6efbfe6dd41 100644 --- a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx @@ -1,23 +1,15 @@ import { usePathname, useSearchParams } from "next/navigation"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; -import { - currencyOptions, - currencySymbols, - isAcceptedCurrencyCode, -} from "@calcom/app-store/paypal/lib/currencyOptions"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Alert, Select, TextField } from "@calcom/ui"; import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps"; import type { appDataSchema } from "../zod"; -import { PaypalPaymentOptions as paymentOptions } from "../zod"; - -type Option = { value: string; label: string }; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, @@ -31,40 +23,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ () => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`, [pathname, searchParams] ); - const { getAppData, setAppData } = useAppContextWithSchema(); - const price = getAppData("price"); - - const currency = getAppData("currency"); - const [selectedCurrency, setSelectedCurrency] = useState(currencyOptions.find((c) => c.value === currency)); - const [currencySymbol, setCurrencySymbol] = useState( - isAcceptedCurrencyCode(currency) ? currencySymbols[currency] : "" - ); - - const paymentOption = getAppData("paymentOption"); - const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || { - label: paymentOptions[0].label, - value: paymentOptions[0].value, - }; - const seatsEnabled = !!eventType.seatsPerTimeSlot; + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); - const { t } = useLocale(); - const recurringEventDefined = eventType.recurringEvent?.count !== undefined; const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata); + const { t } = useLocale(); const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled; - useEffect(() => { - if (requirePayment) { - if (!getAppData("currency")) { - setAppData("currency", currencyOptions[0].value); - } - if (!getAppData("paymentOption")) { - setAppData("paymentOption", paymentOptions[0].value); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ( <> - {recurringEventDefined ? ( - - ) : ( - requirePayment && ( - <> -
- { - setAppData("price", Number(e.target.value) * 100); - if (selectedCurrency) { - setAppData("currency", selectedCurrency.value); - } - }} - value={price > 0 ? price / 100 : undefined} - /> -
-
- - { + if (e) { + setSelectedCurrency(e); + setCurrencySymbol(currencySymbols[e.value]); + setAppData("currency", e.value); + } + }} + /> +
+ +
+ + + data-testid="paypal-payment-option-select" + defaultValue={ + paymentOptionSelectValue + ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } + : { ...paymentOptions[0], label: t(paymentOptions[0].label) } + } + options={paymentOptions.map((option) => { + return { ...option, label: t(option.label) || option.label }; + })} + onChange={(input) => { + if (input) setAppData("paymentOption", input.value); + }} + className="mb-1 h-[38px] w-full" + isDisabled={seatsEnabled} + /> +
+ {seatsEnabled && paymentOption === "HOLD" && ( + + )} + + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/paypal/config.json b/packages/app-store/paypal/config.json index 5624fd3f39ebe6..86558342a0c809 100644 --- a/packages/app-store/paypal/config.json +++ b/packages/app-store/paypal/config.json @@ -14,5 +14,6 @@ "__createdUsingCli": true, "imageSrc": "icon.svg", "__template": "event-type-app-card", - "dirName": "paypal" + "dirName": "paypal", + "isOAuth": true } diff --git a/packages/app-store/ping/config.json b/packages/app-store/ping/config.json index 2559c47fb1f69c..dadb6e2ba298a1 100644 --- a/packages/app-store/ping/config.json +++ b/packages/app-store/ping/config.json @@ -20,5 +20,6 @@ "organizerInputPlaceholder": "https://www.ping.gg/call/theo", "urlRegExp": "^http(s)?:\\/\\/(www\\.)?ping.gg\\/call\\/[a-zA-Z0-9]*" } - } + }, + "isOAuth": false } diff --git a/packages/app-store/pipedream/config.json b/packages/app-store/pipedream/config.json index 37fa14a10361f7..7812bad8df0ea9 100644 --- a/packages/app-store/pipedream/config.json +++ b/packages/app-store/pipedream/config.json @@ -10,5 +10,6 @@ "publisher": "Pipedream, Inc.", "email": "support@pipedream.com", "description": "Connect APIs, remarkably fast. Stop writing boilerplate code, struggling with authentication and managing infrastructure. Start connecting APIs with code-level control when you need it — and no code when you don't", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx b/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx index 306d0be0584292..440f307ff8b63b 100644 --- a/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/plausible/components/EventTypeAppCardInterface.tsx @@ -2,14 +2,13 @@ import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; -import { TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) { const { getAppData, setAppData, disabled } = useAppContextWithSchema(); - const plausibleUrl = getAppData("PLAUSIBLE_URL"); - const trackingId = getAppData("trackingId"); + const { enabled, updateEnabled } = useIsAppEnabled(app); return ( @@ -21,29 +20,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ }} switchChecked={enabled} teamId={eventType.team?.id || undefined}> -
- { - setAppData("PLAUSIBLE_URL", e.target.value); - }} - /> - { - setAppData("trackingId", e.target.value); - }} - /> -
+
); }; diff --git a/packages/app-store/plausible/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/plausible/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 00000000000000..bafa35679ffed9 --- /dev/null +++ b/packages/app-store/plausible/components/EventTypeAppSettingsInterface.tsx @@ -0,0 +1,40 @@ +import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types"; +import { TextField } from "@calcom/ui"; + +const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({ + getAppData, + setAppData, + disabled, + slug, +}) => { + const plausibleUrl = getAppData("PLAUSIBLE_URL"); + const trackingId = getAppData("trackingId"); + + return ( +
+ { + setAppData("PLAUSIBLE_URL", e.target.value); + }} + /> + { + setAppData("trackingId", e.target.value); + }} + /> +
+ ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/plausible/config.json b/packages/app-store/plausible/config.json index 2fa2baee895c8a..8d157e8814ce3f 100644 --- a/packages/app-store/plausible/config.json +++ b/packages/app-store/plausible/config.json @@ -23,5 +23,6 @@ } }, "description": "Simple, privacy-friendly Google Analytics alternative.", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx b/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx index acab0974a26a1e..53df00b922def2 100644 --- a/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/qr_code/components/EventTypeAppCardInterface.tsx @@ -1,39 +1,14 @@ -import { useState } from "react"; - import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; import AppCard from "@calcom/app-store/_components/AppCard"; import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled"; import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Tooltip, TextField } from "@calcom/ui"; import type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) { - const { t } = useLocale(); - const { disabled } = useAppContextWithSchema(); - const [additionalParameters, setAdditionalParameters] = useState(""); const { enabled, updateEnabled } = useIsAppEnabled(app); - - const query = additionalParameters !== "" ? `?${additionalParameters}` : ""; - const eventTypeURL = eventType.URL + query; - - function QRCode({ size, data }: { size: number; data: string }) { - const QR_URL = `https://api.qrserver.com/v1/create-qr-code/?size=${size}&data=${data}`; - return ( - - - {eventTypeURL} - - - ); - } + const { disabled, getAppData, setAppData } = useAppContextWithSchema(); return ( -
-
- setAdditionalParameters(e.target.value)} - label={t("additional_url_parameters")} - containerClassName="w-full" - /> -
- -
- - - -
-
+
); }; diff --git a/packages/app-store/qr_code/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/qr_code/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 00000000000000..cc069d32ebdf81 --- /dev/null +++ b/packages/app-store/qr_code/components/EventTypeAppSettingsInterface.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; + +import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types"; +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { TextField, Tooltip } from "@calcom/ui"; + +const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({ eventType, disabled }) => { + const { t } = useLocale(); + const [additionalParameters, setAdditionalParameters] = useState(""); + const query = additionalParameters !== "" ? `?${additionalParameters}` : ""; + const eventTypeURL = eventType.URL + query; + + function QRCode({ size, data }: { size: number; data: string }) { + const QR_URL = `https://api.qrserver.com/v1/create-qr-code/?size=${size}&data=${data}`; + return ( + + + = 256 && "min-h-32" + )} + style={{ padding: size / 16, borderRadius: size / 20 }} + width={size} + src={QR_URL} + alt={eventTypeURL} + /> + + + ); + } + return ( +
+
+ setAdditionalParameters(e.target.value)} + label={t("additional_url_parameters")} + containerClassName="w-full" + /> +
+ +
+ + + +
+
+ ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/qr_code/config.json b/packages/app-store/qr_code/config.json index 3597091bbdafaa..25763202ba65b3 100644 --- a/packages/app-store/qr_code/config.json +++ b/packages/app-store/qr_code/config.json @@ -11,5 +11,6 @@ "publisher": "Cal.com, Inc.", "email": "support@cal.com", "description": "Easily generate a QR code for your links to print, share, or embed.", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/raycast/config.json b/packages/app-store/raycast/config.json index 12680a74c3d729..aac51930e61c8c 100644 --- a/packages/app-store/raycast/config.json +++ b/packages/app-store/raycast/config.json @@ -10,5 +10,6 @@ "publisher": "Eric Luce", "email": "info@restlessmindstech.com", "description": "Quickly share your Cal.com meeting links with Raycast", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/riverside/config.json b/packages/app-store/riverside/config.json index 45788adfe746cd..947d2c3ecccec2 100644 --- a/packages/app-store/riverside/config.json +++ b/packages/app-store/riverside/config.json @@ -18,5 +18,6 @@ "type": "integrations:riverside_video", "linkType": "static" } - } + }, + "isOAuth": false } diff --git a/packages/app-store/routing-forms/config.json b/packages/app-store/routing-forms/config.json index 55ad69052709a4..22eacf80976013 100644 --- a/packages/app-store/routing-forms/config.json +++ b/packages/app-store/routing-forms/config.json @@ -17,5 +17,6 @@ "upgradeUrl": "/routing-forms/forms" }, "description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/salesforce/api/callback.ts b/packages/app-store/salesforce/api/callback.ts index aad019a5608132..a76438940427a5 100644 --- a/packages/app-store/salesforce/api/callback.ts +++ b/packages/app-store/salesforce/api/callback.ts @@ -42,6 +42,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, salesforceTokenInfo, req); const state = decodeOAuthState(req); + + if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { + return res.redirect(state.appOnboardingRedirectUrl); + } + res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "salesforce" }) ); diff --git a/packages/app-store/salesforce/config.json b/packages/app-store/salesforce/config.json index 3f95a3c9b05aaf..d159830f944704 100644 --- a/packages/app-store/salesforce/config.json +++ b/packages/app-store/salesforce/config.json @@ -10,5 +10,6 @@ "publisher": "Cal.com, Inc.", "email": "help@cal.com", "description": "Salesforce (Sales Cloud) is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day.", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": true } diff --git a/packages/app-store/sendgrid/config.json b/packages/app-store/sendgrid/config.json index 3f15a92402a3b8..d98cf3af04fb7d 100644 --- a/packages/app-store/sendgrid/config.json +++ b/packages/app-store/sendgrid/config.json @@ -10,5 +10,6 @@ "publisher": "Cal.com, Inc.", "email": "help@cal.com", "description": "SendGrid delivers your transactional and marketing emails through the world's largest cloud-based email delivery platform.", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/signal/config.json b/packages/app-store/signal/config.json index bb0e8f5a938dd1..fe3bf5bd2f1295 100644 --- a/packages/app-store/signal/config.json +++ b/packages/app-store/signal/config.json @@ -19,5 +19,6 @@ "organizerInputPlaceholder": "https://signal.me/#p/+11234567890", "urlRegExp": "^http(s)?:\\/\\/(www\\.)?signal.me\\/[a-zA-Z0-9]*" } - } + }, + "isOAuth": false } diff --git a/packages/app-store/sirius_video/config.json b/packages/app-store/sirius_video/config.json index b44436beba01ca..d8c02a2f65bd4e 100644 --- a/packages/app-store/sirius_video/config.json +++ b/packages/app-store/sirius_video/config.json @@ -19,5 +19,6 @@ "organizerInputPlaceholder": "https://sirius.video/sebastian", "urlRegExp": "^http(s)?:\\/\\/(www\\.)?sirius.video\\/[a-zA-Z0-9]*" } - } + }, + "isOAuth": false } diff --git a/packages/app-store/stripepayment/_metadata.ts b/packages/app-store/stripepayment/_metadata.ts index 26bd34023d2f16..54f87d72108a4c 100644 --- a/packages/app-store/stripepayment/_metadata.ts +++ b/packages/app-store/stripepayment/_metadata.ts @@ -23,6 +23,7 @@ export const metadata = { extendsFeature: "EventType", email: "help@cal.com", dirName: "stripepayment", + isOAuth: true, } as AppMeta; export default metadata; diff --git a/packages/app-store/stripepayment/api/callback.ts b/packages/app-store/stripepayment/api/callback.ts index 98e3b4ca875114..61d16f7506af80 100644 --- a/packages/app-store/stripepayment/api/callback.ts +++ b/packages/app-store/stripepayment/api/callback.ts @@ -53,6 +53,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) req ); + if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { + return res.redirect(state.appOnboardingRedirectUrl); + } + const returnTo = getReturnToValueFromQueryState(req); res.redirect(returnTo || getInstalledAppPath({ variant: "payment", slug: "stripe" })); } diff --git a/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx b/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx index 886f626913e487..24f406e14c4608 100644 --- a/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/stripepayment/components/EventTypeAppCardInterface.tsx @@ -1,157 +1,48 @@ import { usePathname } from "next/navigation"; -import { useState, useEffect } from "react"; +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 { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { Alert, Select, TextField } from "@calcom/ui"; import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps"; -import { paymentOptions } from "../lib/constants"; -import { - convertToSmallestCurrencyUnit, - convertFromSmallestToPresentableCurrencyUnit, -} from "../lib/currencyConversions"; -import { currencyOptions } from "../lib/currencyOptions"; +import useIsAppEnabled from "../../_utils/useIsAppEnabled"; import type { appDataSchema } from "../zod"; - -type Option = { value: string; label: string }; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType, eventTypeFormMetadata, }) { + const { t } = useLocale(); const pathname = usePathname(); const { getAppData, setAppData, disabled } = useAppContextWithSchema(); - const price = getAppData("price"); - const currency = getAppData("currency") || currencyOptions[0].value; - const [selectedCurrency, setSelectedCurrency] = useState( - currencyOptions.find((c) => c.value === currency) || { - label: currencyOptions[0].label, - value: currencyOptions[0].value, - } - ); - const paymentOption = getAppData("paymentOption"); - const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value); - const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); + const { enabled, updateEnabled } = useIsAppEnabled(app); const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata); - + const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled; - const { t } = useLocale(); - const recurringEventDefined = eventType.recurringEvent?.count !== undefined; - const seatsEnabled = !!eventType.seatsPerTimeSlot; - const getCurrencySymbol = (locale: string, currency: string) => - (0) - .toLocaleString(locale, { - style: "currency", - currency, - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }) - .replace(/\d/g, "") - .trim(); - - useEffect(() => { - if (requirePayment) { - if (!getAppData("currency")) { - setAppData("currency", currencyOptions[0].value); - } - if (!getAppData("paymentOption")) { - setAppData("paymentOption", paymentOptions[0].value); - } - } - }, [requirePayment, getAppData, setAppData]); - return ( { - setRequirePayment(enabled); + switchChecked={enabled} + switchOnClick={(e) => { + updateEnabled(e); }} teamId={eventType.team?.id || undefined} disableSwitch={shouldDisableSwitch} switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}> - <> - {recurringEventDefined && ( - - )} - {!recurringEventDefined && requirePayment && ( - <> -
- {selectedCurrency.value ? getCurrencySymbol("en", selectedCurrency.value) : ""} - } - addOnSuffix={currency.toUpperCase()} - addOnClassname="h-[38px]" - step="0.01" - min="0.5" - type="number" - required - placeholder="Price" - disabled={disabled} - onChange={(e) => { - setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency)); - }} - value={price > 0 ? convertFromSmallestToPresentableCurrencyUnit(price, currency) : undefined} - /> -
-
- - { + if (e) { + setSelectedCurrency(e); + setAppData("currency", e.value); + } + }} + /> +
+
+ + + data-testid="stripe-payment-option-select" + defaultValue={ + paymentOptionSelectValue + ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } + : { ...paymentOptions[0], label: t(paymentOptions[0].label) } + } + options={paymentOptions.map((option) => { + return { ...option, label: t(option.label) || option.label }; + })} + onChange={(input) => { + if (input) setAppData("paymentOption", input.value); + }} + className="mb-1 h-[38px] w-full" + isDisabled={seatsEnabled || disabled} + /> +
+ + {seatsEnabled && paymentOption === "HOLD" && ( + + )} + + )} + + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/sylapsvideo/config.json b/packages/app-store/sylapsvideo/config.json index 424ba6ce6cec8c..6ab5fe68ffac6c 100644 --- a/packages/app-store/sylapsvideo/config.json +++ b/packages/app-store/sylapsvideo/config.json @@ -18,5 +18,6 @@ "type": "integrations:sylaps_video", "label": "Sylaps" } - } + }, + "isOAuth": false } diff --git a/packages/app-store/tandemvideo/_metadata.ts b/packages/app-store/tandemvideo/_metadata.ts index 28f9477129b782..6deef75e2d86f3 100644 --- a/packages/app-store/tandemvideo/_metadata.ts +++ b/packages/app-store/tandemvideo/_metadata.ts @@ -24,6 +24,7 @@ export const metadata = { }, }, dirName: "tandemvideo", + isOAuth: true, } as AppMeta; export default metadata; diff --git a/packages/app-store/typeform/config.json b/packages/app-store/typeform/config.json index daf11d5a7d8f59..ff0a14c37674c7 100644 --- a/packages/app-store/typeform/config.json +++ b/packages/app-store/typeform/config.json @@ -10,5 +10,6 @@ "publisher": "Cal.com, Inc.", "email": "help@cal.com", "description": "Adds a link to copy Typeform Redirect URL", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/types.d.ts b/packages/app-store/types.d.ts index dfb22ab85ba5ac..da1fb01df9addb 100644 --- a/packages/app-store/types.d.ts +++ b/packages/app-store/types.d.ts @@ -10,6 +10,7 @@ export type IntegrationOAuthCallbackState = { onErrorReturnTo: string; fromApp: boolean; installGoogleVideo?: boolean; + appOnboardingRedirectUrl?: string; teamId?: number; }; @@ -56,4 +57,23 @@ export type EventTypeAppCardComponentProps = { LockedIcon?: JSX.Element | false; eventTypeFormMetadata?: z.infer; }; + +export type EventTypeAppSettingsComponentProps = { + // Limit what data should be accessible to apps\ + eventType: Pick< + z.infer, + "id" | "title" | "description" | "teamId" | "length" | "recurringEvent" | "seatsPerTimeSlot" | "team" + > & { + URL: string; + }; + getAppData: GetAppData; + setAppData: SetAppData; + disabled?: boolean; + slug: string; +}; + export type EventTypeAppCardComponent = React.FC; + +export type EventTypeAppSettingsComponent = React.FC; + +export type EventTypeModel = z.infer; diff --git a/packages/app-store/vimcal/config.json b/packages/app-store/vimcal/config.json index f38f25ad8fc745..4baeb62de9e9c9 100644 --- a/packages/app-store/vimcal/config.json +++ b/packages/app-store/vimcal/config.json @@ -11,5 +11,6 @@ "email": "support@cal.com", "description": "The world's fastest calendar, beautifully designed for a remote world\r", "__createdUsingCli": true, - "dependencies": ["google-calendar"] + "dependencies": ["google-calendar"], + "isOAuth": false } diff --git a/packages/app-store/vital/_metadata.ts b/packages/app-store/vital/_metadata.ts index f0359e0b04f51b..19d47d1527eb53 100644 --- a/packages/app-store/vital/_metadata.ts +++ b/packages/app-store/vital/_metadata.ts @@ -18,6 +18,7 @@ export const metadata = { variant: "other", email: "support@tryvital.io", dirName: "vital", + isOAuth: true, } as AppMeta; export default metadata; diff --git a/packages/app-store/weather_in_your_calendar/config.json b/packages/app-store/weather_in_your_calendar/config.json index e7769ebd57028c..6193d26c1cb96d 100644 --- a/packages/app-store/weather_in_your_calendar/config.json +++ b/packages/app-store/weather_in_your_calendar/config.json @@ -10,5 +10,6 @@ "publisher": "Andreas Vejnø Andersen", "email": "info@vejnoe.dk", "description": "Get the local weather forecast with icons in your calendar\r\r", - "__createdUsingCli": true + "__createdUsingCli": true, + "isOAuth": false } diff --git a/packages/app-store/webex/config.json b/packages/app-store/webex/config.json index 587fc561f5fda7..f37f9b5ac224aa 100644 --- a/packages/app-store/webex/config.json +++ b/packages/app-store/webex/config.json @@ -22,5 +22,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "basic", - "concurrentMeetings": true + "concurrentMeetings": true, + "isAuth": true } diff --git a/packages/app-store/whatsapp/config.json b/packages/app-store/whatsapp/config.json index dde161c6ae0e8e..af42fc8ce5b573 100644 --- a/packages/app-store/whatsapp/config.json +++ b/packages/app-store/whatsapp/config.json @@ -19,5 +19,6 @@ "organizerInputPlaceholder": "https://wa.me/send?phone=1234567890", "urlRegExp": "^http(s)?:\\/\\/(www\\.)?wa.me\\/[a-zA-Z0-9]*" } - } + }, + "isAuth": false } diff --git a/packages/app-store/whereby/config.json b/packages/app-store/whereby/config.json index 3844faa3f272a6..5bf0dcd9d628f0 100644 --- a/packages/app-store/whereby/config.json +++ b/packages/app-store/whereby/config.json @@ -20,5 +20,6 @@ "organizerInputPlaceholder": "https://www.whereby.com/cal", "urlRegExp": "^(?:https?://)?(?:(?!.*-\\.)(?:\\w+(-\\w+)*\\.))*whereby\\.com(/[\\w\\-._~:?#\\[\\]@!$&'()*+,;%=]+)*$" } - } + }, + "isAuth": false } diff --git a/packages/app-store/wipemycalother/_metadata.ts b/packages/app-store/wipemycalother/_metadata.ts index 7b1c1d9c4ed352..d7819dea083c6d 100644 --- a/packages/app-store/wipemycalother/_metadata.ts +++ b/packages/app-store/wipemycalother/_metadata.ts @@ -18,6 +18,7 @@ export const metadata = { variant: "other", email: "help@cal.com", dirName: "wipemycalother", + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/wordpress/config.json b/packages/app-store/wordpress/config.json index 260e7411a00696..c29179fedf9ced 100644 --- a/packages/app-store/wordpress/config.json +++ b/packages/app-store/wordpress/config.json @@ -10,5 +10,6 @@ "publisher": "Cal.com, Inc.", "email": "support@cal.com", "description": "Embedded booking pages right into your wordpress page", - "__createdUsingCli": true + "__createdUsingCli": true, + "isAuth": false } diff --git a/packages/app-store/zapier/_metadata.ts b/packages/app-store/zapier/_metadata.ts index 26b50fb9d2ad35..b8be473f70393d 100644 --- a/packages/app-store/zapier/_metadata.ts +++ b/packages/app-store/zapier/_metadata.ts @@ -17,6 +17,7 @@ export const metadata = { variant: "automation", email: "help@cal.com", dirName: "zapier", + isOAuth: false, } as AppMeta; export default metadata; diff --git a/packages/app-store/zoho-bigin/api/callback.ts b/packages/app-store/zoho-bigin/api/callback.ts index f1ab94a2a8e231..96a3ad3a05f6f7 100644 --- a/packages/app-store/zoho-bigin/api/callback.ts +++ b/packages/app-store/zoho-bigin/api/callback.ts @@ -55,6 +55,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, tokenInfo.data, req); const state = decodeOAuthState(req); + + if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { + return res.redirect(state.appOnboardingRedirectUrl); + } + res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }) diff --git a/packages/app-store/zoho-bigin/config.json b/packages/app-store/zoho-bigin/config.json index aea58ef2c742c1..d4a2851271d0b8 100644 --- a/packages/app-store/zoho-bigin/config.json +++ b/packages/app-store/zoho-bigin/config.json @@ -13,5 +13,6 @@ "isTemplate": false, "__createdUsingCli": true, "__template": "basic", - "scope": "ZohoBigin.modules.events.ALL,ZohoBigin.modules.contacts.ALL" + "scope": "ZohoBigin.modules.events.ALL,ZohoBigin.modules.contacts.ALL", + "isOAuth": true } diff --git a/packages/app-store/zohocalendar/config.json b/packages/app-store/zohocalendar/config.json index 6d43249f52812d..5bdbb6082971e2 100644 --- a/packages/app-store/zohocalendar/config.json +++ b/packages/app-store/zohocalendar/config.json @@ -10,5 +10,6 @@ "logo": "icon.svg", "publisher": "Cal.com", "url": "https://cal.com/", - "email": "help@cal.com" + "email": "help@cal.com", + "isAuth": true } diff --git a/packages/app-store/zohocrm/api/callback.ts b/packages/app-store/zohocrm/api/callback.ts index 9f02f282010e80..bcbed269fa4621 100644 --- a/packages/app-store/zohocrm/api/callback.ts +++ b/packages/app-store/zohocrm/api/callback.ts @@ -55,6 +55,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, zohoCrmTokenInfo.data, req); const state = decodeOAuthState(req); + + if (state?.appOnboardingRedirectUrl && state.appOnboardingRedirectUrl !== "") { + return res.redirect(state.appOnboardingRedirectUrl); + } + res.redirect( getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "zohocrm" }) ); diff --git a/packages/app-store/zohocrm/config.json b/packages/app-store/zohocrm/config.json index daea3c116211a2..2a0cf837ca197b 100644 --- a/packages/app-store/zohocrm/config.json +++ b/packages/app-store/zohocrm/config.json @@ -12,5 +12,6 @@ "description": "Zoho CRM is a cloud-based application designed to help your salespeople sell smarter and faster by centralizing customer information, logging their interactions with your company, and automating many of the tasks salespeople do every day", "isTemplate": false, "__createdUsingCli": true, - "__template": "basic" + "__template": "basic", + "isOAuth": true } diff --git a/packages/app-store/zoomvideo/_metadata.ts b/packages/app-store/zoomvideo/_metadata.ts index 665efb8446e73a..4a65fe8480d9e8 100644 --- a/packages/app-store/zoomvideo/_metadata.ts +++ b/packages/app-store/zoomvideo/_metadata.ts @@ -25,6 +25,7 @@ export const metadata = { }, }, dirName: "zoomvideo", + isOAuth: true, } as AppMeta; export default metadata; diff --git a/packages/lib/apps/appOnboardingSteps.ts b/packages/lib/apps/appOnboardingSteps.ts new file mode 100644 index 00000000000000..e6117b9296d721 --- /dev/null +++ b/packages/lib/apps/appOnboardingSteps.ts @@ -0,0 +1,5 @@ +export enum AppOnboardingSteps { + ACCOUNTS_STEP = "accounts", + EVENT_TYPES_STEP = "event-types", + CONFIGURE_STEP = "configure", +} diff --git a/packages/lib/apps/getAppOnboardingRedirectUrl.ts b/packages/lib/apps/getAppOnboardingRedirectUrl.ts new file mode 100644 index 00000000000000..8a7bb37630a6dd --- /dev/null +++ b/packages/lib/apps/getAppOnboardingRedirectUrl.ts @@ -0,0 +1,8 @@ +import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; + +import { getAppOnboardingUrl } from "./getAppOnboardingUrl"; + +export const getAppOnboardingRedirectUrl = (slug: string, teamId?: number) => { + const url = getAppOnboardingUrl({ slug, teamId, step: AppOnboardingSteps.EVENT_TYPES_STEP }); + return encodeURIComponent(url); +}; diff --git a/packages/lib/apps/getAppOnboardingUrl.ts b/packages/lib/apps/getAppOnboardingUrl.ts new file mode 100644 index 00000000000000..fda259dfe7678d --- /dev/null +++ b/packages/lib/apps/getAppOnboardingUrl.ts @@ -0,0 +1,27 @@ +import { stringify } from "querystring"; + +import type { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; + +export const getAppOnboardingUrl = ({ + slug, + step, + teamId, + eventTypeIds, +}: { + slug: string; + step: AppOnboardingSteps; + teamId?: number; + eventTypeIds?: number[]; +}) => { + const params: { [key: string]: string | number | number[] } = { slug }; + if (!!eventTypeIds && eventTypeIds.length > 0) { + params.eventTypeIds = eventTypeIds.join(","); + } + + if (!!teamId) { + params.teamId = teamId; + } + const query = stringify(params); + + return `/apps/installation/${step}?${query}`; +}; diff --git a/packages/lib/apps/shouldRedirectToAppOnboarding.ts b/packages/lib/apps/shouldRedirectToAppOnboarding.ts new file mode 100644 index 00000000000000..261d5e30ac36b1 --- /dev/null +++ b/packages/lib/apps/shouldRedirectToAppOnboarding.ts @@ -0,0 +1,6 @@ +import type { AppMeta } from "@calcom/types/App"; + +export const shouldRedirectToAppOnboarding = (appMetadata: AppMeta) => { + const hasEventTypes = appMetadata?.extendsFeature == "EventType"; + return hasEventTypes; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index e4ecb40e9b5133..94fa03001b2b06 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -68,6 +68,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const eventType = await ctx.prisma.eventType.findUniqueOrThrow({ where: { id }, select: { + title: true, aiPhoneCallConfig: { select: { generalPrompt: true, diff --git a/packages/types/App.d.ts b/packages/types/App.d.ts index 4c81987fc684a4..62b14dcc8904e8 100644 --- a/packages/types/App.d.ts +++ b/packages/types/App.d.ts @@ -165,6 +165,8 @@ export interface App { concurrentMeetings?: boolean; createdAt?: string; + /** Specifies if the App uses an OAuth flow */ + isOAuth?: boolean; } export type AppFrontendPayload = Omit & { diff --git a/packages/ui/components/apps/AppCard.tsx b/packages/ui/components/apps/AppCard.tsx index 7ceefeb10f08d7..140108445e915d 100644 --- a/packages/ui/components/apps/AppCard.tsx +++ b/packages/ui/components/apps/AppCard.tsx @@ -1,11 +1,16 @@ import { usePathname, useRouter } from "next/navigation"; +import { useMemo } from "react"; import { useEffect, useState } from "react"; import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { InstallAppButton } from "@calcom/app-store/components"; import { doesAppSupportTeamInstall } from "@calcom/app-store/utils"; import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner"; import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams"; +import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps"; +import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl"; +import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding"; import classNames from "@calcom/lib/classNames"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -122,6 +127,7 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar appCategories={app.categories} concurrentMeetings={app.concurrentMeetings} paid={app.paid} + dirName={app.dirName} /> ); }} @@ -149,6 +155,7 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar credentials={credentials} concurrentMeetings={app.concurrentMeetings} paid={app.paid} + dirName={app.dirName} {...props} /> ); @@ -178,6 +185,8 @@ const InstallAppButtonChild = ({ credentials, concurrentMeetings, paid, + dirName, + onClick, ...props }: { userAdminTeams?: UserAdminTeams; @@ -185,6 +194,7 @@ const InstallAppButtonChild = ({ appCategories: string[]; credentials?: Credential[]; concurrentMeetings?: boolean; + dirName: string | undefined; paid: App["paid"]; } & ButtonProps) => { const { t } = useLocale(); @@ -205,6 +215,18 @@ const InstallAppButtonChild = ({ }, }); + const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata]; + const redirectToAppOnboarding = useMemo(() => shouldRedirectToAppOnboarding(appMetadata), [appMetadata]); + + const _onClick = (e: React.MouseEvent) => { + if (redirectToAppOnboarding) { + router.push( + getAppOnboardingUrl({ slug: addAppMutationInput.slug, step: AppOnboardingSteps.ACCOUNTS_STEP }) + ); + } else if (onClick) { + onClick(e); + } + }; // Paid apps don't support team installs at the moment // Also, cal.ai(the only paid app at the moment) doesn't support team install either if (paid) { @@ -214,6 +236,7 @@ const InstallAppButtonChild = ({ className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center" StartIcon="plus" data-testid="install-app-button" + onClick={_onClick} {...props}> {paid.trial ? t("start_paid_trial") : t("subscribe")} @@ -230,12 +253,27 @@ const InstallAppButtonChild = ({ className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center" StartIcon="plus" data-testid="install-app-button" + onClick={_onClick} {...props}> {t("install")} ); } + if (redirectToAppOnboarding) { + return ( + + ); + } return ( diff --git a/packages/ui/components/form/step/Steps.tsx b/packages/ui/components/form/step/Steps.tsx index 3c5294662820f2..fb042d69a1d084 100644 --- a/packages/ui/components/form/step/Steps.tsx +++ b/packages/ui/components/form/step/Steps.tsx @@ -1,17 +1,30 @@ import classNames from "@calcom/lib/classNames"; -interface ISteps { +type StepWithNav = { maxSteps: number; currentStep: number; navigateToStep: (step: number) => void; + disableNavigation?: false; stepLabel?: (currentStep: number, maxSteps: number) => string; -} +}; + +type StepWithoutNav = { + maxSteps: number; + currentStep: number; + navigateToStep?: undefined; + disableNavigation: true; + stepLabel?: (currentStep: number, maxSteps: number) => string; +}; + +// Discriminative union on disableNavigation prop +type StepsProps = StepWithNav | StepWithoutNav; -const Steps = (props: ISteps) => { +const Steps = (props: StepsProps) => { const { maxSteps, currentStep, navigateToStep, + disableNavigation = false, stepLabel = (currentStep, totalSteps) => `Step ${currentStep} of ${totalSteps}`, } = props; return ( @@ -22,10 +35,10 @@ const Steps = (props: ISteps) => { return index <= currentStep - 1 ? (
navigateToStep(index)} + onClick={() => navigateToStep?.(index)} className={classNames( "bg-inverted h-1 w-full rounded-[1px]", - index < currentStep - 1 ? "cursor-pointer" : "" + index < currentStep - 1 && !disableNavigation ? "cursor-pointer" : "" )} data-testid={`step-indicator-${index}`} /> diff --git a/packages/ui/components/scrollable/ScrollableArea.tsx b/packages/ui/components/scrollable/ScrollableArea.tsx index 1e1306e2bbf5a9..23e3da94bdf811 100644 --- a/packages/ui/components/scrollable/ScrollableArea.tsx +++ b/packages/ui/components/scrollable/ScrollableArea.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, PropsWithChildren } from "react"; +import type { PropsWithChildren } from "react"; import React, { useRef, useEffect, useState } from "react"; import { classNames } from "@calcom/lib"; @@ -17,15 +17,6 @@ const ScrollableArea = ({ children, className }: PropsWithChildren<{ className?: } }, []); - const overflowIndicatorStyles = { - position: "absolute", - top: 0, - width: "100%", - height: "30px", - background: "linear-gradient(to bottom, transparent, gray 40px)", - zIndex: 10, - } as CSSProperties; - return (
{children} - {isOverflowingY &&
} + {isOverflowingY &&
}
); };