chore: App-router-migration(/apps) fix metadata, extract components into /module and finish migration (#16354)

* fix metadata for apps page

* migrate apps route to modules

* migrate apps/[slug]

* migrate apps/[slug]/setup

* migrate apps/categories/**/*

* add apps/installed page to app router

* migrate apps/[slug]/[...pages]

* migrate apps/installed/**/*

* Add apps/installation/[[...step]] to App router

* fix imports

* add missing use client directive

* finish migration

* fix metadata

* fix metadata for apps/categories

* fix type for installation/[[...step]]

* fix type for apps/[slug]/[...pages]

* remove unnecessary code

* refactor

* fix apps/installation

* move getServerSideProps to lib

* fix import of apps/installation

* fix import for app slug page

* refactor

* fix installation page in app router

* remove res in getServerSideProps

* replace setHeader with NextResponse header setHeader

* refactor installed pages

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Benny Joo
2024-09-06 15:40:19 +00:00
committed by GitHub
co-authored by Keith Williams Joe Au-Yeung Peer Richelsen
parent a910eb96ad
commit d2f7a427d0
34 changed files with 1523 additions and 1430 deletions
@@ -1,46 +1,17 @@
import LegacyPage, { getLayout } from "@pages/apps/[slug]/[...pages]";
import type { PageProps, SearchParams } from "app/_types";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { SearchParams } from "app/_types";
import { _generateMetadata } from "app/_utils";
import type { GetServerSidePropsContext } from "next";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsResult } from "next";
import type { Params } from "next/dist/shared/lib/router/utils/route-matcher";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { notFound } from "next/navigation";
import z from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import RoutingFormsRoutingConfig, {
serverSidePropsConfig,
} from "@calcom/app-store/routing-forms/pages/app-routing.config";
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import type { AppProps } from "@lib/app-providers";
import { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import PageWrapper from "@components/PageWrapperAppDir";
import { ssrInit } from "@server/lib/ssr";
type AppPageType = {
getServerSideProps: AppGetServerSideProps;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: ((props: any) => JSX.Element) &
Pick<AppProps["Component"], "isBookingPage" | "getLayout" | "PageWrapper">;
};
type Found = {
notFound: false;
Component: AppPageType["default"];
getServerSideProps: AppPageType["getServerSideProps"];
};
const AppsRouting = {
"routing-forms": RoutingFormsRoutingConfig,
typeform: TypeformRoutingConfig,
};
import LegacyPage, { getLayout } from "~/apps/[slug]/[...pages]/pages-view";
const paramsSchema = z.object({
slug: z.string(),
@@ -60,127 +31,22 @@ export const generateMetadata = async ({
return notFound();
}
const mainPage = p.data.pages[0];
if (mainPage === "forms") {
return await _generateMetadata(
() => `Forms`,
() => ""
);
}
const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams);
const { form } = await getPageProps(legacyContext);
const data = await getData(legacyContext);
const form = "form" in data ? (data.form as { name?: string; description?: string }) : null;
const formName = form?.name ?? "Forms";
const formDescription = form?.description ?? "";
return await _generateMetadata(
() => `${form.name}`,
() => form.description
() => formName,
() => formDescription
);
};
function getRoute(appName: string, pages: string[]) {
const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record<string, AppPageType>;
const getData = withAppDirSsr<GetServerSidePropsResult<any>>(getServerSideProps);
if (!routingConfig) {
notFound();
}
const mainPage = pages[0];
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType);
const getServerSidePropsHandler = serverSidePropsConfig[mainPage];
if (!appPage) {
notFound();
}
return {
notFound: false,
Component: appPage.default,
...appPage,
getServerSideProps: getServerSidePropsHandler,
} as Found;
}
const getPageProps = async ({ params, query, req }: GetServerSidePropsContext) => {
const p = paramsSchema.safeParse(params);
if (!p.success) {
return notFound();
}
const { slug: appName, pages } = p.data;
const route = getRoute(appName, pages);
if (route.notFound) {
return route;
}
if (route.getServerSideProps) {
// TODO: Document somewhere that right now it is just a convention that filename should have appPages in it's name.
// appPages is actually hardcoded here and no matter the fileName the same variable would be used.
// We can write some validation logic later on that ensures that [...appPages].tsx file exists
params!.appPages = pages.slice(1);
const ctx = { req, params, query };
const session = await getServerSession({ req });
const user = session?.user;
const app = await getAppWithMetadata({ slug: appName });
if (!app) {
notFound();
}
const result = await route.getServerSideProps(
{
...ctx,
params: {
...ctx.params,
appPages: pages.slice(1),
},
} as GetServerSidePropsContext<{
slug: string;
pages: string[];
appPages: string[];
}>,
prisma,
user,
ssrInit
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
if (result.notFound) {
notFound();
}
if (result.redirect) {
redirect(result.redirect.destination);
}
return {
appName,
appUrl: app.simplePath || `/apps/${appName}`,
...result.props,
};
} else {
return {
appName,
};
}
};
export default async function Page({ params, searchParams }: PageProps) {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
const legacyContext = buildLegacyCtx(h, cookies(), params, searchParams);
const props = await getPageProps(legacyContext);
return (
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null} {...props}>
<LegacyPage {...props} />
</PageWrapper>
);
}
export default WithLayout({
getLayout,
getData,
Page: LegacyPage,
});
+8 -8
View File
@@ -1,21 +1,21 @@
import Page from "@pages/apps/[slug]/index";
import { Prisma } from "@prisma/client";
import { withAppDirSsg } from "app/WithAppDirSsg";
import type { PageProps } from "app/_types";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { InferGetStaticPropsType } from "next";
import { cookies, headers } from "next/headers";
import prisma from "@calcom/prisma";
import { AppRepository } from "@calcom/lib/server/repository/app";
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
type Y = InferGetStaticPropsType<typeof getStaticProps>;
const getData = withAppDirSsg<Y>(getStaticProps);
import type { PageProps } from "~/apps/[slug]/slug-view";
import Page from "~/apps/[slug]/slug-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsg<PageProps>(getStaticProps);
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams);
const res = await getData(legacyContext);
@@ -27,7 +27,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
export const generateStaticParams = async () => {
try {
const appStore = await prisma.app.findMany({ select: { slug: true } });
const appStore = await AppRepository.findAppStore();
return appStore.map(({ slug }) => ({ slug }));
} catch (e: unknown) {
if (e instanceof Prisma.PrismaClientInitializationError) {
@@ -1,20 +1,19 @@
import Page from "@pages/apps/[slug]/setup";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { InferGetServerSidePropsType } from "next";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
export const generateMetadata = async ({ params }: { params: Record<string, string | string[]> }) => {
import Page, { type PageProps } from "~/apps/[slug]/setup/setup-view";
export const generateMetadata = async ({ params }: _PageProps) => {
return await _generateMetadata(
() => `${params.slug}`,
() => ""
);
};
type T = InferGetServerSidePropsType<typeof getServerSideProps>;
const getData = withAppDirSsr<T>(getServerSideProps);
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, Page, getData });
@@ -1,18 +1,18 @@
import CategoryPage, { type PageProps } from "@pages/apps/categories/[category]";
import { withAppDirSsg } from "app/WithAppDirSsg";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { APP_NAME } from "@calcom/lib/constants";
import { AppCategories } from "@calcom/prisma/enums";
import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check";
import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
import CategoryPage, { type PageProps } from "~/apps/categories/[category]/category-view";
export const generateMetadata = async () => {
return await _generateMetadata(
() => `${APP_NAME}`,
() => ""
() => "Apps Store",
() => "Connecting people, technology and the workplace."
);
};
+4 -3
View File
@@ -1,14 +1,15 @@
import Page from "@pages/apps/categories/index";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/apps/categories/getServerSideProps";
import Page from "~/apps/categories/categories-view";
export const generateMetadata = async () => {
return await _generateMetadata(
() => `Categories`,
() => ""
() => "Apps Store",
() => "Connecting people, technology and the workplace."
);
};
@@ -0,0 +1,25 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { getServerSideProps } from "@lib/apps/installation/[[...step]]/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view";
import Page from "~/apps/installation/[[...step]]/step-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const { appMetadata } = await getData(legacyCtx);
return await _generateMetadata(
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
() => ""
);
};
const getData = withAppDirSsr<OnboardingPageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData, Page });
@@ -1,17 +1,18 @@
import Page from "@pages/apps/installed/[category]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps";
import { getServerSidePropsAppDir } from "@lib/apps/installed/[category]/getServerSideProps";
import Page from "~/apps/installed/[category]/installed-category-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${t("installed_apps")}`,
(t) => t("installed_apps"),
(t) => t("manage_your_connected_apps")
);
};
const getData = withAppDirSsr(getServerSideProps);
const getData = withAppDirSsr(getServerSidePropsAppDir);
export default WithLayout({ getLayout: null, getData, Page });
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
const Page = () => {
redirect("/apps/installed/calendar");
};
export default Page;
+9 -7
View File
@@ -1,18 +1,20 @@
import AppsPage from "@pages/apps";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { APP_NAME } from "@calcom/lib/constants";
import { getServerSideProps } from "@lib/apps/getServerSideProps";
import AppsPage, { LayoutWrapper } from "~/apps/apps-view";
export const generateMetadata = async () => {
return await _generateMetadata(
() => `Apps | ${APP_NAME}`,
() => ""
(t) => t("app_store"),
(t) => t("app_store_description")
);
};
export default WithLayout({ getLayout, getData: withAppDirSsr(getServerSideProps), Page: AppsPage });
export default WithLayout({
getLayout: LayoutWrapper,
getData: withAppDirSsr(getServerSideProps),
Page: AppsPage,
});
@@ -1,5 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
import type { Dispatch, SetStateAction } from "react";
import type { FC } from "react";
import React, { forwardRef, useEffect, useRef, useState } from "react";
@@ -18,6 +17,8 @@ import { Button, Form, Icon } from "@calcom/ui";
import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper";
import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings";
import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view";
export type TFormType = {
id: number;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
@@ -1,4 +1,3 @@
import type { TEventType } from "@pages/apps/installation/[[...step]]";
import { useEffect, type FC } from "react";
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
@@ -7,6 +6,8 @@ import useAppsData from "@calcom/lib/hooks/useAppsData";
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
import type { TEventType } from "~/apps/installation/[[...step]]/step-view";
type EventTypeAppSettingsWrapperProps = Pick<
ConfigureStepCardProps,
"slug" | "userName" | "categories" | "credentialId"
@@ -1,4 +1,3 @@
import type { TEventType } from "@pages/apps/installation/[[...step]]";
import { useMemo } from "react";
import { useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
@@ -18,6 +17,8 @@ import { QueryCell } from "@lib/QueryCell";
import type { TFormType } from "@components/apps/installation/ConfigureStepCard";
import type { TEventType } from "~/apps/installation/[[...step]]/step-view";
const LocationsWrapper = ({
eventType,
slug,
@@ -1,4 +1,3 @@
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
import type { Dispatch, SetStateAction } from "react";
import type { FC } from "react";
import React from "react";
@@ -8,6 +7,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { ScrollableArea, Badge, Button } from "@calcom/ui";
import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view";
type EventTypesCardProps = {
userName: string;
setConfigureStep: Dispatch<SetStateAction<boolean>>;
@@ -0,0 +1,135 @@
import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config";
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import type { AppProps } from "@lib/app-providers";
import { ssrInit } from "@server/lib/ssr";
type AppPageType = {
getServerSideProps: AppGetServerSideProps;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: ((props: any) => JSX.Element) &
Pick<AppProps["Component"], "isBookingPage" | "getLayout" | "PageWrapper">;
};
type Found = {
notFound: false;
Component: AppPageType["default"];
getServerSideProps: AppPageType["getServerSideProps"];
};
type NotFound = {
notFound: true;
};
// TODO: It is a candidate for apps.*.generated.*
const AppsRouting = {
"routing-forms": RoutingFormsRoutingConfig,
typeform: TypeformRoutingConfig,
};
function getRoute(appName: string, pages: string[]) {
const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record<string, AppPageType>;
if (!routingConfig) {
return {
notFound: true,
} as NotFound;
}
const mainPage = pages[0];
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType);
if (!appPage) {
return {
notFound: true,
} as NotFound;
}
return { notFound: false, Component: appPage.default, ...appPage } as Found;
}
const paramsSchema = z.object({
slug: z.string(),
pages: z.array(z.string()),
});
export async function getServerSideProps(
context: GetServerSidePropsContext
): Promise<GetServerSidePropsResult<any>> {
const { params, req } = context;
if (!params) {
return {
notFound: true,
};
}
const parsedParams = paramsSchema.safeParse(params);
if (!parsedParams.success) {
return {
notFound: true,
};
}
const appName = parsedParams.data.slug;
const pages = parsedParams.data.pages;
const route = getRoute(appName, pages);
if (route.notFound) {
return { notFound: true };
}
if (route.getServerSideProps) {
// TODO: Document somewhere that right now it is just a convention that filename should have appPages in it's name.
// appPages is actually hardcoded here and no matter the fileName the same variable would be used.
// We can write some validation logic later on that ensures that [...appPages].tsx file exists
params.appPages = pages.slice(1);
const session = await getServerSession({ req });
const user = session?.user;
const app = await getAppWithMetadata({ slug: appName });
if (!app) {
return {
notFound: true,
};
}
const result = await route.getServerSideProps(
context as GetServerSidePropsContext<{
slug: string;
pages: string[];
appPages: string[];
}>,
prisma,
user,
ssrInit
);
if (result.notFound) {
return { notFound: true };
}
if (result.redirect) {
return { redirect: result.redirect };
}
return {
props: {
appName,
appUrl: app.simplePath || `/apps/${appName}`,
...result.props,
},
};
} else {
return {
props: {
appName,
},
};
}
}
@@ -0,0 +1,296 @@
import type { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { z } from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { isConferencing as isConferencingApp } from "@calcom/app-store/utils";
import type { LocationObject } from "@calcom/core/location";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import prisma from "@calcom/prisma";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { STEPS } from "~/apps/installation/[[...step]]/constants";
import type { OnboardingPageProps, TEventType } from "~/apps/installation/[[...step]]/step-view";
// 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,
avatarUrl: true,
name: true,
username: true,
teams: {
where: {
accepted: true,
team: {
members: {
some: {
userId,
role: {
in: ["ADMIN", "OWNER"],
},
},
},
},
},
select: {
team: {
select: {
id: true,
name: true,
logoUrl: true,
parent: {
select: {
logoUrl: true,
name: true,
},
},
},
},
},
},
},
});
if (!user) {
throw new Error(ERROR_MESSAGES.userNotFound);
}
const teams = user.teams.map(({ team }) => ({
...team,
logoUrl: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
}));
return {
...user,
teams,
};
};
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,
locations: true,
userId: true,
destinationCalendar: true,
bookingFields: true,
},
/**
* filter out managed events for now
* @todo: can install apps to managed event types
*/
where: teamId ? { teamId } : { userId, parent: null, 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,
locations: item.locations as unknown as LocationObject[],
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
}));
};
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, 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 });
const locale = await getLocale(context.req);
const app = await getAppBySlug(parsedAppSlug);
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
const extendsEventType = appMetadata?.extendsFeature === "EventType";
const isConferencing = isConferencingApp(appMetadata.categories);
const showEventTypesStep = extendsEventType || isConferencing;
console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep);
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
const user = await getUser(session.user.id);
const userTeams = user.teams;
const hasTeams = Boolean(userTeams.length);
const appInstalls = await getAppInstallsBySlug(
parsedAppSlug,
user.id,
userTeams.map(({ id }) => id)
);
if (parsedTeamIdParam) {
const isUserMemberOfTeam = userTeams.some((team) => team.id === parsedTeamIdParam);
if (!isUserMemberOfTeam) {
throw new Error(ERROR_MESSAGES.userNotInTeam);
}
}
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
if (!showEventTypesStep) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
if (isConferencing) {
const destinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
userId: user.id,
eventTypeId: null,
},
});
for (let index = 0; index < eventTypes.length; index++) {
let eventType = eventTypes[index];
if (!eventType.destinationCalendar) {
eventType = { ...eventType, destinationCalendar };
}
eventTypes[index] = eventType;
}
}
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,
avatarUrl: user.avatarUrl,
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
};
const teamsWithIsAppInstalled = hasTeams
? userTeams.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,
showEventTypesStep,
step: parsedStepParam,
teams: teamsWithIsAppInstalled,
personalAccount,
eventTypes,
teamId: parsedTeamIdParam ?? null,
userName: user.username,
credentialId,
isConferencing,
// conferencing apps dont support team install
installableOnTeams: !isConferencing,
} 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" } };
}
} else {
return { redirect: { permanent: false, destination: "/apps" } };
}
}
};
@@ -41,3 +41,38 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
},
};
}
export async function getServerSidePropsAppDir(ctx: GetServerSidePropsContext) {
// get return-to cookie and redirect if needed
const { cookies } = ctx.req;
const returnTo = cookies["return-to"];
if (cookies && returnTo) {
const NextResponse = await import("next/server").then((mod) => mod.NextResponse);
const response = NextResponse.next();
response.headers.set("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
const redirect = {
redirect: {
destination: `${returnTo}`,
permanent: false,
},
} as const;
return redirect;
}
const params = querySchema.safeParse(ctx.params);
if (!params.success) {
const notFound = { notFound: true } as const;
return notFound;
}
return {
props: {
category: params.data.category,
},
};
}
@@ -0,0 +1,99 @@
"use client";
import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config";
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { AppProps } from "@lib/app-providers";
import type { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps";
type AppPageType = {
getServerSideProps: AppGetServerSideProps;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: ((props: any) => JSX.Element) &
Pick<AppProps["Component"], "isBookingPage" | "getLayout" | "PageWrapper">;
};
type Found = {
notFound: false;
Component: AppPageType["default"];
getServerSideProps: AppPageType["getServerSideProps"];
};
type NotFound = {
notFound: true;
};
// TODO: It is a candidate for apps.*.generated.*
const AppsRouting = {
"routing-forms": RoutingFormsRoutingConfig,
typeform: TypeformRoutingConfig,
};
function getRoute(appName: string, pages: string[]) {
const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record<string, AppPageType>;
if (!routingConfig) {
return {
notFound: true,
} as NotFound;
}
const mainPage = pages[0];
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType);
if (!appPage) {
return {
notFound: true,
} as NotFound;
}
return { notFound: false, Component: appPage.default, ...appPage } as Found;
}
export type PageProps = inferSSRProps<typeof getServerSideProps>;
const AppPage: AppPageType["default"] = function AppPage(props: PageProps) {
const appName = props.appName;
const params = useParamsWithFallback();
const pages = Array.isArray(params.pages) ? params.pages : params.pages?.split("/") ?? [];
const route = getRoute(appName, pages);
const componentProps = {
...props,
pages: pages.slice(1),
};
if (!route || route.notFound) {
throw new Error("Route can't be undefined");
}
return <route.Component {...componentProps} />;
};
AppPage.isBookingPage = ({ router }) => {
const route = getRoute(router.query.slug as string, router.query.pages as string[]);
if (route.notFound) {
return false;
}
const isBookingPage = route.Component.isBookingPage;
if (typeof isBookingPage === "function") {
return isBookingPage({ router });
}
return !!isBookingPage;
};
export const getLayout: NonNullable<(typeof AppPage)["getLayout"]> = (page) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { slug, pages } = useParamsWithFallback();
const route = getRoute(slug as string, pages as string[]);
if (route.notFound) {
return null;
}
if (!route.Component.getLayout) {
return page;
}
return route.Component.getLayout(page);
};
export default AppPage;
@@ -0,0 +1,38 @@
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import type { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { HeadSeo } from "@calcom/ui";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function SetupInformation(props: PageProps) {
const searchParams = useCompatSearchParams();
const router = useRouter();
const slug = searchParams?.get("slug") as string;
const { status } = useSession();
if (status === "loading") {
return <div className="bg-emphasis absolute z-50 flex h-screen w-full items-center" />;
}
if (status === "unauthenticated") {
const urlSearchParams = new URLSearchParams({
callbackUrl: `/apps/${slug}/setup`,
});
router.replace(`/auth/login?${urlSearchParams.toString()}`);
}
return (
<>
{/* So that the set up page does not get indexed by search engines */}
<HeadSeo nextSeoProps={{ noindex: true, nofollow: true }} title={`${slug} | Cal.com`} description="" />
<AppSetupPage slug={slug} {...props} />
</>
);
}
@@ -0,0 +1,81 @@
"use client";
import MarkdownIt from "markdown-it";
import type { InferGetStaticPropsType } from "next";
import Link from "next/link";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { showToast } from "@calcom/ui";
import type { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import App from "@components/apps/App";
const md = new MarkdownIt("default", { html: true, breaks: true });
export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;
function SingleAppPage(props: PageProps) {
const { error, setQuery: setError } = useRouterQuery("error");
const { t } = useLocale();
if (error === "account_already_linked") {
showToast(t(error), "error", { id: error });
setError(undefined);
}
// If it's not production environment, it would be a better idea to inform that the App is disabled.
if (props.isAppDisabled) {
if (!IS_PRODUCTION) {
// TODO: Improve disabled App UI. This is just a placeholder.
return (
<div className="p-2">
This App seems to be disabled. If you are an admin, you can enable this app from{" "}
<Link href="/settings/admin/apps" className="cursor-pointer text-blue-500 underline">
here
</Link>
</div>
);
}
// Disabled App should give 404 any ways in production.
return null;
}
const { source, data } = props;
return (
<App
name={data.name}
description={data.description}
isGlobal={data.isGlobal}
slug={data.slug}
variant={data.variant}
type={data.type}
logo={data.logo}
categories={data.categories ?? [data.category]}
author={data.publisher}
feeType={data.feeType || "usage-based"}
price={data.price || 0}
commission={data.commission || 0}
docs={data.docsUrl}
website={data.url}
email={data.email}
licenseRequired={data.licenseRequired}
teamsPlanRequired={data.teamsPlanRequired}
descriptionItems={source.data?.items as string[] | undefined}
isTemplate={data.isTemplate}
dependencies={data.dependencies}
concurrentMeetings={data.concurrentMeetings}
paid={data.paid}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={
<>
<div dangerouslySetInnerHTML={{ __html: md.render(source.content) }} />
</>
}
/>
);
}
export default SingleAppPage;
+110
View File
@@ -0,0 +1,110 @@
"use client";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { HorizontalTabItemProps } from "@calcom/ui";
import {
AllApps,
AppStoreCategories,
HorizontalTabs,
TextField,
PopularAppsSlider,
RecentAppsSlider,
} from "@calcom/ui";
import { Icon } from "@calcom/ui";
import { type getServerSideProps } from "@lib/apps/getServerSideProps";
import AppsLayout from "@components/apps/layouts/AppsLayout";
const tabs: HorizontalTabItemProps[] = [
{
name: "app_store",
href: "/apps",
},
{
name: "installed_apps",
href: "/apps/installed",
},
];
function AppsSearch({
onChange,
className,
}: {
onChange: ChangeEventHandler<HTMLInputElement>;
className?: string;
}) {
const { t } = useLocale();
return (
<TextField
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
addOnLeading={<Icon name="search" className="text-subtle h-4 w-4" />}
addOnClassname="!border-muted"
containerClassName={classNames("focus:!ring-offset-0 m-1", className)}
type="search"
autoComplete="false"
onChange={onChange}
placeholder={t("search")}
/>
);
}
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Apps({ categories, appStore, userAdminTeams }: PageProps) {
const { t } = useLocale();
const [searchText, setSearchText] = useState<string | undefined>(undefined);
return (
<AppsLayout
isPublic
heading={t("app_store")}
subtitle={t("app_store_description")}
actions={(className) => (
<div className="flex w-full flex-col pt-4 md:flex-row md:justify-between md:pt-0 lg:w-auto">
<div className="ltr:mr-2 rtl:ml-2 lg:hidden">
<HorizontalTabs tabs={tabs} />
</div>
<div>
<AppsSearch className={className} onChange={(e) => setSearchText(e.target.value)} />
</div>
</div>
)}
headerClassName="sm:hidden lg:block hidden"
emptyStore={!appStore.length}>
<div className="flex flex-col gap-y-8">
{!searchText && (
<>
<AppStoreCategories categories={categories} />
<PopularAppsSlider items={appStore} />
<RecentAppsSlider items={appStore} />
</>
)}
<AllApps
apps={appStore}
searchText={searchText}
categories={categories.map((category) => category.name)}
userAdminTeams={userAdminTeams}
/>
</div>
</AppsLayout>
);
}
export const LayoutWrapper = (page: React.ReactElement) => {
return (
<Shell
title="Apps Store"
description="Create forms to direct attendees to the correct destinations."
withoutMain={true}
hideHeadingOnMobile>
{page}
</Shell>
);
};
@@ -0,0 +1,54 @@
"use client";
import type { InferGetStaticPropsType } from "next";
import Link from "next/link";
import Shell from "@calcom/features/shell/Shell";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCard, SkeletonText } from "@calcom/ui";
import type { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;
export default function Apps({ apps }: PageProps) {
const searchParams = useCompatSearchParams();
const { t, isLocaleReady } = useLocale();
const category = searchParams?.get("category");
return (
<>
<Shell
isPublic
backPath="/apps"
title="Apps Store"
description="Connecting people, technology and the workplace."
smallHeading
heading={
<>
<Link
href="/apps"
className="text-emphasis inline-flex items-center justify-start gap-1 rounded-sm py-2">
{isLocaleReady ? t("app_store") : <SkeletonText className="h-4 w-24" />}{" "}
</Link>
{category && (
<span className="text-default gap-1">
<span>&nbsp;/&nbsp;</span>
{t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })}
</span>
)}
</>
}>
<div className="mb-16">
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{apps
?.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))
.map((app) => {
return <AppCard key={app.slug} app={app} />;
})}
</div>
</div>
</Shell>
</>
);
}
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Icon, SkeletonText } from "@calcom/ui";
import type { getServerSideProps } from "@lib/apps/categories/getServerSideProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function Apps({ categories }: PageProps) {
const { t, isLocaleReady } = useLocale();
return (
<Shell
isPublic
large
hideHeadingOnMobile
title="Apps Store"
description="Connecting people, technology and the workplace.">
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-normal md:px-8 lg:px-0 lg:pt-0">
<Link
href="/apps"
className="text-emphasis inline-flex items-center justify-start gap-1 rounded-sm py-2">
<Icon name="arrow-left" className="h-4 w-4" />
{isLocaleReady ? t("app_store") : <SkeletonText className="h-6 w-24" />}{" "}
</Link>
</div>
<div className="mb-16">
<div className="grid h-auto w-full grid-cols-5 gap-3">
{categories.map((category) => (
<Link
key={category.name}
href={`/apps/categories/${category.name}`}
data-testid={`app-store-category-${category.name}`}
className="bg-subtle relative flex rounded-sm px-6 py-4 sm:block">
<div className="self-center">
<h3 className="font-medium capitalize">{category.name}</h3>
<p className="text-subtle text-sm">
{t("number_apps", { count: category.count })}{" "}
<Icon name="arrow-right" className="inline-block h-4 w-4" />
</p>
</div>
</Link>
))}
</div>
</div>
</Shell>
);
}
@@ -0,0 +1,7 @@
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
export const STEPS = [
AppOnboardingSteps.ACCOUNTS_STEP,
AppOnboardingSteps.EVENT_TYPES_STEP,
AppOnboardingSteps.CONFIGURE_STEP,
] as const;
@@ -0,0 +1,306 @@
"use client";
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 type { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types";
import type { LocationObject } from "@calcom/core/location";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
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 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";
import { STEPS } from "~/apps/installation/[[...step]]/constants";
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
Pick<
EventTypeModel,
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
> & {
selected: boolean;
locations: LocationFormValues["locations"];
bookingFields?: LocationFormValues["bookingFields"];
};
export type TEventTypesForm = {
eventTypes: TEventType[];
};
type StepType = (typeof STEPS)[number];
type StepObj = Record<
StepType,
{
getTitle: (appName: string) => string;
getDescription: (appName: string) => string;
stepNumber: number;
}
>;
export type OnboardingPageProps = {
appMetadata: AppMeta;
step: StepType;
teams?: TeamsProp;
personalAccount: PersonalAccountProps;
eventTypes?: TEventType[];
userName: string;
credentialId?: number;
showEventTypesStep: boolean;
isConferencing: boolean;
installableOnTeams: boolean;
};
type TUpdateObject = {
id: number;
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
bookingFields?: z.infer<typeof eventTypeBookingFields>;
locations?: LocationObject[];
};
const OnboardingPage = ({
step,
teams,
personalAccount,
appMetadata,
eventTypes,
userName,
credentialId,
showEventTypesStep,
isConferencing,
installableOnTeams,
}: 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: installableOnTeams ? 2 : 1,
},
[AppOnboardingSteps.CONFIGURE_STEP]: {
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
getDescription: () => `${t("configure_app_description")}`,
stepNumber: installableOnTeams ? 3 : 2,
},
} as const;
const [configureStep, setConfigureStep] = 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 maxSteps = useMemo(() => {
if (!showEventTypesStep) {
return 1;
}
return installableOnTeams ? STEPS.length : STEPS.length - 1;
}, [showEventTypesStep, installableOnTeams]);
const utils = trpc.useContext();
const formPortalRef = useRef<HTMLDivElement>(null);
const formMethods = useForm<TEventTypesForm>({
defaultValues: {
eventTypes,
},
});
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
if (data?.setupPending) return;
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
useEffect(() => {
eventTypes && formMethods.setValue("eventTypes", eventTypes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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) => {
mutation.mutate({
type: appMetadata.type,
variant: appMetadata.variant,
slug: appMetadata.slug,
...(teamId && { teamId }),
// for oAuth apps
...(showEventTypesStep && {
returnTo:
WEBAPP_URL +
getAppOnboardingUrl({
slug: appMetadata.slug,
teamId,
step: AppOnboardingSteps.EVENT_TYPES_STEP,
}),
}),
});
};
const handleSetUpLater = () => {
router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`);
};
return (
<div
key={pathname}
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen px-4"
data-testid="onboarding">
<Head>
<title>
{t("install")} {appMetadata?.name ?? ""}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto py-6 sm:px-4 md:py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]" ref={formPortalRef}>
<Form
form={formMethods}
id="outer-event-type-form"
handleSubmit={async (values) => {
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<typeof EventTypeMetaDataSchema>)
)
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"));
}
let updateObject: TUpdateObject = { id: value.id };
if (isConferencing) {
updateObject = {
...updateObject,
locations: value.locations,
bookingFields: value.bookingFields ? value.bookingFields : undefined,
};
} else {
updateObject = {
...updateObject,
metadata: value.metadata,
};
}
return updateMutation.mutateAsync(updateObject);
});
try {
await Promise.all(mutationPromises);
router.push("/event-types");
} catch (err) {
console.error(err);
}
}}>
<StepHeader
title={stepObj.getTitle(appMetadata.name)}
subtitle={stepObj.getDescription(appMetadata.name)}>
<Steps maxSteps={maxSteps} currentStep={stepObj.stepNumber} disableNavigation />
</StepHeader>
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
<AccountsStepCard
teams={teams}
personalAccount={personalAccount}
onSelect={handleSelectAccount}
loading={mutation.isPending}
installableOnTeams={installableOnTeams}
/>
)}
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
eventTypes &&
Boolean(eventTypes?.length) && (
<EventTypesStepCard
setConfigureStep={setConfigureStep}
userName={userName}
handleSetUpLater={handleSetUpLater}
/>
)}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && (
<ConfigureStepCard
slug={appMetadata.slug}
categories={appMetadata.categories}
credentialId={credentialId}
userName={userName}
loading={updateMutation.isPending}
formPortalRef={formPortalRef}
setConfigureStep={setConfigureStep}
eventTypes={eventTypes}
handleSetUpLater={handleSetUpLater}
isConferencing={isConferencing}
/>
)}
</Form>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
};
export default OnboardingPage;
@@ -0,0 +1,162 @@
"use client";
import { useReducer } from "react";
import getAppCategoryTitle from "@calcom/app-store/_utils/getAppCategoryTitle";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { Icon } from "@calcom/ui";
import { AppSkeletonLoader as SkeletonLoader, Button, EmptyScreen, ShellSubHeading } from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
import type { querySchemaType, getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps";
import { AppList } from "@components/apps/AppList";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
interface IntegrationsContainerProps {
variant?: AppCategories;
exclude?: AppCategories[];
handleDisconnect: (credentialId: number) => void;
}
const IntegrationsContainer = ({
variant,
exclude,
handleDisconnect,
}: IntegrationsContainerProps): JSX.Element => {
const { t } = useLocale();
const query = trpc.viewer.integrations.useQuery({
variant,
exclude,
onlyInstalled: true,
includeTeamInstalledApps: true,
});
// TODO: Refactor and reuse getAppCategories?
const emptyIcon: Record<AppCategories, React.ComponentProps<typeof Icon>["name"]> = {
calendar: "calendar",
conferencing: "video",
automation: "share-2",
analytics: "chart-bar",
payment: "credit-card",
other: "grid-3x3",
web3: "credit-card", // deprecated
video: "video", // deprecated
messaging: "mail",
crm: "contact",
};
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
if (!data.items.length) {
const emptyHeaderCategory = getAppCategoryTitle(variant || "other", true);
return (
<EmptyScreen
Icon={emptyIcon[variant || "other"]}
headline={t("no_category_apps", {
category: emptyHeaderCategory,
})}
description={t(`no_category_apps_description_${variant || "other"}`)}
buttonRaw={
<Button
color="secondary"
data-testid={`connect-${variant || "other"}-apps`}
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
{t(`connect_${variant || "other"}_apps`)}
</Button>
}
/>
);
}
return (
<div className="border-subtle rounded-md border p-7">
<ShellSubHeading
title={t(variant || "other")}
subtitle={t(`installed_app_${variant || "other"}_description`)}
className="mb-6"
actions={
<Button
data-testid="add-apps"
href={variant ? `/apps/categories/${variant}` : "/apps"}
color="secondary"
StartIcon="plus">
{t("add")}
</Button>
}
/>
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
</div>
);
}}
/>
);
};
type ModalState = {
isOpen: boolean;
credentialId: null | number;
teamId?: number;
};
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InstalledApps(props: PageProps) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const category = searchParams?.get("category") as querySchemaType["category"];
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
// Exclude calendar and other from categoryList, we handle those slightly differently below
return !(category in { other: null, calendar: null });
});
const [data, updateData] = useReducer(
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
{
isOpen: false,
credentialId: null,
}
);
const handleModelClose = () => {
updateData({ isOpen: false, credentialId: null });
};
const handleDisconnect = (credentialId: number, teamId?: number) => {
updateData({ isOpen: true, credentialId, teamId });
};
return (
<>
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
{categoryList.includes(category) && (
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
)}
{category === "calendar" && <CalendarListContainer />}
{category === "other" && (
<IntegrationsContainer
handleDisconnect={handleDisconnect}
variant={category}
exclude={[...categoryList, "calendar"]}
/>
)}
</InstalledAppsLayout>
<DisconnectIntegrationModal
handleModelClose={handleModelClose}
isOpen={data.isOpen}
credentialId={data.credentialId}
teamId={data.teamId}
/>
</>
);
}
+8 -172
View File
@@ -1,179 +1,15 @@
"use client";
import type { GetServerSidePropsContext } from "next";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import RoutingFormsRoutingConfig from "@calcom/app-store/routing-forms/pages/app-routing.config";
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import prisma from "@calcom/prisma";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
import type { AppProps } from "@lib/app-providers";
import { getServerSideProps } from "@lib/apps/[slug]/[...pages]/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
import type { PageProps } from "~/apps/[slug]/[...pages]/pages-view";
import PagesView, { getLayout } from "~/apps/[slug]/[...pages]/pages-view";
type AppPageType = {
getServerSideProps: AppGetServerSideProps;
// A component than can accept any properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: ((props: any) => JSX.Element) &
Pick<AppProps["Component"], "isBookingPage" | "getLayout" | "PageWrapper">;
};
const Page = (props: PageProps) => <PagesView {...props} />;
type Found = {
notFound: false;
Component: AppPageType["default"];
getServerSideProps: AppPageType["getServerSideProps"];
};
Page.PageWrapper = PageWrapper;
Page.getLayout = getLayout;
type NotFound = {
notFound: true;
};
export { getServerSideProps };
// TODO: It is a candidate for apps.*.generated.*
const AppsRouting = {
"routing-forms": RoutingFormsRoutingConfig,
typeform: TypeformRoutingConfig,
};
function getRoute(appName: string, pages: string[]) {
const routingConfig = AppsRouting[appName as keyof typeof AppsRouting] as Record<string, AppPageType>;
if (!routingConfig) {
return {
notFound: true,
} as NotFound;
}
const mainPage = pages[0];
const appPage = routingConfig.layoutHandler || (routingConfig[mainPage] as AppPageType);
if (!appPage) {
return {
notFound: true,
} as NotFound;
}
return { notFound: false, Component: appPage.default, ...appPage } as Found;
}
const AppPage: AppPageType["default"] = function AppPage(props) {
const appName = props.appName;
const params = useParamsWithFallback();
const pages = Array.isArray(params.pages) ? params.pages : params.pages?.split("/") ?? [];
const route = getRoute(appName, pages);
const componentProps = {
...props,
pages: pages.slice(1),
};
if (!route || route.notFound) {
throw new Error("Route can't be undefined");
}
return <route.Component {...componentProps} />;
};
AppPage.isBookingPage = ({ router }) => {
const route = getRoute(router.query.slug as string, router.query.pages as string[]);
if (route.notFound) {
return false;
}
const isBookingPage = route.Component.isBookingPage;
if (typeof isBookingPage === "function") {
return isBookingPage({ router });
}
return !!isBookingPage;
};
export const getLayout: NonNullable<(typeof AppPage)["getLayout"]> = (page) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { slug, pages } = useParamsWithFallback();
const route = getRoute(slug as string, pages as string[]);
if (route.notFound) {
return null;
}
if (!route.Component.getLayout) {
return page;
}
return route.Component.getLayout(page);
};
AppPage.PageWrapper = PageWrapper;
AppPage.getLayout = getLayout;
export default AppPage;
export async function getServerSideProps(
context: GetServerSidePropsContext<{
slug: string;
pages: string[];
appPages?: string[];
}>
) {
const { params, req, res } = context;
if (!params) {
return {
notFound: true,
};
}
const appName = params.slug;
const pages = params.pages;
const route = getRoute(appName, pages);
if (route.notFound) {
return route;
}
if (route.getServerSideProps) {
// TODO: Document somewhere that right now it is just a convention that filename should have appPages in it's name.
// appPages is actually hardcoded here and no matter the fileName the same variable would be used.
// We can write some validation logic later on that ensures that [...appPages].tsx file exists
params.appPages = pages.slice(1);
const session = await getServerSession({ req, res });
const user = session?.user;
const app = await getAppWithMetadata({ slug: appName });
if (!app) {
return {
notFound: true,
};
}
const result = await route.getServerSideProps(
context as GetServerSidePropsContext<{
slug: string;
pages: string[];
appPages: string[];
}>,
prisma,
user,
ssrInit
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
if (result.notFound) {
return {
notFound: true,
};
}
if (result.redirect) {
return {
redirect: result.redirect,
};
}
return {
props: {
appName,
appUrl: app.simplePath || `/apps/${appName}`,
...result.props,
},
};
} else {
return {
props: {
appName,
},
};
}
}
export default Page;
+7 -75
View File
@@ -1,90 +1,22 @@
"use client";
import { Prisma } from "@prisma/client";
import MarkdownIt from "markdown-it";
import type { GetStaticPaths } from "next";
import Link from "next/link";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { showToast } from "@calcom/ui";
import { AppRepository } from "@calcom/lib/server/repository/app";
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import App from "@components/apps/App";
const md = new MarkdownIt("default", { html: true, breaks: true });
import type { PageProps } from "~/apps/[slug]/slug-view";
import SingleAppPage from "~/apps/[slug]/slug-view";
function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
const { error, setQuery: setError } = useRouterQuery("error");
const { t } = useLocale();
if (error === "account_already_linked") {
showToast(t(error), "error", { id: error });
setError(undefined);
}
// If it's not production environment, it would be a better idea to inform that the App is disabled.
if (props.isAppDisabled) {
if (!IS_PRODUCTION) {
// TODO: Improve disabled App UI. This is just a placeholder.
return (
<div className="p-2">
This App seems to be disabled. If you are an admin, you can enable this app from{" "}
<Link href="/settings/admin/apps" className="cursor-pointer text-blue-500 underline">
here
</Link>
</div>
);
}
// Disabled App should give 404 any ways in production.
return null;
}
const { source, data } = props;
return (
<App
name={data.name}
description={data.description}
isGlobal={data.isGlobal}
slug={data.slug}
variant={data.variant}
type={data.type}
logo={data.logo}
categories={data.categories ?? [data.category]}
author={data.publisher}
feeType={data.feeType || "usage-based"}
price={data.price || 0}
commission={data.commission || 0}
docs={data.docsUrl}
website={data.url}
email={data.email}
licenseRequired={data.licenseRequired}
teamsPlanRequired={data.teamsPlanRequired}
descriptionItems={source.data?.items as string[] | undefined}
isTemplate={data.isTemplate}
dependencies={data.dependencies}
concurrentMeetings={data.concurrentMeetings}
paid={data.paid}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={
<>
<div dangerouslySetInnerHTML={{ __html: md.render(source.content) }} />
</>
}
/>
);
}
const Page = (props: PageProps) => <SingleAppPage {...props} />;
export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
let paths: { params: { slug: string } }[] = [];
try {
const appStore = await prisma.app.findMany({ select: { slug: true } });
const appStore = await AppRepository.findAppStore();
paths = appStore.map(({ slug }) => ({ params: { slug } }));
} catch (e: unknown) {
if (e instanceof Prisma.PrismaClientInitializationError) {
@@ -102,6 +34,6 @@ export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
export { getStaticProps };
SingleAppPage.PageWrapper = PageWrapper;
Page.PageWrapper = PageWrapper;
export default SingleAppPage;
export default Page;
+6 -34
View File
@@ -1,42 +1,14 @@
"use client";
import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const searchParams = useCompatSearchParams();
const router = useRouter();
const slug = searchParams?.get("slug") as string;
const { status } = useSession();
import type { PageProps } from "~/apps/[slug]/setup/setup-view";
import SetupView from "~/apps/[slug]/setup/setup-view";
if (status === "loading") {
return <div className="bg-emphasis absolute z-50 flex h-screen w-full items-center" />;
}
const Page = (props: PageProps) => <SetupView {...props} />;
if (status === "unauthenticated") {
const urlSearchParams = new URLSearchParams({
callbackUrl: `/apps/${slug}/setup`,
});
router.replace(`/auth/login?${urlSearchParams.toString()}`);
}
return (
<>
{/* So that the set up page does not get indexed by search engines */}
<HeadSeo nextSeoProps={{ noindex: true, nofollow: true }} title={`${slug} | Cal.com`} description="" />
<AppSetupPage slug={slug} {...props} />
</>
);
}
SetupInformation.PageWrapper = PageWrapper;
Page.PageWrapper = PageWrapper;
export { getServerSideProps };
export default Page;
+6 -52
View File
@@ -1,63 +1,15 @@
"use client";
import type { InferGetStaticPropsType } from "next";
import Link from "next/link";
import Shell from "@calcom/features/shell/Shell";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import { isPrismaAvailableCheck } from "@calcom/prisma/is-prisma-available-check";
import { AppCard, SkeletonText } from "@calcom/ui";
import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
import PageWrapper from "@components/PageWrapper";
export type PageProps = InferGetStaticPropsType<typeof getStaticProps>;
export default function Apps({ apps }: PageProps) {
const searchParams = useCompatSearchParams();
const { t, isLocaleReady } = useLocale();
const category = searchParams?.get("category");
import type { PageProps } from "~/apps/categories/[category]/category-view";
import CategoryView from "~/apps/categories/[category]/category-view";
return (
<>
<Shell
isPublic
backPath="/apps"
title="Apps Store"
description="Connecting people, technology and the workplace."
smallHeading
heading={
<>
<Link
href="/apps"
className="text-emphasis inline-flex items-center justify-start gap-1 rounded-sm py-2">
{isLocaleReady ? t("app_store") : <SkeletonText className="h-4 w-24" />}{" "}
</Link>
{category && (
<span className="text-default gap-1">
<span>&nbsp;/&nbsp;</span>
{t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })}
</span>
)}
</>
}>
<div className="mb-16">
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
{apps
?.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))
.map((app) => {
return <AppCard key={app.slug} app={app} />;
})}
</div>
</div>
</Shell>
</>
);
}
Apps.PageWrapper = PageWrapper;
const Page = (props: PageProps) => <CategoryView {...props} />;
Page.PageWrapper = PageWrapper;
export const getStaticPaths = async () => {
const paths = Object.keys(AppCategories);
@@ -76,4 +28,6 @@ export const getStaticPaths = async () => {
};
};
export default Page;
export { getStaticProps };
+5 -50
View File
@@ -1,57 +1,12 @@
"use client";
import Link from "next/link";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Icon, SkeletonText } from "@calcom/ui";
import { getServerSideProps } from "@lib/apps/categories/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
export default function Apps({ categories }: Omit<inferSSRProps<typeof getServerSideProps>, "trpcState">) {
const { t, isLocaleReady } = useLocale();
import type { PageProps } from "~/apps/categories/categories-view";
import Apps from "~/apps/categories/categories-view";
return (
<Shell
isPublic
large
hideHeadingOnMobile
title="Apps Store"
description="Connecting people, technology and the workplace.">
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-normal md:px-8 lg:px-0 lg:pt-0">
<Link
href="/apps"
className="text-emphasis inline-flex items-center justify-start gap-1 rounded-sm py-2">
<Icon name="arrow-left" className="h-4 w-4" />
{isLocaleReady ? t("app_store") : <SkeletonText className="h-6 w-24" />}{" "}
</Link>
</div>
<div className="mb-16">
<div className="grid h-auto w-full grid-cols-5 gap-3">
{categories.map((category) => (
<Link
key={category.name}
href={`/apps/categories/${category.name}`}
data-testid={`app-store-category-${category.name}`}
className="bg-subtle relative flex rounded-sm px-6 py-4 sm:block">
<div className="self-center">
<h3 className="font-medium capitalize">{category.name}</h3>
<p className="text-subtle text-sm">
{t("number_apps", { count: category.count })}{" "}
<Icon name="arrow-right" className="inline-block h-4 w-4" />
</p>
</div>
</Link>
))}
</div>
</div>
</Shell>
);
}
Apps.PageWrapper = PageWrapper;
const Page = (props: PageProps) => <Apps {...props} />;
Page.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps };
+6 -107
View File
@@ -1,116 +1,15 @@
"use client";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { HorizontalTabItemProps } from "@calcom/ui";
import {
AllApps,
AppStoreCategories,
HorizontalTabs,
TextField,
PopularAppsSlider,
RecentAppsSlider,
} from "@calcom/ui";
import { Icon } from "@calcom/ui";
import { getServerSideProps } from "@lib/apps/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import AppsLayout from "@components/apps/layouts/AppsLayout";
const tabs: HorizontalTabItemProps[] = [
{
name: "app_store",
href: "/apps",
},
{
name: "installed_apps",
href: "/apps/installed",
},
];
import type { PageProps } from "~/apps/apps-view";
import Apps, { LayoutWrapper } from "~/apps/apps-view";
function AppsSearch({
onChange,
className,
}: {
onChange: ChangeEventHandler<HTMLInputElement>;
className?: string;
}) {
const { t } = useLocale();
return (
<TextField
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
addOnLeading={<Icon name="search" className="text-subtle h-4 w-4" />}
addOnClassname="!border-muted"
containerClassName={classNames("focus:!ring-offset-0 m-1", className)}
type="search"
autoComplete="false"
onChange={onChange}
placeholder={t("search")}
/>
);
}
const Page = (props: PageProps) => <Apps {...props} />;
export default function Apps({
categories,
appStore,
userAdminTeams,
}: Omit<inferSSRProps<typeof getServerSideProps>, "trpcState">) {
const { t } = useLocale();
const [searchText, setSearchText] = useState<string | undefined>(undefined);
return (
<AppsLayout
isPublic
heading={t("app_store")}
subtitle={t("app_store_description")}
actions={(className) => (
<div className="flex w-full flex-col pt-4 md:flex-row md:justify-between md:pt-0 lg:w-auto">
<div className="ltr:mr-2 rtl:ml-2 lg:hidden">
<HorizontalTabs tabs={tabs} />
</div>
<div>
<AppsSearch className={className} onChange={(e) => setSearchText(e.target.value)} />
</div>
</div>
)}
headerClassName="sm:hidden lg:block hidden"
emptyStore={!appStore.length}>
<div className="flex flex-col gap-y-8">
{!searchText && (
<>
<AppStoreCategories categories={categories} />
<PopularAppsSlider items={appStore} />
<RecentAppsSlider items={appStore} />
</>
)}
<AllApps
apps={appStore}
searchText={searchText}
categories={categories.map((category) => category.name)}
userAdminTeams={userAdminTeams}
/>
</div>
</AppsLayout>
);
}
Page.PageWrapper = PageWrapper;
Page.getLayout = LayoutWrapper;
export { getServerSideProps };
Apps.PageWrapper = PageWrapper;
Apps.getLayout = (page: React.ReactElement) => {
return (
<Shell
title="Apps Store"
description="Create forms to direct attendees to the correct destinations."
withoutMain={true}
hideHeadingOnMobile>
{page}
</Shell>
);
};
export default Page;
@@ -1,598 +1,12 @@
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 useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types";
import { isConferencing as isConferencingApp } from "@calcom/app-store/utils";
import type { LocationObject } from "@calcom/core/location";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
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<
EventTypeModel,
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
> & {
selected: boolean;
locations: LocationFormValues["locations"];
bookingFields?: LocationFormValues["bookingFields"];
};
import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view";
import StepView from "~/apps/installation/[[...step]]/step-view";
export type TEventTypesForm = {
eventTypes: TEventType[];
};
const Page = (props: OnboardingPageProps) => <StepView {...props} />;
const STEPS = [
AppOnboardingSteps.ACCOUNTS_STEP,
AppOnboardingSteps.EVENT_TYPES_STEP,
AppOnboardingSteps.CONFIGURE_STEP,
] as const;
Page.PageWrapper = PageWrapper;
type StepType = (typeof STEPS)[number];
export { getServerSideProps } from "@lib/apps/installation/[[...step]]/getServerSideProps";
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;
showEventTypesStep: boolean;
isConferencing: boolean;
installableOnTeams: boolean;
};
type TUpdateObject = {
id: number;
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
bookingFields?: z.infer<typeof eventTypeBookingFields>;
locations?: LocationObject[];
};
const OnboardingPage = ({
step,
teams,
personalAccount,
appMetadata,
eventTypes,
userName,
credentialId,
showEventTypesStep,
isConferencing,
installableOnTeams,
}: 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: installableOnTeams ? 2 : 1,
},
[AppOnboardingSteps.CONFIGURE_STEP]: {
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
getDescription: () => `${t("configure_app_description")}`,
stepNumber: installableOnTeams ? 3 : 2,
},
} as const;
const [configureStep, setConfigureStep] = 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 maxSteps = useMemo(() => {
if (!showEventTypesStep) {
return 1;
}
return installableOnTeams ? STEPS.length : STEPS.length - 1;
}, [showEventTypesStep, installableOnTeams]);
const utils = trpc.useContext();
const formPortalRef = useRef<HTMLDivElement>(null);
const formMethods = useForm<TEventTypesForm>({
defaultValues: {
eventTypes,
},
});
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
if (data?.setupPending) return;
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
useEffect(() => {
eventTypes && formMethods.setValue("eventTypes", eventTypes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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) => {
mutation.mutate({
type: appMetadata.type,
variant: appMetadata.variant,
slug: appMetadata.slug,
...(teamId && { teamId }),
// for oAuth apps
...(showEventTypesStep && {
returnTo:
WEBAPP_URL +
getAppOnboardingUrl({
slug: appMetadata.slug,
teamId,
step: AppOnboardingSteps.EVENT_TYPES_STEP,
}),
}),
});
};
const handleSetUpLater = () => {
router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`);
};
return (
<div
key={pathname}
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen px-4"
data-testid="onboarding">
<Head>
<title>
{t("install")} {appMetadata?.name ?? ""}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto py-6 sm:px-4 md:py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]" ref={formPortalRef}>
<Form
form={formMethods}
id="outer-event-type-form"
handleSubmit={async (values) => {
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<typeof EventTypeMetaDataSchema>)
)
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"));
}
let updateObject: TUpdateObject = { id: value.id };
if (isConferencing) {
updateObject = {
...updateObject,
locations: value.locations,
bookingFields: value.bookingFields ? value.bookingFields : undefined,
};
} else {
updateObject = {
...updateObject,
metadata: value.metadata,
};
}
return updateMutation.mutateAsync(updateObject);
});
try {
await Promise.all(mutationPromises);
router.push("/event-types");
} catch (err) {
console.error(err);
}
}}>
<StepHeader
title={stepObj.getTitle(appMetadata.name)}
subtitle={stepObj.getDescription(appMetadata.name)}>
<Steps maxSteps={maxSteps} currentStep={stepObj.stepNumber} disableNavigation />
</StepHeader>
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
<AccountsStepCard
teams={teams}
personalAccount={personalAccount}
onSelect={handleSelectAccount}
loading={mutation.isPending}
installableOnTeams={installableOnTeams}
/>
)}
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
eventTypes &&
Boolean(eventTypes?.length) && (
<EventTypesStepCard
setConfigureStep={setConfigureStep}
userName={userName}
handleSetUpLater={handleSetUpLater}
/>
)}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && (
<ConfigureStepCard
slug={appMetadata.slug}
categories={appMetadata.categories}
credentialId={credentialId}
userName={userName}
loading={updateMutation.isPending}
formPortalRef={formPortalRef}
setConfigureStep={setConfigureStep}
eventTypes={eventTypes}
handleSetUpLater={handleSetUpLater}
isConferencing={isConferencing}
/>
)}
</Form>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
};
// 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,
avatarUrl: true,
name: true,
username: true,
teams: {
where: {
accepted: true,
team: {
members: {
some: {
userId,
role: {
in: ["ADMIN", "OWNER"],
},
},
},
},
},
select: {
team: {
select: {
id: true,
name: true,
logoUrl: true,
parent: {
select: {
logoUrl: true,
name: true,
},
},
},
},
},
},
},
});
if (!user) {
throw new Error(ERROR_MESSAGES.userNotFound);
}
const teams = user.teams.map(({ team }) => ({
...team,
logoUrl: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
}));
return {
...user,
teams,
};
};
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,
locations: true,
userId: true,
destinationCalendar: true,
bookingFields: true,
},
/**
* filter out managed events for now
* @todo: can install apps to managed event types
*/
where: teamId ? { teamId } : { userId, parent: null, 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,
locations: item.locations as unknown as LocationObject[],
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
}));
};
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 extendsEventType = appMetadata?.extendsFeature === "EventType";
const isConferencing = isConferencingApp(appMetadata.categories);
const showEventTypesStep = extendsEventType || isConferencing;
console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep);
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
const user = await getUser(session.user.id);
const userTeams = user.teams;
const hasTeams = Boolean(userTeams.length);
const appInstalls = await getAppInstallsBySlug(
parsedAppSlug,
user.id,
userTeams.map(({ id }) => id)
);
if (parsedTeamIdParam) {
const isUserMemberOfTeam = userTeams.some((team) => team.id === parsedTeamIdParam);
if (!isUserMemberOfTeam) {
throw new Error(ERROR_MESSAGES.userNotInTeam);
}
}
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
if (!showEventTypesStep) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
if (isConferencing) {
const destinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
userId: user.id,
eventTypeId: null,
},
});
for (let index = 0; index < eventTypes.length; index++) {
let eventType = eventTypes[index];
if (!eventType.destinationCalendar) {
eventType = { ...eventType, destinationCalendar };
}
eventTypes[index] = eventType;
}
}
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,
avatarUrl: user.avatarUrl,
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
};
const teamsWithIsAppInstalled = hasTeams
? userTeams.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,
showEventTypesStep,
step: parsedStepParam,
teams: teamsWithIsAppInstalled,
personalAccount,
eventTypes,
teamId: parsedTeamIdParam ?? null,
userName: user.username,
credentialId,
isConferencing,
// conferencing apps dont support team install
installableOnTeams: !isConferencing,
} as OnboardingPageProps,
};
} catch (err) {
console.log("eerrerrerrerrerrerrerrerrrr: ", 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;
export default Page;
+5 -157
View File
@@ -1,164 +1,12 @@
"use client";
import { useReducer } from "react";
import getAppCategoryTitle from "@calcom/app-store/_utils/getAppCategoryTitle";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { Icon } from "@calcom/ui";
import { AppSkeletonLoader as SkeletonLoader, Button, EmptyScreen, ShellSubHeading } from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
import type { querySchemaType } from "@lib/apps/installed/[category]/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import { AppList } from "@components/apps/AppList";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
interface IntegrationsContainerProps {
variant?: AppCategories;
exclude?: AppCategories[];
handleDisconnect: (credentialId: number) => void;
}
import type { PageProps } from "~/apps/installed/[category]/installed-category-view";
import InstalledApps from "~/apps/installed/[category]/installed-category-view";
const IntegrationsContainer = ({
variant,
exclude,
handleDisconnect,
}: IntegrationsContainerProps): JSX.Element => {
const { t } = useLocale();
const query = trpc.viewer.integrations.useQuery({
variant,
exclude,
onlyInstalled: true,
includeTeamInstalledApps: true,
});
const Page = (props: PageProps) => <InstalledApps {...props} />;
// TODO: Refactor and reuse getAppCategories?
const emptyIcon: Record<AppCategories, React.ComponentProps<typeof Icon>["name"]> = {
calendar: "calendar",
conferencing: "video",
automation: "share-2",
analytics: "chart-bar",
payment: "credit-card",
other: "grid-3x3",
web3: "credit-card", // deprecated
video: "video", // deprecated
messaging: "mail",
crm: "contact",
};
return (
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
if (!data.items.length) {
const emptyHeaderCategory = getAppCategoryTitle(variant || "other", true);
return (
<EmptyScreen
Icon={emptyIcon[variant || "other"]}
headline={t("no_category_apps", {
category: emptyHeaderCategory,
})}
description={t(`no_category_apps_description_${variant || "other"}`)}
buttonRaw={
<Button
color="secondary"
data-testid={`connect-${variant || "other"}-apps`}
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
{t(`connect_${variant || "other"}_apps`)}
</Button>
}
/>
);
}
return (
<div className="border-subtle rounded-md border p-7">
<ShellSubHeading
title={t(variant || "other")}
subtitle={t(`installed_app_${variant || "other"}_description`)}
className="mb-6"
actions={
<Button
data-testid="add-apps"
href={variant ? `/apps/categories/${variant}` : "/apps"}
color="secondary"
StartIcon="plus">
{t("add")}
</Button>
}
/>
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
</div>
);
}}
/>
);
};
type ModalState = {
isOpen: boolean;
credentialId: null | number;
teamId?: number;
};
export default function InstalledApps() {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const category = searchParams?.get("category") as querySchemaType["category"];
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
// Exclude calendar and other from categoryList, we handle those slightly differently below
return !(category in { other: null, calendar: null });
});
const [data, updateData] = useReducer(
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
{
isOpen: false,
credentialId: null,
}
);
const handleModelClose = () => {
updateData({ isOpen: false, credentialId: null });
};
const handleDisconnect = (credentialId: number, teamId?: number) => {
updateData({ isOpen: true, credentialId, teamId });
};
return (
<>
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
{categoryList.includes(category) && (
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
)}
{category === "calendar" && <CalendarListContainer />}
{category === "other" && (
<IntegrationsContainer
handleDisconnect={handleDisconnect}
variant={category}
exclude={[...categoryList, "calendar"]}
/>
)}
</InstalledAppsLayout>
<DisconnectIntegrationModal
handleModelClose={handleModelClose}
isOpen={data.isOpen}
credentialId={data.credentialId}
teamId={data.teamId}
/>
</>
);
}
Page.PageWrapper = PageWrapper;
export { getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps";
InstalledApps.PageWrapper = PageWrapper;
export default Page;
+4 -2
View File
@@ -1,8 +1,6 @@
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: ["repository/eventType"] });
export class AppRepository {
static async seedApp(dirName: string, keys?: any) {
const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata];
@@ -21,4 +19,8 @@ export class AppRepository {
},
});
}
static async findAppStore() {
return await prisma.app.findMany({ select: { slug: true } });
}
}