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:
martmull
2026-07-08 21:12:59 +02:00
committed by GitHub
parent 5f3f734b34
commit 16e5d2b9d0
19 changed files with 1148 additions and 0 deletions
+5
View File
@@ -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`,