* 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 <hello@leog.me> Co-authored-by: Jeroen Reumkens <jeroen.reumkens@deptagency.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Leo Giovanetti <hello@leog.me> Co-authored-by: Peer Richelsen <peer@cal.com>
This commit is contained in:
co-authored by
Jeroen Reumkens
Peer Richelsen
Leo Giovanetti
Peer Richelsen
parent
fb3c481235
commit
3da3e6ec38
@@ -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<typeof App>[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 (
|
||||
<>
|
||||
<div className="px-4 pb-3 pt-3 md:px-8 lg:px-0 lg:pt-0">
|
||||
<Link href="/apps">
|
||||
<a className="text-md hover:text-brand-400 inline-flex rounded-sm py-2 font-semibold text-gray-900">
|
||||
<Icon.FiArrowLeft className="mr-2 h-6 w-6" /> {t("app_store")}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasImages && (
|
||||
<div className="flex-2 mb-4 -ml-4 -mr-4 flex w-auto snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:rounded-md">
|
||||
{images &&
|
||||
images.map((img) => (
|
||||
<img
|
||||
key={img}
|
||||
src={img}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 max-h-80 max-w-[90%] snap-center rounded-md last:mb-0 lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"sticky top-0 max-w-xl flex-1 pb-12 text-sm lg:pb-0",
|
||||
hasImages && "lg:ml-8"
|
||||
)}>
|
||||
<div className="mb-8 flex">
|
||||
<header>
|
||||
<div className="mb-4 flex items-center">
|
||||
<span className="block rounded-sm bg-gray-100 p-2">
|
||||
<img className="min-h-16 min-w-16 h-16 w-16" src={logo} alt={name} />
|
||||
</span>
|
||||
<h1 className="font-cal ml-4 text-3xl text-gray-900">{name}</h1>
|
||||
{isProOnly && user?.plan === "FREE" ? (
|
||||
<Badge className="ml-2" variant="default">
|
||||
PRO
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h2 className="text-sm text-gray-600">
|
||||
<span className="bg-gray-100 p-1 text-xs capitalize text-gray-800">{categories[0]}</span> •{" "}
|
||||
{t("published_by", { author })}
|
||||
</h2>
|
||||
</header>
|
||||
</div>
|
||||
{!isLoading ? (
|
||||
isGlobal || (installedAppCount > 0 && allowedMultipleInstalls) ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
|
||||
{installedAppCount > 0
|
||||
? t("active_install", { count: installedAppCount })
|
||||
: t("globally_install")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
StartIcon={Icon.FiPlus}
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base"
|
||||
data-testid="install-app-button">
|
||||
{t("add_another")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : installedAppCount > 0 ? (
|
||||
<Button color="secondary" disabled title="App already installed">
|
||||
{t("installed")}
|
||||
</Button>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<SkeletonButton width="24" height="10" />
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<span className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="mt-8 space-x-2">{body}</div>
|
||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||
<span>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h4 className="mt-8 mb-2 font-semibold text-gray-900 ">{t("learn_more")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={docs}>
|
||||
<Icon.FiBookOpen className="mr-1 -mt-1 inline h-4 w-4" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={website}>
|
||||
<Icon.FiExternalLink className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<Icon.FiMail className="mr-1 -mt-px inline h-4 w-4" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={tos}>
|
||||
<Icon.FiFile className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<Icon.FiShield className="mr-1 -mt-px inline h-4 w-4" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-8" />
|
||||
<span className="leading-1 block text-xs text-gray-500">
|
||||
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.
|
||||
</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<Icon.FiFlag className="inline h-3 w-3" /> Report App
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Shell large isPublic>
|
||||
{props.licenseRequired ? (
|
||||
<LicenseRequired>
|
||||
<Component {...props} />
|
||||
</LicenseRequired>
|
||||
) : (
|
||||
<Component {...props} />
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"]) => (
|
||||
<Link href={href}>
|
||||
<a {...otherProps} />
|
||||
</Link>
|
||||
),
|
||||
img: ({ src = "", alt = "", placeholder, ...rest }: JSX.IntrinsicElements["img"]) => (
|
||||
<Image src={src} alt={alt} {...rest} />
|
||||
),
|
||||
// @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<typeof getStaticProps>) {
|
||||
return (
|
||||
<App
|
||||
name={data.name}
|
||||
isGlobal={data.isGlobal}
|
||||
type={data.type}
|
||||
logo={data.logo}
|
||||
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}
|
||||
isProOnly={data.isProOnly}
|
||||
images={source?.scope?.items as string[] | undefined}
|
||||
// tos="https://zoom.us/terms"
|
||||
// privacy="https://zoom.us/privacy"
|
||||
body={<MDXRemote {...source} components={components} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user