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} + + +