diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 4ff36ca97a..ae2313c733 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 274f9dfcf6..52900a523b 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 0000000000..4361e6b928 --- /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 0000000000..118ad0cc47 --- /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 0000000000..c11fe9627a --- /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 0000000000..e79dbc2ea6 --- /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 10160ae34a..9625c791d4 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 0000000000..b5496a867a --- /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 8eee52c3f5..1927fc8671 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 0000000000..1b5ba5ced0 --- /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 d9d0137957..3fa89085d1 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 61c649684a..179b3c05ff 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 8050f9338d..75d69830ff 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) { +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 page.click('[data-testid="install-app-button"]'); + await page.click(buttonSelector); - 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.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 b8e67d47c0..d490c7ce45 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 19291b6345..ca8d35e193 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 d36262b956..9a419431b4 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 baa0df45da..69d05a0a92 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 68702b7710..91c461b548 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 30e00222f8..e13a5a4292 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 c300a808c6..2a432997ef 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 3bc5e0e3fb..70b497522e 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 632ef31447..318d26addf 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 74a9210dc5..ac87eed277 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 1b468204a3..b5947fad57 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 ffc97b935a..cec2a72e07 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 0cc2358e49..186802b882 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 c2dbb9fad1..fb121835f5 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 98a5518ab8..d359812bea 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 a0cad118dc..af9d241373 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 6ec6551057..c13c045312 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 e342f74fb2..1ea69be584 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 7f8e670066..2fb381f1ff 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 58a46a86e9..5224a8ab02 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 8e0b71952b..d5c8dae388 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 b416739784..38c6b5df78 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 34682ccce9..1449a0169c 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 a809d0ff54..c0baba41f8 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 e27a228af1..7de9af060a 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 3d7c0734af..8859f4fdf2 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 138b5e5442..a851408161 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 113fef68a6..c55b9bd13a 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 874468fbf6..ca7c28ad3c 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 09899e57f7..10e0a0f8a6 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 0000000000..ea2a79002c --- /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 b491e51160..029bbaf649 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 09899e57f7..10e0a0f8a6 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 0000000000..0ad1148d49 --- /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 b0bdb496d5..30d6e8eb06 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 050f4ab234..5be513f2a7 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 b285eeedef..b48ce29e34 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 0000000000..0cb8017443 --- /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 dbdb831035..4701bde435 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 1fc1bbc03d..c835c79d22 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 09899e57f7..10e0a0f8a6 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 0000000000..ace8121c1f --- /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 b2faf7243d..f535c680e8 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 ea5379a233..7212d9635d 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 9a0f3d6003..65f90fd31a 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 6665aba5b6..64c05ceb5d 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 37c6831818..ef9a2955c2 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 6f6b042431..2a5b71700e 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 050e2ed7d8..ea00f1cde6 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 bc2a1349da..9d51eab49c 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 38ee45f214..8bab69cac9 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 0000000000..14b3103673 --- /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 7d055c3b49..a56804c0e8 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 c0cea23d8e..d1dbcf09fa 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 1b6cec2091..2770acacc5 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 6389322c54..98ded5de73 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 8d13922560..cc1da5656d 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 4880ce02e6..9b663ad63d 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 02fcb22c5b..3dc6efbfe6 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 5624fd3f39..86558342a0 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 2559c47fb1..dadb6e2ba2 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 37fa14a103..7812bad8df 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 306d0be058..440f307ff8 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 0000000000..bafa35679f --- /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 2fa2baee89..8d157e8814 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 acab0974a2..53df00b922 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 0000000000..cc069d32eb --- /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 3597091bbd..25763202ba 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 12680a74c3..aac51930e6 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 45788adfe7..947d2c3ecc 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 55ad690527..22eacf8097 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 aad019a560..a764389404 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 3f95a3c9b0..d159830f94 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 3f15a92402..d98cf3af04 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 bb0e8f5a93..fe3bf5bd2f 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 b44436beba..d8c02a2f65 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 26bd34023d..54f87d7210 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 98e3b4ca87..61d16f7506 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 886f626913..24f406e14c 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 424ba6ce6c..6ab5fe68ff 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 28f9477129..6deef75e2d 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 daf11d5a7d..ff0a14c376 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 dfb22ab85b..da1fb01df9 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 f38f25ad8f..4baeb62de9 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 f0359e0b04..19d47d1527 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 e7769ebd57..6193d26c1c 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 587fc561f5..f37f9b5ac2 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 dde161c6ae..af42fc8ce5 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 3844faa3f2..5bf0dcd9d6 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 7b1c1d9c4e..d7819dea08 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 260e7411a0..c29179fedf 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 26b50fb9d2..b8be473f70 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 f1ab94a2a8..96a3ad3a05 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 aea58ef2c7..d4a2851271 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 6d43249f52..5bdbb60829 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 9f02f28201..bcbed269fa 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 daea3c1162..2a0cf837ca 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 665efb8446..4a65fe8480 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 0000000000..e6117b9296 --- /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 0000000000..8a7bb37630 --- /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 0000000000..fda259dfe7 --- /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 0000000000..261d5e30ac --- /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 e4ecb40e9b..94fa03001b 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 4c81987fc6..62b14dcc89 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 7ceefeb10f..140108445e 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 3c52946628..fb042d69a1 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; -} +}; -const Steps = (props: ISteps) => { +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: 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 1e1306e2bb..23e3da94bd 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 &&
}
); };