From 16e5d2b9d080ca167c42733d55bc9363f86292e0 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 8 Jul 2026 21:12:59 +0200 Subject: [PATCH] feat(website): add public apps marketplace with one-click install (#22611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image image image image image ## 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 Co-authored-by: martmull 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 Co-authored-by: Thomas Trompette Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Parship Chowdhury Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: Charles Bochet Co-authored-by: Félix Malfait Co-authored-by: Pratik Mahajan Co-authored-by: neo773 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 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> --- packages/twenty-website/.env.example | 5 + .../app/[locale]/(site)/apps/[slug]/page.tsx | 72 +++++ .../src/app/[locale]/(site)/apps/page.tsx | 53 ++++ .../src/apps-marketplace/AppCard.tsx | 207 +++++++++++++++ .../src/apps-marketplace/AppDetail.tsx | 245 ++++++++++++++++++ .../src/apps-marketplace/AppLogo.tsx | 69 +++++ .../src/apps-marketplace/AppsGrid.tsx | 35 +++ .../AppsMarketplaceClient.tsx | 137 ++++++++++ .../AppsMarketplaceHeader.tsx | 47 ++++ .../apps-marketplace/build-app-install-url.ts | 10 + .../fetch-marketplace-app-detail.ts | 89 +++++++ .../fetch-marketplace-apps.ts | 65 +++++ .../src/apps-marketplace/get-app-slug.ts | 14 + .../src/apps-marketplace/index.ts | 10 + .../apps-marketplace/marketplace-api-fetch.ts | 47 ++++ .../src/apps-marketplace/marketplace-app.ts | 18 ++ .../platform/routing/static-website-routes.ts | 9 + .../src/platform/routing/website-route.ts | 1 + .../src/sections/menu/data/menu.ts | 15 ++ 19 files changed, 1148 insertions(+) create mode 100644 packages/twenty-website/src/app/[locale]/(site)/apps/[slug]/page.tsx create mode 100644 packages/twenty-website/src/app/[locale]/(site)/apps/page.tsx create mode 100644 packages/twenty-website/src/apps-marketplace/AppCard.tsx create mode 100644 packages/twenty-website/src/apps-marketplace/AppDetail.tsx create mode 100644 packages/twenty-website/src/apps-marketplace/AppLogo.tsx create mode 100644 packages/twenty-website/src/apps-marketplace/AppsGrid.tsx create mode 100644 packages/twenty-website/src/apps-marketplace/AppsMarketplaceClient.tsx create mode 100644 packages/twenty-website/src/apps-marketplace/AppsMarketplaceHeader.tsx create mode 100644 packages/twenty-website/src/apps-marketplace/build-app-install-url.ts create mode 100644 packages/twenty-website/src/apps-marketplace/fetch-marketplace-app-detail.ts create mode 100644 packages/twenty-website/src/apps-marketplace/fetch-marketplace-apps.ts create mode 100644 packages/twenty-website/src/apps-marketplace/get-app-slug.ts create mode 100644 packages/twenty-website/src/apps-marketplace/index.ts create mode 100644 packages/twenty-website/src/apps-marketplace/marketplace-api-fetch.ts create mode 100644 packages/twenty-website/src/apps-marketplace/marketplace-app.ts diff --git a/packages/twenty-website/.env.example b/packages/twenty-website/.env.example index b3ac1c405d..0fc49c511c 100644 --- a/packages/twenty-website/.env.example +++ b/packages/twenty-website/.env.example @@ -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= diff --git a/packages/twenty-website/src/app/[locale]/(site)/apps/[slug]/page.tsx b/packages/twenty-website/src/app/[locale]/(site)/apps/[slug]/page.tsx new file mode 100644 index 0000000000..d337a2a7b5 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/(site)/apps/[slug]/page.tsx @@ -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; +}): Promise { + 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; +}) { + 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 ( + <> + + +
+ +
+ + ); +} diff --git a/packages/twenty-website/src/app/[locale]/(site)/apps/page.tsx b/packages/twenty-website/src/app/[locale]/(site)/apps/page.tsx new file mode 100644 index 0000000000..4945211857 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/(site)/apps/page.tsx @@ -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; +}) { + const [, communityStats, apps] = await Promise.all([ + getRouteI18n(params), + getCommunityStats(), + fetchMarketplaceApps(), + ]); + const locale = resolveLocaleParam((await params).locale); + + return ( + <> + + +
+ + +
+ + ); +} diff --git a/packages/twenty-website/src/apps-marketplace/AppCard.tsx b/packages/twenty-website/src/apps-marketplace/AppCard.tsx new file mode 100644 index 0000000000..824ef31ed5 --- /dev/null +++ b/packages/twenty-website/src/apps-marketplace/AppCard.tsx @@ -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 ( + + + + + + {app.name} + + {app.category.length > 0 && ( + {app.category} + )} + + + + {app.tagline} + + +