From 3da3e6ec38ced3a39bd1d61899ffe18bc3302b59 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Tue, 30 Aug 2022 11:37:40 +0200 Subject: [PATCH] feat: Adds new v2 design for app detail page. Fixes #3779 (#3997) * feat: Adds new v2 design for app detail page. Fixes #3779 * fix: Made app images 90% width on mobile to show indication for scrolling. #3779 * Update apps/web/components/v2/apps/App.tsx Co-authored-by: Leo Giovanetti Co-authored-by: Jeroen Reumkens Co-authored-by: Peer Richelsen Co-authored-by: Leo Giovanetti Co-authored-by: Peer Richelsen --- apps/web/components/v2/apps/App.tsx | 337 ++++++++++++++++++++++++ apps/web/middleware.ts | 3 + apps/web/pages/v2/apps/[slug]/index.tsx | 105 ++++++++ 3 files changed, 445 insertions(+) create mode 100644 apps/web/components/v2/apps/App.tsx create mode 100644 apps/web/pages/v2/apps/[slug]/index.tsx diff --git a/apps/web/components/v2/apps/App.tsx b/apps/web/components/v2/apps/App.tsx new file mode 100644 index 0000000000..59b0fdd69b --- /dev/null +++ b/apps/web/components/v2/apps/App.tsx @@ -0,0 +1,337 @@ +import classNames from "@calcom/lib/classNames"; +import Link from "next/link"; +import React, { useEffect, useState } from "react"; + +import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation"; +import { InstallAppButton } from "@calcom/app-store/components"; +import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; +import { trpc } from "@calcom/trpc/react"; +import { App as AppType } from "@calcom/types/App"; +import Badge from "@calcom/ui/Badge"; +import { Icon } from "@calcom/ui/Icon"; +import { Button, SkeletonButton, Shell } from "@calcom/ui/v2"; + +const Component = ({ + name, + type, + logo, + body, + categories, + author, + price = 0, + commission, + isGlobal = false, + feeType, + docs, + website, + email, + tos, + privacy, + isProOnly, + images, +}: Parameters[0]) => { + const { t } = useLocale(); + const { data: user } = trpc.useQuery(["viewer.me"]); + const hasImages = images && images.length > 0; + + const mutation = useAddAppMutation(null, { + onSuccess: () => { + showToast("App successfully installed", "success"); + }, + onError: (error) => { + if (error instanceof Error) showToast(error.message || "App could not be installed", "error"); + }, + }); + + const priceInDollar = Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + useGrouping: false, + }).format(price); + const [installedAppCount, setInstalledAppCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + useEffect(() => { + async function getInstalledApp(appCredentialType: string) { + const queryParam = new URLSearchParams(); + queryParam.set("app-credential-type", appCredentialType); + try { + const result = await fetch(`/api/app-store/installed?${queryParam.toString()}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }).then((data) => { + setIsLoading(false); + return data; + }); + if (result.status === 200) { + const res = await result.json(); + setInstalledAppCount(res.count); + } + } catch (error) { + if (error instanceof Error) { + console.log(error.message); + } + } + } + getInstalledApp(type); + }, [type]); + const allowedMultipleInstalls = categories.indexOf("calendar") > -1; + + return ( + <> + + +
+ {hasImages && ( +
+ {images && + images.map((img) => ( + {`Screenshot + ))} +
+ )} +
+
+
+
+ + {name} + +

{name}

+ {isProOnly && user?.plan === "FREE" ? ( + + PRO + + ) : null} +
+

+ {categories[0]} •{" "} + {t("published_by", { author })} +

+
+
+ {!isLoading ? ( + isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? ( +
+ + {!isGlobal && ( + { + if (useDefaultComponent) { + props = { + onClick: () => { + mutation.mutate({ type }); + }, + loading: mutation.isLoading, + }; + } + return ( + + ); + }} + /> + )} +
+ ) : installedAppCount > 0 ? ( + + ) : ( + { + if (useDefaultComponent) { + props = { + onClick: () => { + mutation.mutate({ type }); + }, + loading: mutation.isLoading, + }; + } + return ( + + ); + }} + /> + ) + ) : ( + + )} + {price !== 0 && ( + + {feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar} + {feeType === "monthly" && "/" + t("month")} + + )} + +
{body}
+

{t("pricing")}

+ + {price === 0 ? ( + "Free" + ) : ( + <> + {Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + useGrouping: false, + }).format(price)} + {feeType === "monthly" && "/" + t("month")} + + )} + + +

{t("learn_more")}

+ +
+ + Every app published on the Cal.com App Store is open source and thoroughly tested via peer + reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are + published by Cal.com. If you encounter inappropriate content or behaviour please report it. + + + Report App + +
+
+ + ); +}; + +export default function App(props: { + name: string; + type: AppType["type"]; + isGlobal?: AppType["isGlobal"]; + logo: string; + body: React.ReactNode; + categories: string[]; + author: string; + pro?: boolean; + price?: number; + commission?: number; + feeType?: AppType["feeType"]; + docs?: string; + website?: string; + email: string; // required + tos?: string; + privacy?: string; + licenseRequired: AppType["licenseRequired"]; + isProOnly: AppType["isProOnly"]; + images?: string[]; +}) { + return ( + + {props.licenseRequired ? ( + + + + ) : ( + + )} + + ); +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index e3dcc4ad23..ccf4dc07ac 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -11,6 +11,9 @@ const V2_WHITELIST = [ "/availability", "/bookings", "/event-types", + // Apps contains trailing slash to prevent app overview from being rendered as v2, + // since it doesn't exist yet. + "/apps/", ]; const middleware: NextMiddleware = async (req) => { diff --git a/apps/web/pages/v2/apps/[slug]/index.tsx b/apps/web/pages/v2/apps/[slug]/index.tsx new file mode 100644 index 0000000000..f2a4cbc925 --- /dev/null +++ b/apps/web/pages/v2/apps/[slug]/index.tsx @@ -0,0 +1,105 @@ +import fs from "fs"; +import matter from "gray-matter"; +import { GetStaticPaths, GetStaticPropsContext } from "next"; +import { MDXRemote } from "next-mdx-remote"; +import { serialize } from "next-mdx-remote/serialize"; +import Image from "next/image"; +import Link from "next/link"; +import path from "path"; + +import { getAppWithMetadata } from "@calcom/app-store/_appRegistry"; +import prisma from "@calcom/prisma"; + +import { inferSSRProps } from "@lib/types/inferSSRProps"; + +import App from "@components/v2/apps/App"; + +const components = { + a: ({ href = "", ...otherProps }: JSX.IntrinsicElements["a"]) => ( + + + + ), + img: ({ src = "", alt = "", placeholder, ...rest }: JSX.IntrinsicElements["img"]) => ( + {alt} + ), + // @TODO: In v2 the slider isn't shown anymore. However, to ensure the v1 pages keep + // working, this component is still rendered in the MDX content. To skip them in the v2 + // content we have to render null here. In v2 the gallery is shown by directly + // using the `items` property from the MDX's meta data. + Slider: () => <>, +}; + +function SingleAppPage({ data, source }: inferSSRProps) { + return ( + } + /> + ); +} + +export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => { + const appStore = await prisma.app.findMany({ select: { slug: true } }); + const paths = appStore.map(({ slug }) => ({ params: { slug } })); + + return { + paths, + fallback: false, + }; +}; + +export const getStaticProps = async (ctx: GetStaticPropsContext) => { + if (typeof ctx.params?.slug !== "string") return { notFound: true }; + + const app = await prisma.app.findUnique({ + where: { slug: ctx.params.slug }, + }); + + if (!app) return { notFound: true }; + + const singleApp = await getAppWithMetadata(app); + + if (!singleApp) return { notFound: true }; + + const appDirname = app.dirName; + const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/README.mdx`); + const postFilePath = path.join(README_PATH); + let source = ""; + + try { + /* If the app doesn't have a README we fallback to the package description */ + source = fs.readFileSync(postFilePath).toString(); + } catch (error) { + console.log(`No README.mdx provided for: ${appDirname}`); + source = singleApp.description; + } + + const { content, data } = matter(source); + const mdxSource = await serialize(content, { scope: data }); + + return { + props: { + source: mdxSource, + data: singleApp, + }, + }; +}; + +export default SingleAppPage;