feat(website): add public apps marketplace with one-click install (#22611)
<img width="1335" height="570" alt="image" src="https://github.com/user-attachments/assets/85bbe656-7525-4800-9ae6-bc59445ccf48" /> <img width="1512" height="723" alt="image" src="https://github.com/user-attachments/assets/6494c539-a3e0-4e95-9df5-ee7559c098ab" /> <img width="1512" height="813" alt="image" src="https://github.com/user-attachments/assets/4cb6adea-d35b-470a-9bfd-d47b2a514e8c" /> <img width="1512" height="739" alt="image" src="https://github.com/user-attachments/assets/757ea3e0-bff9-4482-9a71-3e75949fd7e7" /> <img width="1468" height="790" alt="image" src="https://github.com/user-attachments/assets/37365c56-47ae-4f08-a40f-32e5600a6c0f" /> ## What Adds a public `/apps` marketplace on twenty-website listing the vetted, Twenty-built apps. Each app card and detail page has a one-click Install button that deep-links to the in-app available-application page (`app.twenty.com/settings/applications/available/:universalIdentifier`), where authentication and permission consent are handled before installing. - New apps directory (`/apps`) and per-app detail pages (`/apps/[slug]`), with a category filter - Routing/sitemap entry for `/apps` and an Apps link in the Resources menu - The catalog is fetched from the Twenty GraphQL API (public `publicMarketplaceApps` / `publicMarketplaceAppDetail` queries), defaulting to `api.twenty.com`, and degrades to an empty state on failure. No app info is duplicated in the website; logos and screenshots come from the catalog's CDN URLs. ## Dependency This is the website half of the split. It consumes the public queries added in the server PR #22647, which should merge first. --------- Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com> Co-authored-by: martmull <martin@twenty.com> Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com> Co-authored-by: Aressand <97886962+Aressand@users.noreply.github.com> Co-authored-by: Brahm Lower <bplower@gmail.com> Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Parship Chowdhury <parshipchowdhury@gmail.com> Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Pratik Mahajan <Pratik@mahajan.xyz> Co-authored-by: neo773 <neo773@protonmail.com> Co-authored-by: Deepak kumar maharana <100968930+deep231w@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
This commit is contained in:
@@ -13,6 +13,11 @@ PARTNER_APPLICATION_SECRET=
|
||||
TWENTY_PARTNERS_API_URL=
|
||||
TWENTY_PARTNERS_API_KEY=
|
||||
|
||||
# Apps marketplace — the apps directory reads the vetted marketplace catalog
|
||||
# from the Twenty GraphQL API (public publicMarketplaceApps query), server-side.
|
||||
# Defaults to https://api.twenty.com when unset.
|
||||
TWENTY_MARKETPLACE_API_URL=
|
||||
|
||||
# Stripe — self-hosted enterprise checkout & subscription APIs.
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { AppDetail, fetchMarketplaceAppDetailBySlug } from '@/apps-marketplace';
|
||||
import { getCommunityStats } from '@/platform/community';
|
||||
import { getRouteI18n } from '@/platform/i18n/get-route-i18n';
|
||||
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
|
||||
import { resolveLocaleParam } from '@/platform/i18n/resolve-locale-param';
|
||||
import { buildBreadcrumbListJsonLd, JsonLd } from '@/platform/seo';
|
||||
import { Menu } from '@/sections/menu';
|
||||
|
||||
type AppParams = { locale: string; slug: string };
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<AppParams>;
|
||||
}): Promise<Metadata> {
|
||||
await getRouteI18n(params);
|
||||
const i18n = getServerI18n();
|
||||
const { slug } = await params;
|
||||
const app = await fetchMarketplaceAppDetailBySlug(slug);
|
||||
|
||||
if (app === null) {
|
||||
return { title: i18n._(msg`App not found — Twenty`) };
|
||||
}
|
||||
|
||||
return {
|
||||
title: i18n._(msg`${app.name} — Twenty Apps`),
|
||||
description: app.tagline,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AppDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<AppParams>;
|
||||
}) {
|
||||
const [, communityStats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
getCommunityStats(),
|
||||
]);
|
||||
const { locale: rawLocale, slug } = await params;
|
||||
const locale = resolveLocaleParam(rawLocale);
|
||||
const app = await fetchMarketplaceAppDetailBySlug(slug);
|
||||
|
||||
if (app === null) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={buildBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Apps', path: '/apps' },
|
||||
{ name: app.name, path: `/apps/${slug}` },
|
||||
],
|
||||
locale,
|
||||
)}
|
||||
/>
|
||||
<Menu communityStats={communityStats} scheme="light" />
|
||||
<main>
|
||||
<AppDetail app={app} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AppsMarketplaceClient,
|
||||
AppsMarketplaceHeader,
|
||||
fetchMarketplaceApps,
|
||||
} from '@/apps-marketplace';
|
||||
import { getCommunityStats } from '@/platform/community';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/platform/i18n/get-route-i18n';
|
||||
import { resolveLocaleParam } from '@/platform/i18n/resolve-locale-param';
|
||||
import {
|
||||
buildBreadcrumbListJsonLd,
|
||||
buildRouteMetadata,
|
||||
JsonLd,
|
||||
} from '@/platform/seo';
|
||||
import { Menu } from '@/sections/menu';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('apps');
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AppsMarketplacePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
}) {
|
||||
const [, communityStats, apps] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
getCommunityStats(),
|
||||
fetchMarketplaceApps(),
|
||||
]);
|
||||
const locale = resolveLocaleParam((await params).locale);
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={buildBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Apps', path: '/apps' },
|
||||
],
|
||||
locale,
|
||||
)}
|
||||
/>
|
||||
<Menu communityStats={communityStats} scheme="light" />
|
||||
<main>
|
||||
<AppsMarketplaceHeader />
|
||||
<AppsMarketplaceClient apps={apps} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { type CSSProperties } from 'react';
|
||||
|
||||
import { LocalizedLink } from '@/platform/i18n/LocalizedLink';
|
||||
import {
|
||||
color,
|
||||
EASING,
|
||||
FONT_WEIGHT,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
radius,
|
||||
REDUCED_MOTION,
|
||||
semanticColor,
|
||||
SHADOW,
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
import { Button } from '@/ui';
|
||||
|
||||
import { AppLogo } from './AppLogo';
|
||||
import { buildAppInstallUrl } from './build-app-install-url';
|
||||
import { type MarketplaceApp } from './marketplace-app';
|
||||
|
||||
type AppCardStyle = CSSProperties & {
|
||||
'--app-card-index': number;
|
||||
};
|
||||
|
||||
const CardArticle = styled.article`
|
||||
@keyframes appCardEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 18px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
animation: appCardEnter 700ms ${EASING.standard} both;
|
||||
animation-delay: calc(var(--app-card-index) * 90ms + 180ms);
|
||||
background-color: ${color('white')};
|
||||
border: 1px solid ${semanticColor.line};
|
||||
border-radius: ${radius(2)};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(5)};
|
||||
isolation: isolate;
|
||||
padding: ${spacing(6)};
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
transform 0.25s ease;
|
||||
will-change: transform;
|
||||
|
||||
&:hover {
|
||||
border-color: ${semanticColor.lineStrong};
|
||||
box-shadow: ${SHADOW.card};
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
${REDUCED_MOTION} {
|
||||
animation: none;
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${spacing(4)};
|
||||
`;
|
||||
|
||||
const HeaderText = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
|
||||
& > * + * {
|
||||
margin-top: ${spacing(1)};
|
||||
}
|
||||
`;
|
||||
|
||||
const AppName = styled.h3`
|
||||
color: ${semanticColor.ink};
|
||||
font-family: ${fontFamily('serif')};
|
||||
font-size: ${fontSize(6)};
|
||||
font-weight: ${FONT_WEIGHT.light};
|
||||
letter-spacing: -0.02em;
|
||||
line-height: ${fontSize(7)};
|
||||
`;
|
||||
|
||||
const NameLink = styled(LocalizedLink)`
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&::after {
|
||||
border-radius: ${radius(2)};
|
||||
content: '';
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&:focus-visible::after {
|
||||
outline: 2px solid ${semanticColor.ink};
|
||||
outline-offset: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CategoryEyebrow = styled.span`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('mono')};
|
||||
font-size: ${fontSize(3)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
letter-spacing: 0.08em;
|
||||
line-height: ${fontSize(4)};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const Tagline = styled.p`
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
color: ${semanticColor.inkMuted};
|
||||
display: -webkit-box;
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(4)};
|
||||
line-height: ${fontSize(5.5)};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CtaRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${spacing(4)};
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const LearnMoreLink = styled(LocalizedLink)`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('mono')};
|
||||
font-size: ${fontSize(3)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${semanticColor.ink};
|
||||
}
|
||||
`;
|
||||
|
||||
type AppCardProps = {
|
||||
app: MarketplaceApp;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export function AppCard({ app, index }: AppCardProps) {
|
||||
const { i18n } = useLingui();
|
||||
const headingId = `app-card-heading-${app.slug}`;
|
||||
const style: AppCardStyle = { '--app-card-index': index };
|
||||
const detailHref = `/apps/${app.slug}`;
|
||||
|
||||
return (
|
||||
<CardArticle aria-labelledby={headingId} style={style}>
|
||||
<CardHeader>
|
||||
<AppLogo name={app.name} logoUrl={app.logoUrl} />
|
||||
<HeaderText>
|
||||
<AppName id={headingId}>
|
||||
<NameLink href={detailHref}>{app.name}</NameLink>
|
||||
</AppName>
|
||||
{app.category.length > 0 && (
|
||||
<CategoryEyebrow>{app.category}</CategoryEyebrow>
|
||||
)}
|
||||
</HeaderText>
|
||||
</CardHeader>
|
||||
|
||||
<Tagline>{app.tagline}</Tagline>
|
||||
|
||||
<CtaRow>
|
||||
<Button
|
||||
href={buildAppInstallUrl(app.universalIdentifier)}
|
||||
label={i18n._(msg`Install`)}
|
||||
variant="filled"
|
||||
size="small"
|
||||
/>
|
||||
<LearnMoreLink
|
||||
href={detailHref}
|
||||
aria-label={i18n._(msg`Learn more about ${app.name}`)}
|
||||
>
|
||||
{i18n._(msg`Learn more`)}
|
||||
</LearnMoreLink>
|
||||
</CtaRow>
|
||||
</CardArticle>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
|
||||
import {
|
||||
FONT_WEIGHT,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
mediaUp,
|
||||
radius,
|
||||
semanticColor,
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
import { Body, Button, ExternalLink, Heading, SectionShell } from '@/ui';
|
||||
|
||||
import { AppLogo } from './AppLogo';
|
||||
import { buildAppInstallUrl } from './build-app-install-url';
|
||||
import { type MarketplaceAppDetail } from './marketplace-app';
|
||||
|
||||
const HeroRow = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(6)};
|
||||
|
||||
${mediaUp('md')} {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: ${spacing(8)};
|
||||
}
|
||||
`;
|
||||
|
||||
const HeroText = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(3)};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const CategoryEyebrow = styled.span`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('mono')};
|
||||
font-size: ${fontSize(3)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
letter-spacing: 0.08em;
|
||||
line-height: ${fontSize(4)};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const HeroActions = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${spacing(4)};
|
||||
margin-top: ${spacing(2)};
|
||||
`;
|
||||
|
||||
const VettedBadge = styled.span`
|
||||
align-items: center;
|
||||
border: 1px solid ${semanticColor.line};
|
||||
border-radius: ${radius(4)};
|
||||
color: ${semanticColor.inkMuted};
|
||||
display: inline-flex;
|
||||
font-family: ${fontFamily('mono')};
|
||||
font-size: ${fontSize(2)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
gap: ${spacing(2)};
|
||||
letter-spacing: 0.06em;
|
||||
padding: ${spacing(1)} ${spacing(3)};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const ContentGrid = styled.div`
|
||||
display: grid;
|
||||
gap: ${spacing(8)};
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
${mediaUp('lg')} {
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
}
|
||||
`;
|
||||
|
||||
const MainColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(6)};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const ScreenshotList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(5)};
|
||||
`;
|
||||
|
||||
const Screenshot = styled.img`
|
||||
aspect-ratio: 16 / 10;
|
||||
background-color: ${semanticColor.surface};
|
||||
border: 1px solid ${semanticColor.line};
|
||||
border-radius: ${radius(2)};
|
||||
display: block;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Sidebar = styled.aside`
|
||||
align-self: flex-start;
|
||||
border: 1px solid ${semanticColor.line};
|
||||
border-radius: ${radius(2)};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(4)};
|
||||
padding: ${spacing(6)};
|
||||
`;
|
||||
|
||||
const DetailRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(1)};
|
||||
`;
|
||||
|
||||
const DetailLabel = styled.span`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('mono')};
|
||||
font-size: ${fontSize(2)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const DetailValue = styled.span`
|
||||
color: ${semanticColor.ink};
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(4)};
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const DetailLink = styled(ExternalLink)`
|
||||
color: ${semanticColor.ink};
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(4)};
|
||||
text-decoration: underline;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const Divider = styled.hr`
|
||||
background-color: ${semanticColor.line};
|
||||
border: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type AppDetailProps = {
|
||||
app: MarketplaceAppDetail;
|
||||
};
|
||||
|
||||
export function AppDetail({ app }: AppDetailProps) {
|
||||
const i18n = getServerI18n();
|
||||
const installUrl = buildAppInstallUrl(app.universalIdentifier);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionShell rhythm="hero" scheme="light">
|
||||
<HeroRow>
|
||||
<AppLogo
|
||||
name={app.name}
|
||||
logoUrl={app.logoUrl}
|
||||
size={96}
|
||||
loading="eager"
|
||||
/>
|
||||
<HeroText>
|
||||
{app.category.length > 0 && (
|
||||
<CategoryEyebrow>{app.category}</CategoryEyebrow>
|
||||
)}
|
||||
<Heading as="h1" size="lg" weight="light">
|
||||
{app.name}
|
||||
</Heading>
|
||||
<Body muted size="md">
|
||||
{app.tagline}
|
||||
</Body>
|
||||
<HeroActions>
|
||||
<Button
|
||||
href={installUrl}
|
||||
label={i18n._(msg`Install`)}
|
||||
variant="filled"
|
||||
/>
|
||||
<VettedBadge>{i18n._(msg`Vetted by Twenty`)}</VettedBadge>
|
||||
</HeroActions>
|
||||
</HeroText>
|
||||
</HeroRow>
|
||||
</SectionShell>
|
||||
|
||||
<SectionShell rhythm="section" scheme="light">
|
||||
<ContentGrid>
|
||||
<MainColumn>
|
||||
{app.screenshots.length > 0 && (
|
||||
<ScreenshotList>
|
||||
{app.screenshots.map((screenshot) => (
|
||||
<Screenshot
|
||||
key={screenshot}
|
||||
src={screenshot}
|
||||
alt={i18n._(msg`${app.name} screenshot`)}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</ScreenshotList>
|
||||
)}
|
||||
</MainColumn>
|
||||
|
||||
<Sidebar>
|
||||
<Button
|
||||
href={installUrl}
|
||||
label={i18n._(msg`Install on Twenty`)}
|
||||
variant="filled"
|
||||
/>
|
||||
<Divider aria-hidden="true" />
|
||||
<DetailRow>
|
||||
<DetailLabel>{i18n._(msg`Developer`)}</DetailLabel>
|
||||
<DetailValue>{app.author}</DetailValue>
|
||||
</DetailRow>
|
||||
{app.category.length > 0 && (
|
||||
<DetailRow>
|
||||
<DetailLabel>{i18n._(msg`Category`)}</DetailLabel>
|
||||
<DetailValue>{app.category}</DetailValue>
|
||||
</DetailRow>
|
||||
)}
|
||||
{app.sourcePackage !== undefined && (
|
||||
<DetailRow>
|
||||
<DetailLabel>{i18n._(msg`Package`)}</DetailLabel>
|
||||
<DetailValue>{app.sourcePackage}</DetailValue>
|
||||
</DetailRow>
|
||||
)}
|
||||
{app.websiteUrl !== undefined && (
|
||||
<DetailRow>
|
||||
<DetailLabel>{i18n._(msg`Website`)}</DetailLabel>
|
||||
<DetailLink href={app.websiteUrl}>{app.websiteUrl}</DetailLink>
|
||||
</DetailRow>
|
||||
)}
|
||||
</Sidebar>
|
||||
</ContentGrid>
|
||||
</SectionShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type CSSProperties } from 'react';
|
||||
|
||||
import {
|
||||
color,
|
||||
FONT_WEIGHT,
|
||||
fontFamily,
|
||||
radius,
|
||||
semanticColor,
|
||||
} from '@/tokens';
|
||||
|
||||
const LogoBlock = styled.span`
|
||||
align-items: center;
|
||||
background-color: ${color('white')};
|
||||
border: 1px solid ${semanticColor.line};
|
||||
border-radius: ${radius(1.5)};
|
||||
color: ${semanticColor.ink};
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
font-family: ${fontFamily('serif')};
|
||||
font-weight: ${FONT_WEIGHT.light};
|
||||
height: var(--app-logo-size);
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
width: var(--app-logo-size);
|
||||
`;
|
||||
|
||||
const LogoImage = styled.img`
|
||||
display: block;
|
||||
height: 62%;
|
||||
object-fit: contain;
|
||||
width: 62%;
|
||||
`;
|
||||
|
||||
type AppLogoStyle = CSSProperties & {
|
||||
'--app-logo-size': string;
|
||||
};
|
||||
|
||||
type AppLogoProps = {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
size?: number;
|
||||
loading?: 'lazy' | 'eager';
|
||||
};
|
||||
|
||||
const getInitial = (name: string): string =>
|
||||
name.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
export function AppLogo({
|
||||
name,
|
||||
logoUrl,
|
||||
size = 48,
|
||||
loading = 'lazy',
|
||||
}: AppLogoProps) {
|
||||
const style: AppLogoStyle = {
|
||||
'--app-logo-size': `${size}px`,
|
||||
fontSize: `${Math.round(size * 0.42)}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
<LogoBlock style={style}>
|
||||
{logoUrl !== undefined && logoUrl.length > 0 ? (
|
||||
<LogoImage src={logoUrl} alt={`${name} logo`} loading={loading} />
|
||||
) : (
|
||||
getInitial(name)
|
||||
)}
|
||||
</LogoBlock>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { mediaUp, spacing } from '@/tokens';
|
||||
|
||||
import { AppCard } from './AppCard';
|
||||
import { type MarketplaceApp } from './marketplace-app';
|
||||
|
||||
const CardGrid = styled.div`
|
||||
display: grid;
|
||||
gap: ${spacing(6)};
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
${mediaUp('md')} {
|
||||
gap: ${spacing(8)};
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
${mediaUp('lg')} {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
`;
|
||||
|
||||
type AppsGridProps = {
|
||||
apps: readonly MarketplaceApp[];
|
||||
};
|
||||
|
||||
export function AppsGrid({ apps }: AppsGridProps) {
|
||||
return (
|
||||
<CardGrid>
|
||||
{apps.map((app, index) => (
|
||||
<AppCard key={app.slug} app={app} index={index} />
|
||||
))}
|
||||
</CardGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
color,
|
||||
FONT_WEIGHT,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
radius,
|
||||
semanticColor,
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
import { Body, SectionShell } from '@/ui';
|
||||
|
||||
import { AppsGrid } from './AppsGrid';
|
||||
import { type MarketplaceApp } from './marketplace-app';
|
||||
|
||||
const FilterRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${spacing(2)};
|
||||
margin-bottom: ${spacing(6)};
|
||||
`;
|
||||
|
||||
const FilterChip = styled.button`
|
||||
background-color: ${color('white')};
|
||||
border: 1px solid ${semanticColor.line};
|
||||
border-radius: ${radius(4)};
|
||||
color: ${semanticColor.inkMuted};
|
||||
cursor: pointer;
|
||||
font-family: ${fontFamily('mono')};
|
||||
font-size: ${fontSize(3)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
letter-spacing: 0.04em;
|
||||
padding: ${spacing(2)} ${spacing(4)};
|
||||
text-transform: uppercase;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${semanticColor.lineStrong};
|
||||
color: ${semanticColor.ink};
|
||||
}
|
||||
|
||||
&[data-active='true'] {
|
||||
background-color: ${color('black')};
|
||||
border-color: ${color('black')};
|
||||
color: ${color('white')};
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyState = styled.div`
|
||||
padding-block: ${spacing(10)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const ALL_CATEGORIES = 'all';
|
||||
|
||||
type AppsMarketplaceClientProps = {
|
||||
apps: readonly MarketplaceApp[];
|
||||
};
|
||||
|
||||
export function AppsMarketplaceClient({ apps }: AppsMarketplaceClientProps) {
|
||||
const { i18n } = useLingui();
|
||||
const [activeCategory, setActiveCategory] = useState<string>(ALL_CATEGORIES);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const seen: string[] = [];
|
||||
|
||||
for (const app of apps) {
|
||||
if (app.category.length > 0 && !seen.includes(app.category)) {
|
||||
seen.push(app.category);
|
||||
}
|
||||
}
|
||||
|
||||
return seen.toSorted((a, b) => a.localeCompare(b));
|
||||
}, [apps]);
|
||||
|
||||
const visibleApps = useMemo(() => {
|
||||
const matching =
|
||||
activeCategory === ALL_CATEGORIES
|
||||
? apps
|
||||
: apps.filter((app) => app.category === activeCategory);
|
||||
|
||||
return matching.toSorted(
|
||||
(a, b) => Number(b.isFeatured) - Number(a.isFeatured),
|
||||
);
|
||||
}, [apps, activeCategory]);
|
||||
|
||||
return (
|
||||
<SectionShell rhythm="section" scheme="light">
|
||||
{apps.length === 0 ? (
|
||||
<EmptyState>
|
||||
<Body muted size="md">
|
||||
{i18n._(msg`No apps are available right now. Check back soon.`)}
|
||||
</Body>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{categories.length > 0 && (
|
||||
<FilterRow
|
||||
role="group"
|
||||
aria-label={i18n._(msg`Filter apps by category`)}
|
||||
>
|
||||
<FilterChip
|
||||
type="button"
|
||||
aria-pressed={activeCategory === ALL_CATEGORIES}
|
||||
data-active={activeCategory === ALL_CATEGORIES}
|
||||
onClick={() => setActiveCategory(ALL_CATEGORIES)}
|
||||
>
|
||||
{i18n._(msg`All`)}
|
||||
</FilterChip>
|
||||
{categories.map((category) => (
|
||||
<FilterChip
|
||||
key={category}
|
||||
type="button"
|
||||
aria-pressed={activeCategory === category}
|
||||
data-active={activeCategory === category}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
>
|
||||
{category}
|
||||
</FilterChip>
|
||||
))}
|
||||
</FilterRow>
|
||||
)}
|
||||
<AppsGrid apps={visibleApps} />
|
||||
</>
|
||||
)}
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
|
||||
import { mediaUp, spacing } from '@/tokens';
|
||||
import { Body, Eyebrow, Heading, SectionShell } from '@/ui';
|
||||
|
||||
const HeaderStack = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > * + * {
|
||||
margin-top: ${spacing(4)};
|
||||
}
|
||||
|
||||
${mediaUp('md')} {
|
||||
& > * + * {
|
||||
margin-top: ${spacing(6)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderBody = styled.div`
|
||||
max-width: 640px;
|
||||
`;
|
||||
|
||||
export function AppsMarketplaceHeader() {
|
||||
const i18n = getServerI18n();
|
||||
|
||||
return (
|
||||
<SectionShell rhythm="hero" scheme="light">
|
||||
<HeaderStack>
|
||||
<Eyebrow>{i18n._(msg`Marketplace`)}</Eyebrow>
|
||||
<Heading as="h1" size="lg" weight="light">
|
||||
{i18n._(msg`Apps for *Twenty*`)}
|
||||
</Heading>
|
||||
<HeaderBody>
|
||||
<Body muted size="md">
|
||||
{i18n._(
|
||||
msg`Extend your CRM with apps built and maintained by Twenty. Every app here is vetted by our team — install one in a click and it's live in your workspace.`,
|
||||
)}
|
||||
</Body>
|
||||
</HeaderBody>
|
||||
</HeaderStack>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const DEFAULT_TWENTY_APP_BASE_URL = 'https://app.twenty.com';
|
||||
|
||||
export const buildAppInstallUrl = (universalIdentifier: string): string => {
|
||||
const baseUrl =
|
||||
process.env.TWENTY_APP_BASE_URL ?? DEFAULT_TWENTY_APP_BASE_URL;
|
||||
|
||||
const returnToPath = `/settings/applications/available/${universalIdentifier}`;
|
||||
|
||||
return `${baseUrl.replace(/\/$/, '')}/?returnToPath=${encodeURIComponent(returnToPath)}`;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { fetchMarketplaceApps } from './fetch-marketplace-apps';
|
||||
import { getAppSlug } from './get-app-slug';
|
||||
import { marketplaceGraphqlRequest } from './marketplace-api-fetch';
|
||||
import { type MarketplaceAppDetail } from './marketplace-app';
|
||||
|
||||
const FIND_MARKETPLACE_APP_DETAIL_QUERY = `
|
||||
query PublicMarketplaceAppDetail($universalIdentifier: String!) {
|
||||
publicMarketplaceAppDetail(universalIdentifier: $universalIdentifier) {
|
||||
universalIdentifier
|
||||
name
|
||||
sourcePackage
|
||||
latestAvailableVersion
|
||||
isFeatured
|
||||
description
|
||||
author
|
||||
category
|
||||
logo
|
||||
websiteUrl
|
||||
aboutDescription
|
||||
screenshots
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type ApiMarketplaceAppDetail = {
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
sourcePackage?: string | null;
|
||||
latestAvailableVersion?: string | null;
|
||||
isFeatured: boolean;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
category?: string | null;
|
||||
logo?: string | null;
|
||||
websiteUrl?: string | null;
|
||||
aboutDescription?: string | null;
|
||||
screenshots?: string[] | null;
|
||||
};
|
||||
|
||||
type FindMarketplaceAppDetailData = {
|
||||
publicMarketplaceAppDetail: ApiMarketplaceAppDetail;
|
||||
};
|
||||
|
||||
export async function fetchMarketplaceAppDetailBySlug(
|
||||
slug: string,
|
||||
): Promise<MarketplaceAppDetail | null> {
|
||||
const apps = await fetchMarketplaceApps();
|
||||
const app = apps.find((candidate) => candidate.slug === slug);
|
||||
|
||||
if (app === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await marketplaceGraphqlRequest<FindMarketplaceAppDetailData>(
|
||||
FIND_MARKETPLACE_APP_DETAIL_QUERY,
|
||||
{ universalIdentifier: app.universalIdentifier },
|
||||
);
|
||||
|
||||
const detail = data.publicMarketplaceAppDetail;
|
||||
|
||||
return {
|
||||
universalIdentifier: detail.universalIdentifier,
|
||||
slug: getAppSlug(
|
||||
detail.sourcePackage ?? undefined,
|
||||
detail.universalIdentifier,
|
||||
),
|
||||
name: detail.name,
|
||||
tagline: app.tagline,
|
||||
author: detail.author ?? app.author,
|
||||
category: detail.category ?? app.category,
|
||||
logoUrl: detail.logo ?? app.logoUrl,
|
||||
sourcePackage: detail.sourcePackage ?? undefined,
|
||||
isFeatured: detail.isFeatured,
|
||||
description: detail.aboutDescription ?? detail.description ?? app.tagline,
|
||||
screenshots: detail.screenshots ?? [],
|
||||
websiteUrl: detail.websiteUrl ?? undefined,
|
||||
latestAvailableVersion: detail.latestAvailableVersion ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[apps-marketplace] detail fetch failed:', error);
|
||||
|
||||
return {
|
||||
...app,
|
||||
description: app.tagline,
|
||||
screenshots: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getAppSlug } from './get-app-slug';
|
||||
import { marketplaceGraphqlRequest } from './marketplace-api-fetch';
|
||||
import { type MarketplaceApp } from './marketplace-app';
|
||||
|
||||
const FIND_MANY_MARKETPLACE_APPS_QUERY = `
|
||||
query PublicMarketplaceApps {
|
||||
publicMarketplaceApps {
|
||||
id
|
||||
name
|
||||
description
|
||||
author
|
||||
category
|
||||
logo
|
||||
sourcePackage
|
||||
isFeatured
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type ApiMarketplaceApp = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
category: string;
|
||||
logo?: string | null;
|
||||
sourcePackage?: string | null;
|
||||
isFeatured: boolean;
|
||||
};
|
||||
|
||||
type FindManyMarketplaceAppsData = {
|
||||
publicMarketplaceApps: ApiMarketplaceApp[];
|
||||
};
|
||||
|
||||
const normalizeApp = (apiApp: ApiMarketplaceApp): MarketplaceApp => ({
|
||||
universalIdentifier: apiApp.id,
|
||||
slug: getAppSlug(apiApp.sourcePackage ?? undefined, apiApp.id),
|
||||
name: apiApp.name,
|
||||
tagline: apiApp.description,
|
||||
author: apiApp.author,
|
||||
category: apiApp.category,
|
||||
logoUrl: apiApp.logo ?? undefined,
|
||||
sourcePackage: apiApp.sourcePackage ?? undefined,
|
||||
isFeatured: apiApp.isFeatured,
|
||||
});
|
||||
|
||||
export async function fetchMarketplaceApps(): Promise<
|
||||
readonly MarketplaceApp[]
|
||||
> {
|
||||
try {
|
||||
const data = await marketplaceGraphqlRequest<FindManyMarketplaceAppsData>(
|
||||
FIND_MANY_MARKETPLACE_APPS_QUERY,
|
||||
);
|
||||
|
||||
if (!Array.isArray(data.publicMarketplaceApps)) {
|
||||
throw new Error('marketplace API response missing apps array');
|
||||
}
|
||||
|
||||
return data.publicMarketplaceApps.map(normalizeApp);
|
||||
} catch (error) {
|
||||
console.error('[apps-marketplace] live fetch failed:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const getAppSlug = (
|
||||
sourcePackage: string | undefined,
|
||||
universalIdentifier: string,
|
||||
): string => {
|
||||
if (sourcePackage === undefined || sourcePackage.length === 0) {
|
||||
return universalIdentifier;
|
||||
}
|
||||
|
||||
const lastSegment = sourcePackage.split('/').pop();
|
||||
|
||||
return lastSegment !== undefined && lastSegment.length > 0
|
||||
? lastSegment
|
||||
: universalIdentifier;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export { AppDetail } from './AppDetail';
|
||||
export { AppsMarketplaceClient } from './AppsMarketplaceClient';
|
||||
export { AppsMarketplaceHeader } from './AppsMarketplaceHeader';
|
||||
export { buildAppInstallUrl } from './build-app-install-url';
|
||||
export { fetchMarketplaceApps } from './fetch-marketplace-apps';
|
||||
export { fetchMarketplaceAppDetailBySlug } from './fetch-marketplace-app-detail';
|
||||
export {
|
||||
type MarketplaceApp,
|
||||
type MarketplaceAppDetail,
|
||||
} from './marketplace-app';
|
||||
@@ -0,0 +1,47 @@
|
||||
const REVALIDATE_SECONDS = 300;
|
||||
|
||||
const DEFAULT_MARKETPLACE_API_URL = 'https://api.twenty.com';
|
||||
|
||||
export async function marketplaceGraphqlRequest<TData>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
): Promise<TData> {
|
||||
const baseUrl =
|
||||
process.env.TWENTY_MARKETPLACE_API_URL ?? DEFAULT_MARKETPLACE_API_URL;
|
||||
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
next: { revalidate: REVALIDATE_SECONDS },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(
|
||||
`Twenty marketplace API ${response.status}: ${body.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
data?: TData;
|
||||
errors?: { message: string }[];
|
||||
};
|
||||
|
||||
if (json.errors !== undefined && json.errors.length > 0) {
|
||||
throw new Error(
|
||||
`Twenty marketplace API errors: ${json.errors
|
||||
.map((error) => error.message)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (json.data === undefined) {
|
||||
throw new Error('Twenty marketplace API returned no data');
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type MarketplaceApp = {
|
||||
universalIdentifier: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
author: string;
|
||||
category: string;
|
||||
logoUrl?: string;
|
||||
sourcePackage?: string;
|
||||
isFeatured: boolean;
|
||||
};
|
||||
|
||||
export type MarketplaceAppDetail = MarketplaceApp & {
|
||||
description: string;
|
||||
screenshots: readonly string[];
|
||||
websiteUrl?: string;
|
||||
latestAvailableVersion?: string;
|
||||
};
|
||||
@@ -12,6 +12,15 @@ export const STATIC_WEBSITE_ROUTES: readonly WebsiteRoute[] = [
|
||||
priority: 1,
|
||||
title: msg`Twenty | #1 Open Source CRM`,
|
||||
},
|
||||
{
|
||||
changeFrequency: 'weekly',
|
||||
description: msg`Extend Twenty with vetted apps — call recording, enrichment, Slack, Discord, Linear and more. Install any app on your open source CRM workspace in one click.`,
|
||||
id: 'apps',
|
||||
indexed: true,
|
||||
path: '/apps',
|
||||
priority: 0.8,
|
||||
title: msg`Twenty Apps — Vetted Apps for the Open Source CRM`,
|
||||
},
|
||||
{
|
||||
changeFrequency: 'monthly',
|
||||
description: msg`Pipelines, custom objects, AI assistants, and a native API on top of Postgres. Twenty is the open source CRM with the modern UX teams actually want to use.`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type MessageDescriptor } from '@lingui/core';
|
||||
import { type MetadataRoute } from 'next';
|
||||
|
||||
export type WebsiteRouteId =
|
||||
| 'apps'
|
||||
| 'customers'
|
||||
| 'enterpriseActivate'
|
||||
| 'halftone'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
IconApps,
|
||||
IconBook,
|
||||
IconBrandLinkedin,
|
||||
IconBrandX,
|
||||
@@ -69,6 +70,20 @@ export const MENU: {
|
||||
description: msg`APIs, SDKs and webhooks to extend Twenty and ship apps on top of your CRM data.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: msg`Apps`,
|
||||
description: msg`Extend your CRM`,
|
||||
href: '/apps',
|
||||
icon: IconApps,
|
||||
preview: {
|
||||
image: '/images/menu/developers.webp',
|
||||
imageAlt: msg`Twenty apps marketplace`,
|
||||
imagePosition: 'center',
|
||||
imageScale: 1.6,
|
||||
title: msg`Vetted apps for your workspace`,
|
||||
description: msg`Install call recording, enrichment, Slack, Linear and more — every app built and maintained by Twenty.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: msg`Partners`,
|
||||
description: msg`Find a Twenty partner`,
|
||||
|
||||
Reference in New Issue
Block a user