From 6742cfe861cc0be651c3d13cb8ae2126944c9155 Mon Sep 17 00:00:00 2001
From: Rashad Karanouh <11599358+rashad@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:03:46 +0200
Subject: [PATCH] =?UTF-8?q?Marketplace=20glowup=20=E2=80=94=20live=20partn?=
=?UTF-8?q?er=20profiles,=20case=20studies=20&=20matching=20(website)=20(#?=
=?UTF-8?q?23016)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rebuilds the partners marketplace on live CRM-backed partner data: real
profiles, case studies, matching/scope cards, and a "match me" entry
point in the grid.
## What changed
- Marketplace grid and partner cards now fetch, rank, and filter live
partner data instead of static fixtures
- Partner profile pages render live profile data, including services,
portfolio/case studies, and clients
- Partner scope/matching cards on the profile page, plus a
`MarketplaceMatchCard` as the first tile in the marketplace grid,
routing into the client-brief flow
- Rich CTA rail on partner profiles (calendar link, website, socials)
built from live partner links
- Markdown rendering (`react-markdown`) for partner descriptions and
case study bodies, including proper heading rendering
- Minor route/sitemap adjustments to support the live-data pages
## Architecture / notes
This branch was 463 commits behind `main` and was resynced via a single
merge (not rebase) to avoid re-resolving the same conflicts repeatedly.
Several of the branch's earlier commits (client-brief wizard,
`MarketplaceBriefPrompt`, `MarketplaceMatchCard`'s base styling,
`PricingEngagementBand`) had already landed on `main` independently, in
some cases refactored into shared components (`EngagementBand`,
`MarketplaceCardFrame`, `createWebhookForwardingRoute`) — those
conflicts were resolved by taking `main`'s already-shipped version.
`PartnerCard.tsx` had diverged into two different designs (`main` gained
chip rows / money row / LinkedIn icon; this branch gained the live
case-study/portfolio data model with markdown descriptions and
structured partner links); the resolution keeps this branch's data model
(`description` as markdown, `links`/`linkUrls`) while adopting `main`'s
card layout, adapting field references accordingly.
`PartnerProfileCtas.tsx` keeps this branch's richer link-rail
implementation since it's the one that matches the live data model
already wired into `PartnerProfile.tsx`.
This is the website counterpart to app PR #22929 (glowup, v1.3.0),
already deployed to prod, and supersedes the closed drafts #22471 and
#22402.
Lint, format, targeted marketplace/client-brief jest tests, and `nx
typecheck twenty-website` all pass after the merge.
---
packages/twenty-website/package.json | 1 +
.../[locale]/(site)/partners/list/page.tsx | 7 +-
.../(site)/partners/profile/[slug]/page.tsx | 17 +-
.../partners-marketplace/CaseStudyModal.tsx | 203 +++++++++
.../CaseStudyPlaceholder.tsx | 87 ++++
.../partners-marketplace/CaseStudyVisual.tsx | 59 +++
.../CaseStudyVisualHover.tsx | 15 +
.../src/partners-marketplace/FilterBar.tsx | 3 +-
.../partners-marketplace/MarketplaceGrid.tsx | 4 +-
.../MarketplaceListSkeleton.tsx | 157 +++++++
.../src/partners-marketplace/PartnerCard.tsx | 18 +-
.../PartnerCoverageSection.tsx | 123 ++++++
.../partners-marketplace/PartnerFactsList.tsx | 106 -----
.../partners-marketplace/PartnerProfile.tsx | 123 ++----
.../PartnerProfileAbout.tsx | 87 ++++
.../PartnerProfileCtas.tsx | 192 ++++++---
.../PartnerRatesPanel.tsx | 67 ++-
.../PartnerReachFacts.tsx | 110 +++++
.../PartnerSelectedWork.tsx | 290 +++++++++++++
.../partners-marketplace/PartnerServices.tsx | 184 ++++++++
.../ProfileSectionTitle.tsx | 14 +
.../src/partners-marketplace/RichText.tsx | 40 ++
.../case-study-card-aspect-ratio.ts | 2 +
.../case-study-modal-image-height.ts | 2 +
.../case-study-modal.styles.ts | 260 +++++++++++
.../case-study-sample-image.ts | 3 +
.../collect-partner-link-urls.ts | 23 +
.../collect-partner-links.ts | 32 ++
.../completeness-score.ts | 14 +
.../data/partner-scope-options.ts | 36 ++
.../dummy-marketplace-partners.ts | 408 ++++++++++++++++++
.../fetch-live-marketplace-partners.test.ts | 17 +-
.../fetch-live-marketplace-partners.ts | 44 +-
.../fetch-live-partner-profile.test.ts | 88 ++++
.../fetch-live-partner-profile.ts | 91 ++++
.../filter-partners.test.ts | 13 +-
.../format-partner-link-label.ts | 14 +
.../get-marketplace-partner-by-slug.ts | 18 +-
.../get-marketplace-partners.ts | 15 +
.../partners-marketplace/is-ghost-partner.ts | 4 +
.../src/partners-marketplace/link-url.ts | 5 +
.../marketplace-api-types.ts | 5 +
.../partners-marketplace/marketplace-copy.ts | 6 +
.../marketplace-partner.ts | 28 +-
.../marketplace-partners-source.ts | 2 +
.../src/partners-marketplace/micros-to-usd.ts | 6 +
.../src/partners-marketplace/normalize-url.ts | 7 +
.../partner-link-entries.test.ts | 58 +++
.../partner-link-entry.ts | 4 +
.../partner-scope-labels.ts | 14 +-
.../partner-scope-priority.test.ts | 16 +
.../partners-api-fetch.ts | 14 +-
.../rank-partners.test.ts | 70 +++
.../src/partners-marketplace/rank-partners.ts | 15 +
.../resolve-partner-scope-cards.test.ts | 33 ++
.../resolve-partner-scope-cards.ts | 19 +
.../rich-text-excerpt.test.ts | 23 +
.../partners-marketplace/rich-text-excerpt.ts | 14 +
.../scope-priority-order.ts | 9 +
.../src/partners-marketplace/top-scopes.ts | 13 +
yarn.lock | 1 +
61 files changed, 3015 insertions(+), 338 deletions(-)
create mode 100644 packages/twenty-website/src/partners-marketplace/CaseStudyModal.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/CaseStudyPlaceholder.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/CaseStudyVisual.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/CaseStudyVisualHover.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/MarketplaceListSkeleton.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/PartnerCoverageSection.tsx
delete mode 100644 packages/twenty-website/src/partners-marketplace/PartnerFactsList.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/PartnerProfileAbout.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/PartnerReachFacts.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/PartnerSelectedWork.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/PartnerServices.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/ProfileSectionTitle.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/RichText.tsx
create mode 100644 packages/twenty-website/src/partners-marketplace/case-study-card-aspect-ratio.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/case-study-modal-image-height.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/case-study-modal.styles.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/case-study-sample-image.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/collect-partner-link-urls.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/collect-partner-links.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/completeness-score.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/data/partner-scope-options.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/dummy-marketplace-partners.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/fetch-live-partner-profile.test.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/fetch-live-partner-profile.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/format-partner-link-label.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/get-marketplace-partners.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/is-ghost-partner.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/link-url.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/marketplace-api-types.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/marketplace-copy.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/marketplace-partners-source.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/micros-to-usd.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/normalize-url.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/partner-link-entries.test.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/partner-link-entry.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/partner-scope-priority.test.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/rank-partners.test.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/rank-partners.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/resolve-partner-scope-cards.test.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/resolve-partner-scope-cards.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/rich-text-excerpt.test.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/rich-text-excerpt.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/scope-priority-order.ts
create mode 100644 packages/twenty-website/src/partners-marketplace/top-scopes.ts
diff --git a/packages/twenty-website/package.json b/packages/twenty-website/package.json
index b459a95a20..774c0d3a78 100644
--- a/packages/twenty-website/package.json
+++ b/packages/twenty-website/package.json
@@ -27,6 +27,7 @@
"next-with-linaria": "^1.3.0",
"react": "19.2.3",
"react-dom": "19.2.3",
+ "react-markdown": "^10.1.0",
"server-only": "^0.0.1",
"stripe": "^20.3.1",
"three": "^0.184.0",
diff --git a/packages/twenty-website/src/app/[locale]/(site)/partners/list/page.tsx b/packages/twenty-website/src/app/[locale]/(site)/partners/list/page.tsx
index 14321ca3ff..589f74666c 100644
--- a/packages/twenty-website/src/app/[locale]/(site)/partners/list/page.tsx
+++ b/packages/twenty-website/src/app/[locale]/(site)/partners/list/page.tsx
@@ -6,8 +6,9 @@ import {
type LocaleRouteParams,
} from '@/platform/i18n/get-route-i18n';
import { resolveLocaleParam } from '@/platform/i18n/resolve-locale-param';
-import { fetchLiveMarketplacePartners } from '@/partners-marketplace/fetch-live-marketplace-partners';
+import { getMarketplacePartners } from '@/partners-marketplace/marketplace-partners-source';
import { MarketplaceClient } from '@/partners-marketplace/MarketplaceClient';
+import { MarketplaceListSkeleton } from '@/partners-marketplace/MarketplaceListSkeleton';
import { MarketplaceHeader } from '@/partners-marketplace/MarketplaceHeader';
import {
buildBreadcrumbListJsonLd,
@@ -28,7 +29,7 @@ export default async function PartnersMarketplacePage({
const [, communityStats, partners] = await Promise.all([
getRouteI18n(params),
getCommunityStats(),
- fetchLiveMarketplacePartners(),
+ getMarketplacePartners(),
]);
const locale = resolveLocaleParam((await params).locale);
@@ -47,7 +48,7 @@ export default async function PartnersMarketplacePage({
-
+ }>
diff --git a/packages/twenty-website/src/app/[locale]/(site)/partners/profile/[slug]/page.tsx b/packages/twenty-website/src/app/[locale]/(site)/partners/profile/[slug]/page.tsx
index cb84dd1eaf..86c5fe9026 100644
--- a/packages/twenty-website/src/app/[locale]/(site)/partners/profile/[slug]/page.tsx
+++ b/packages/twenty-website/src/app/[locale]/(site)/partners/profile/[slug]/page.tsx
@@ -6,9 +6,12 @@ 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 { fetchLiveMarketplacePartners } from '@/partners-marketplace/fetch-live-marketplace-partners';
-import { getMarketplacePartnerBySlug } from '@/partners-marketplace/get-marketplace-partner-by-slug';
+import {
+ getMarketplacePartnerBySlug,
+ getMarketplacePartners,
+} from '@/partners-marketplace/marketplace-partners-source';
import { PartnerProfile } from '@/partners-marketplace/PartnerProfile';
+import { richTextExcerpt } from '@/partners-marketplace/rich-text-excerpt';
import { buildBreadcrumbListJsonLd, JsonLd } from '@/platform/seo';
import { Menu } from '@/sections/menu';
@@ -19,16 +22,10 @@ export const dynamic = 'force-dynamic';
export const dynamicParams = true;
export async function generateStaticParams(): Promise> {
- const partners = await fetchLiveMarketplacePartners();
+ const partners = await getMarketplacePartners();
return partners.map((partner) => ({ slug: partner.slug }));
}
-// Collapse whitespace and cap to a meta-description length.
-const truncateDescription = (text: string, max = 160): string => {
- const cleaned = text.replace(/\s+/g, ' ').trim();
- return cleaned.length <= max ? cleaned : `${cleaned.slice(0, max - 1)}…`;
-};
-
export async function generateMetadata({
params,
}: {
@@ -43,7 +40,7 @@ export async function generateMetadata({
}
return {
title: i18n._(msg`${partner.name} — Twenty Partner`),
- description: truncateDescription(partner.introduction),
+ description: richTextExcerpt(partner.description, 160),
};
}
diff --git a/packages/twenty-website/src/partners-marketplace/CaseStudyModal.tsx b/packages/twenty-website/src/partners-marketplace/CaseStudyModal.tsx
new file mode 100644
index 0000000000..682007e629
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/CaseStudyModal.tsx
@@ -0,0 +1,203 @@
+'use client';
+
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { IconX } from '@tabler/icons-react';
+import { useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+import { isSafeHttpUrl } from './is-safe-http-url';
+import { type PartnerCaseStudy } from './marketplace-partner';
+import { caseStudyModalStyles as modal } from './case-study-modal.styles';
+import { CaseStudyVisual } from './CaseStudyVisual';
+import { RichText } from './RichText';
+
+const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+export function CaseStudyModal({
+ cases,
+ openIndex,
+ onClose,
+}: {
+ cases: readonly PartnerCaseStudy[];
+ openIndex: number | null;
+ onClose: () => void;
+}) {
+ const { i18n } = useLingui();
+ const [index, setIndex] = useState(openIndex ?? 0);
+ const [mounted, setMounted] = useState(false);
+ const panelRef = useRef(null);
+ const bodyRef = useRef(null);
+ const closeButtonRef = useRef(null);
+ const previousFocusRef = useRef(null);
+ const previousOverflowRef = useRef('');
+ const onCloseRef = useRef(onClose);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ useEffect(() => {
+ onCloseRef.current = onClose;
+ });
+
+ useEffect(() => {
+ if (openIndex !== null) {
+ setIndex(openIndex);
+ }
+ }, [openIndex]);
+
+ useEffect(() => {
+ bodyRef.current?.scrollTo({ top: 0 });
+ }, [index, openIndex]);
+
+ useEffect(() => {
+ if (openIndex === null || cases[openIndex] === undefined) {
+ return undefined;
+ }
+
+ previousFocusRef.current = document.activeElement;
+ previousOverflowRef.current = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ closeButtonRef.current?.focus();
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ onCloseRef.current();
+ return;
+ }
+
+ if (event.key !== 'Tab' || panelRef.current === null) {
+ return;
+ }
+
+ const focusableElements = Array.from(
+ panelRef.current.querySelectorAll(FOCUSABLE_SELECTOR),
+ );
+
+ if (focusableElements.length === 0) {
+ return;
+ }
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ if (event.shiftKey && document.activeElement === firstElement) {
+ event.preventDefault();
+ lastElement.focus();
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
+ event.preventDefault();
+ firstElement.focus();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = previousOverflowRef.current;
+
+ if (previousFocusRef.current instanceof HTMLElement) {
+ previousFocusRef.current.focus();
+ }
+ };
+ }, [openIndex, cases]);
+
+ if (openIndex === null || !mounted) {
+ return null;
+ }
+
+ const caseStudy = cases[index];
+
+ if (caseStudy === undefined) {
+ return null;
+ }
+
+ const imageUrl =
+ caseStudy.imageUrl !== null && isSafeHttpUrl(caseStudy.imageUrl)
+ ? caseStudy.imageUrl
+ : null;
+ const outboundLink =
+ caseStudy.link !== null && isSafeHttpUrl(caseStudy.link)
+ ? caseStudy.link
+ : null;
+ const isPreviousDisabled = index === 0;
+ const isNextDisabled = index === cases.length - 1;
+
+ const handlePrevious = () => {
+ setIndex((currentIndex) => Math.max(0, currentIndex - 1));
+ };
+
+ const handleNext = () => {
+ setIndex((currentIndex) => Math.min(cases.length - 1, currentIndex + 1));
+ };
+
+ return createPortal(
+
+
+
+
+
+
+
+
+
+ {caseStudy.client}
+ {caseStudy.title}
+
+ {imageUrl !== null ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {outboundLink !== null ? (
+
+ {i18n._(msg`View case study`)}
+
+ ) : null}
+
+
+ {i18n._(msg`Previous`)}
+
+
+ {i18n._(msg`Next`)}
+
+
+
+
+ ,
+ document.body,
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/CaseStudyPlaceholder.tsx b/packages/twenty-website/src/partners-marketplace/CaseStudyPlaceholder.tsx
new file mode 100644
index 0000000000..3ce47198c2
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/CaseStudyPlaceholder.tsx
@@ -0,0 +1,87 @@
+import { styled } from '@linaria/react';
+
+import {
+ color,
+ fontFamily,
+ fontSize,
+ FONT_WEIGHT,
+ GRADIENT,
+ radius,
+ semanticColor,
+ spacing,
+} from '@/tokens';
+
+import { CASE_STUDY_CARD_ASPECT_RATIO } from './case-study-card-aspect-ratio';
+
+const PlaceholderFrame = styled.div`
+ aspect-ratio: ${CASE_STUDY_CARD_ASPECT_RATIO};
+ background:
+ radial-gradient(120% 90% at 12% 0%, ${color('blue')}14, transparent 55%),
+ ${GRADIENT.heroGlow};
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+
+ &::before {
+ background-image:
+ linear-gradient(${semanticColor.line} 1px, transparent 1px),
+ linear-gradient(90deg, ${semanticColor.line} 1px, transparent 1px);
+ background-size: ${spacing(6)} ${spacing(6)};
+ content: '';
+ inset: 0;
+ opacity: 0.65;
+ pointer-events: none;
+ position: absolute;
+ }
+`;
+
+const PlaceholderInner = styled.div`
+ align-items: center;
+ display: flex;
+ inset: 0;
+ justify-content: center;
+ position: absolute;
+`;
+
+const ClientMark = styled.span`
+ backdrop-filter: blur(6px);
+ background: ${color('white-80')};
+ border: 1px solid ${semanticColor.line};
+ border-radius: ${radius(2)};
+ color: ${semanticColor.ink};
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(3)};
+ font-weight: ${FONT_WEIGHT.medium};
+ letter-spacing: 0.16em;
+ line-height: 1;
+ padding: ${spacing(2.5)} ${spacing(4)};
+ text-transform: uppercase;
+
+ [data-scheme='dark'] & {
+ background: ${color('black-40')};
+ }
+`;
+
+function clientInitials(client: string): string {
+ const words = client.trim().split(/\s+/).filter(Boolean);
+
+ if (words.length === 0) {
+ return 'CS';
+ }
+
+ if (words.length === 1) {
+ return words[0]!.slice(0, 2).toUpperCase();
+ }
+
+ return `${words[0]!.charAt(0)}${words[1]!.charAt(0)}`.toUpperCase();
+}
+
+export function CaseStudyPlaceholder({ client }: { client: string }) {
+ return (
+
+
+ {clientInitials(client)}
+
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/CaseStudyVisual.tsx b/packages/twenty-website/src/partners-marketplace/CaseStudyVisual.tsx
new file mode 100644
index 0000000000..fb28993faa
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/CaseStudyVisual.tsx
@@ -0,0 +1,59 @@
+import { styled } from '@linaria/react';
+
+import { color, EASING, radius } from '@/tokens';
+
+import { CASE_STUDY_CARD_ASPECT_RATIO } from './case-study-card-aspect-ratio';
+import { CASE_STUDY_MODAL_IMAGE_HEIGHT } from './case-study-modal-image-height';
+
+const VisualFrame = styled.div`
+ background-color: ${color('black-5')};
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+
+ &[data-size='card'] {
+ aspect-ratio: ${CASE_STUDY_CARD_ASPECT_RATIO};
+ }
+
+ &[data-size='modal'] {
+ border-radius: ${radius(1.5)};
+ height: ${CASE_STUDY_MODAL_IMAGE_HEIGHT};
+ }
+
+ &[data-size='card']::after {
+ background: linear-gradient(
+ 180deg,
+ transparent 55%,
+ ${color('black-10')} 100%
+ );
+ content: '';
+ inset: 0;
+ pointer-events: none;
+ position: absolute;
+ }
+
+ & img {
+ display: block;
+ height: 100%;
+ object-fit: cover;
+ transform: scale(1);
+ transition: transform 0.55s ${EASING.standard};
+ width: 100%;
+ }
+`;
+
+export function CaseStudyVisual({
+ alt,
+ imageUrl,
+ size = 'card',
+}: {
+ alt?: string;
+ imageUrl: string;
+ size?: 'card' | 'modal';
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/CaseStudyVisualHover.tsx b/packages/twenty-website/src/partners-marketplace/CaseStudyVisualHover.tsx
new file mode 100644
index 0000000000..cc4abc758d
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/CaseStudyVisualHover.tsx
@@ -0,0 +1,15 @@
+import { styled } from '@linaria/react';
+
+export const CaseStudyVisualHover = styled.div`
+ overflow: hidden;
+ width: 100%;
+
+ &[data-layout='featured'] {
+ flex-shrink: 0;
+ width: 100%;
+ }
+
+ &:hover [data-size='card'] img {
+ transform: scale(1.045);
+ }
+`;
diff --git a/packages/twenty-website/src/partners-marketplace/FilterBar.tsx b/packages/twenty-website/src/partners-marketplace/FilterBar.tsx
index 8cdb662cca..c30d30a802 100644
--- a/packages/twenty-website/src/partners-marketplace/FilterBar.tsx
+++ b/packages/twenty-website/src/partners-marketplace/FilterBar.tsx
@@ -10,6 +10,7 @@ import { Button } from '@/ui';
import { ActiveFilterPills, type ActivePill } from './ActiveFilterPills';
import { type FilterCriteria } from './filter-criteria';
import { FilterDropdown } from './FilterDropdown';
+import { MARKETPLACE_COPY } from './marketplace-copy';
import { PARTNER_SCOPE_LABELS } from './partner-scope-labels';
import { PARTNER_SCOPES, type PartnerScope } from './partner-scopes';
import { SERVED_GEO_LABELS } from './served-geo-labels';
@@ -109,7 +110,7 @@ export function FilterBar({
selected={criteria.languages}
/>
`
+ height: ${spacing(3.5)};
+ width: ${({ $width = '100%' }) => $width};
+`;
+
+const CardFoot = styled.div`
+ align-items: center;
+ border-top: 1px solid ${semanticColor.line};
+ display: flex;
+ gap: ${spacing(3.5)};
+ justify-content: space-between;
+ margin-top: ${spacing(0.5)};
+ padding-top: ${spacing(3.5)};
+`;
+
+const ScopeBar = styled(SkeletonBlock)`
+ flex: 1;
+ height: ${spacing(3.25)};
+ max-width: 68%;
+`;
+
+const CtaBar = styled(SkeletonBlock)`
+ height: ${spacing(3.25)};
+ width: ${spacing(16)};
+`;
+
+function PartnerCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function MarketplaceListSkeleton() {
+ return (
+
+
+ {Array.from({ length: SKELETON_CARD_COUNT }, (_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerCard.tsx b/packages/twenty-website/src/partners-marketplace/PartnerCard.tsx
index 785aa09f56..38fdd31913 100644
--- a/packages/twenty-website/src/partners-marketplace/PartnerCard.tsx
+++ b/packages/twenty-website/src/partners-marketplace/PartnerCard.tsx
@@ -23,6 +23,7 @@ import { PartnerAvatar } from './PartnerAvatar';
import { PartnerChipRow } from './PartnerChipRow';
import { PartnerMoneyRow } from './PartnerMoneyRow';
import { PARTNER_SCOPE_LABELS } from './partner-scope-labels';
+import { richTextExcerpt } from './rich-text-excerpt';
import { SERVED_GEO_LABELS } from './served-geo-labels';
import { SPOKEN_LANGUAGE_LABELS } from './spoken-language-labels';
import { titleCaseFallback } from './title-case-fallback';
@@ -171,7 +172,12 @@ export function PartnerCard({ partner, index }: PartnerCardProps) {
.filter(Boolean)
.join(', ');
- const linkedinSafe = isSafeHttpUrl(partner.linkedinUrl);
+ const linkedinHref =
+ partner.links.linkedin !== null && isSafeHttpUrl(partner.links.linkedin)
+ ? partner.links.linkedin
+ : null;
+
+ const descriptionExcerpt = richTextExcerpt(partner.description);
return (
@@ -186,20 +192,22 @@ export function PartnerCard({ partner, index }: PartnerCardProps) {
{partner.name}
- {linkedinSafe && (
+ {linkedinHref !== null && (
)}
- {locationLine}
+ {locationLine.length > 0 && (
+ {locationLine}
+ )}
- {partner.introduction}
+ {descriptionExcerpt}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerCoverageSection.tsx b/packages/twenty-website/src/partners-marketplace/PartnerCoverageSection.tsx
new file mode 100644
index 0000000000..18963692ef
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/PartnerCoverageSection.tsx
@@ -0,0 +1,123 @@
+import { msg } from '@lingui/core/macro';
+import { styled } from '@linaria/react';
+
+import { getServerI18n } from '@/platform/i18n/get-server-i18n';
+import { Body } from '@/ui';
+import {
+ color,
+ FONT_WEIGHT,
+ fontFamily,
+ fontSize,
+ radius,
+ semanticColor,
+ spacing,
+} from '@/tokens';
+
+import { ProfileEyebrow } from './ProfileEyebrow';
+import { ProfileSectionTitle } from './ProfileSectionTitle';
+import { MARKETPLACE_COPY } from './marketplace-copy';
+import { resolvePartnerScopeCards } from './resolve-partner-scope-cards';
+import { type PartnerScope } from './partner-scopes';
+
+const Section = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(4)};
+`;
+
+const CoverageGroups = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(4)};
+`;
+
+const CoverageGroup = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(1.5)};
+`;
+
+const CoverageLabel = styled.span`
+ color: ${semanticColor.ink};
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(4)};
+ font-weight: ${FONT_WEIGHT.medium};
+ line-height: 1.25;
+`;
+
+const SkillsBlock = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(3)};
+
+ &[data-separated='true'] {
+ border-top: 1px solid ${semanticColor.line};
+ padding-top: ${spacing(4)};
+ }
+`;
+
+const SkillsRow = styled.ul`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${spacing(1.5)};
+ list-style: none;
+ padding: 0;
+`;
+
+const SkillChip = styled.li`
+ background-color: ${color('blue-5')};
+ border-radius: ${radius(2)};
+ color: ${semanticColor.ink};
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(3.5)};
+ font-weight: ${FONT_WEIGHT.medium};
+ padding: ${spacing(1.5)} ${spacing(2.5)};
+`;
+
+export function PartnerCoverageSection({
+ partnerScope,
+ skills,
+}: {
+ partnerScope: readonly PartnerScope[];
+ skills: readonly string[];
+}) {
+ const scopeCards = resolvePartnerScopeCards(partnerScope);
+ const hasScopes = scopeCards.length > 0;
+ const hasSkills = skills.length > 0;
+
+ if (!hasScopes && !hasSkills) {
+ return null;
+ }
+
+ const i18n = getServerI18n();
+
+ return (
+
+
+ {i18n._(MARKETPLACE_COPY.partnerScopeHeading)}
+
+ {hasScopes && (
+
+ {scopeCards.map((scope) => (
+
+ {i18n._(scope.label)}
+
+ {i18n._(msg`ex. ${i18n._(scope.examples)}`)}
+
+
+ ))}
+
+ )}
+ {hasSkills && (
+
+ {i18n._(msg`Technical skills`)}
+
+ {skills.map((skill) => (
+ {skill}
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerFactsList.tsx b/packages/twenty-website/src/partners-marketplace/PartnerFactsList.tsx
deleted file mode 100644
index b4094f233e..0000000000
--- a/packages/twenty-website/src/partners-marketplace/PartnerFactsList.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-'use client';
-
-import { type MessageDescriptor } from '@lingui/core';
-import { msg } from '@lingui/core/macro';
-import { useLingui } from '@lingui/react';
-import { styled } from '@linaria/react';
-
-import { color, fontFamily, fontSize, semanticColor, spacing } from '@/tokens';
-
-import { type MarketplacePartner } from './marketplace-partner';
-import { PARTNER_SCOPE_LABELS } from './partner-scope-labels';
-import { SERVED_GEO_LABELS } from './served-geo-labels';
-import { SPOKEN_LANGUAGE_LABELS } from './spoken-language-labels';
-import { titleCaseFallback } from './title-case-fallback';
-
-const FactsDl = styled.dl`
- display: flex;
- flex-direction: column;
-
- & > * + * {
- margin-top: ${spacing(3)};
- }
-`;
-
-const FactRow = styled.div`
- align-items: baseline;
- display: flex;
- gap: ${spacing(3)};
-`;
-
-const FactLabel = styled.dt`
- color: ${semanticColor.inkMuted};
- flex-shrink: 0;
- font-family: ${fontFamily('mono')};
- font-size: ${fontSize(3)};
- letter-spacing: 0.08em;
- text-transform: uppercase;
- width: 100px;
-`;
-
-const FactValue = styled.dd`
- color: ${color('black-80')};
- font-family: ${fontFamily('sans')};
- font-size: ${fontSize(4)};
-`;
-
-function resolveLabels(
- values: readonly TValue[],
- labels: Record,
- translate: (descriptor: MessageDescriptor) => string,
-): string {
- return values
- .map((value) => {
- const descriptor = labels[value];
- return descriptor ? translate(descriptor) : titleCaseFallback(value);
- })
- .join(', ');
-}
-
-export function PartnerFactsList({
- region,
- languagesSpoken,
- partnerScope,
-}: {
- region: MarketplacePartner['region'];
- languagesSpoken: MarketplacePartner['languagesSpoken'];
- partnerScope: MarketplacePartner['partnerScope'];
-}) {
- const { i18n } = useLingui();
- const translate = (descriptor: MessageDescriptor) => i18n._(descriptor);
-
- const regionText = resolveLabels(region, SERVED_GEO_LABELS, translate);
- const languageText = resolveLabels(
- languagesSpoken,
- SPOKEN_LANGUAGE_LABELS,
- translate,
- );
- const categoryText = resolveLabels(
- partnerScope,
- PARTNER_SCOPE_LABELS,
- translate,
- );
-
- return (
-
- {regionText && (
-
- {i18n._(msg`Regions`)}
- {regionText}
-
- )}
- {languageText && (
-
- {i18n._(msg`Languages`)}
- {languageText}
-
- )}
- {categoryText && (
-
- {i18n._(msg`Categories`)}
- {categoryText}
-
- )}
-
- );
-}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerProfile.tsx b/packages/twenty-website/src/partners-marketplace/PartnerProfile.tsx
index 2bc3abb0cb..34bca7b141 100644
--- a/packages/twenty-website/src/partners-marketplace/PartnerProfile.tsx
+++ b/packages/twenty-website/src/partners-marketplace/PartnerProfile.tsx
@@ -5,27 +5,25 @@ import { getServerI18n } from '@/platform/i18n/get-server-i18n';
import {
color,
EASING,
- FONT_WEIGHT,
- fontFamily,
- fontSize,
GRADIENT,
mediaUp,
radius,
REDUCED_MOTION,
- semanticColor,
spacing,
} from '@/tokens';
import { GuideCrosshair, SectionShell } from '@/ui';
import { BackToMarketplaceLink } from './BackToMarketplaceLink';
import { type MarketplacePartner } from './marketplace-partner';
-import { PartnerFactsList } from './PartnerFactsList';
+import { PartnerCoverageSection } from './PartnerCoverageSection';
import { PartnerProfileCtas } from './PartnerProfileCtas';
import { PartnerProfileHeader } from './PartnerProfileHeader';
-import { PartnerProfileIntro } from './PartnerProfileIntro';
+import { PartnerProfileAbout } from './PartnerProfileAbout';
import { PartnerProfilePhoto } from './PartnerProfilePhoto';
+import { PartnerReachFacts } from './PartnerReachFacts';
+import { PartnerSelectedWork } from './PartnerSelectedWork';
+import { PartnerServices } from './PartnerServices';
import { PartnerRatesPanel } from './PartnerRatesPanel';
-import { ProfileEyebrow } from './ProfileEyebrow';
const GradientBackdrop = styled.div`
background: ${GRADIENT.heroGlow};
@@ -36,7 +34,7 @@ const GradientBackdrop = styled.div`
// A detail page reads tighter than the full 1440 grid — capped like a measure.
const ProfileInner = styled.div`
margin-inline: auto;
- max-width: 1100px;
+ max-width: 1240px;
width: 100%;
`;
@@ -46,35 +44,25 @@ const ContentGrid = styled.div`
gap: ${spacing(10)};
${mediaUp('md')} {
+ column-gap: ${spacing(9)};
display: grid;
- gap: 0;
- grid-template-columns: 7fr 1fr 4fr;
+ grid-template-columns: minmax(0, 1fr) minmax(18.5rem, 21rem);
+ }
+
+ ${mediaUp('lg')} {
+ column-gap: ${spacing(10)};
}
`;
const MainColumn = styled.div`
display: flex;
flex-direction: column;
-
- & > * + * {
- margin-top: ${spacing(10)};
- }
+ gap: ${spacing(10)};
+ min-width: 0;
${mediaUp('md')} {
+ gap: ${spacing(14)};
grid-column: 1;
-
- & > * + * {
- margin-top: ${spacing(14)};
- }
- }
-`;
-
-const GutterColumn = styled.div`
- display: none;
-
- ${mediaUp('md')} {
- display: block;
- grid-column: 2;
}
`;
@@ -88,8 +76,15 @@ const RailColumn = styled.aside`
display: flex;
flex-direction: column;
gap: ${spacing(6)};
+ min-width: 0;
padding: ${spacing(6)};
position: relative;
+ width: 100%;
+
+ ${mediaUp('md')} {
+ gap: ${spacing(7.5)};
+ padding: ${spacing(7.5)};
+ }
@keyframes profileRailEnter {
from {
@@ -107,43 +102,12 @@ const RailColumn = styled.aside`
}
${mediaUp('md')} {
- grid-column: 3;
+ grid-column: 2;
+ position: sticky;
+ top: ${spacing(20)};
}
`;
-const Block = styled.div`
- display: flex;
- flex-direction: column;
-
- & > * + * {
- margin-top: ${spacing(4)};
- }
-`;
-
-const SkillsRow = styled.ul`
- display: flex;
- flex-wrap: wrap;
- gap: ${spacing(2)};
- list-style: none;
- padding: 0;
-`;
-
-const SkillChip = styled.li`
- background-color: ${color('blue-5')};
- border-radius: ${radius(2)};
- color: ${semanticColor.ink};
- font-family: ${fontFamily('sans')};
- font-size: ${fontSize(4)};
- font-weight: ${FONT_WEIGHT.medium};
- padding: ${spacing(2)} ${spacing(4)};
-`;
-
-const Divider = styled.hr`
- background-color: ${semanticColor.line};
- border: none;
- height: 1px;
-`;
-
export function PartnerProfile({ partner }: { partner: MarketplacePartner }) {
const i18n = getServerI18n();
@@ -158,28 +122,14 @@ export function PartnerProfile({ partner }: { partner: MarketplacePartner }) {
-
- {partner.skills.length > 0 && (
-
- {i18n._(msg`What they do`)}
-
- {partner.skills.map((skill) => (
- {skill}
- ))}
-
-
- )}
-
-
- {i18n._(msg`Where & how`)}
-
-
+
+
+
+
-
+
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerProfileAbout.tsx b/packages/twenty-website/src/partners-marketplace/PartnerProfileAbout.tsx
new file mode 100644
index 0000000000..27a1a26625
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/PartnerProfileAbout.tsx
@@ -0,0 +1,87 @@
+import { styled } from '@linaria/react';
+
+import { color, fontFamily, fontSize, semanticColor, spacing } from '@/tokens';
+
+import { RichText } from './RichText';
+
+const About = styled.div`
+ color: ${semanticColor.inkMuted};
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(4.25)};
+ line-height: 1.58;
+ max-width: 64ch;
+ overflow-wrap: anywhere;
+
+ & p {
+ margin: 0 0 ${spacing(3.5)};
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ & strong {
+ color: ${semanticColor.ink};
+ font-weight: 600;
+ }
+
+ & h3 {
+ color: ${semanticColor.ink};
+ font-size: ${fontSize(5.5)};
+ font-weight: 600;
+ margin: ${spacing(6)} 0 ${spacing(2.5)};
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 4px;
+ }
+
+ & h4 {
+ color: ${semanticColor.ink};
+ font-size: ${fontSize(4.75)};
+ font-weight: 600;
+ margin: ${spacing(6)} 0 ${spacing(2.5)};
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 4px;
+ }
+
+ & ul {
+ display: grid;
+ gap: ${spacing(2)};
+ list-style: none;
+ margin: 0 0 ${spacing(3.5)};
+ padding: 0;
+ }
+
+ & ul li {
+ padding-left: ${spacing(5)};
+ position: relative;
+ }
+
+ & ul li::before {
+ background: ${color('blue')};
+ border-radius: 50%;
+ content: '';
+ height: 5px;
+ left: 3px;
+ position: absolute;
+ top: 9px;
+ width: 5px;
+ }
+
+ & a {
+ color: ${color('blue')};
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+`;
+
+export function PartnerProfileAbout({ description }: { description: string }) {
+ if (!description) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerProfileCtas.tsx b/packages/twenty-website/src/partners-marketplace/PartnerProfileCtas.tsx
index bb94d49ca0..6deb8cc9e2 100644
--- a/packages/twenty-website/src/partners-marketplace/PartnerProfileCtas.tsx
+++ b/packages/twenty-website/src/partners-marketplace/PartnerProfileCtas.tsx
@@ -2,96 +2,188 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
+import { IconArrowUpRight } from '@tabler/icons-react';
import { styled } from '@linaria/react';
-import { spacing } from '@/tokens';
-import { Button } from '@/ui';
+import {
+ DURATION,
+ fontFamily,
+ fontSize,
+ radius,
+ semanticColor,
+ spacing,
+} from '@/tokens';
+import { Button, ExternalLink } from '@/ui';
import { isSafeHttpUrl } from './is-safe-http-url';
+import { type PartnerLinks } from './marketplace-partner';
+import { collectPartnerLinks } from './collect-partner-links';
+import { collectPartnerLinkUrls } from './collect-partner-link-urls';
import { ProfileEyebrow } from './ProfileEyebrow';
-// Where "Contact partner" enquiries route when a partner has no direct channel
-// (calendar / LinkedIn) of their own.
-const CONTACT_EMAIL = 'rashad@twenty.com';
-
const Wrapper = styled.div`
display: flex;
flex-direction: column;
+ width: 100%;
& > * + * {
margin-top: ${spacing(3)};
}
`;
-const ButtonStack = styled.div`
+const CtaCard = styled.div`
+ background-color: ${semanticColor.surface};
+ border: 1px solid ${semanticColor.line};
+ border-radius: ${radius(2)};
display: flex;
flex-direction: column;
+ gap: ${spacing(4.5)};
+ padding: ${spacing(6)};
+ width: 100%;
+`;
- & > * {
+const PrimaryAction = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(2)};
+ width: 100%;
+
+ & > a,
+ & > button {
+ align-self: stretch;
display: flex;
+ justify-content: center;
width: 100%;
}
- & > * + * {
- margin-top: ${spacing(2)};
+ & > a[data-variant='filled'] [data-slot='content'] {
+ justify-content: center;
+ white-space: nowrap;
}
`;
+const SubNote = styled.p`
+ color: ${semanticColor.inkMuted};
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(2.75)};
+ letter-spacing: 0.03em;
+ text-align: center;
+`;
+
+const RailLinks = styled.ul`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(2)};
+ list-style: none;
+ padding: 0;
+ width: 100%;
+`;
+
+const RailLink = styled(ExternalLink)`
+ align-items: center;
+ border: 1px solid ${semanticColor.lineStrong};
+ border-radius: ${radius(1.5)};
+ color: ${semanticColor.ink};
+ display: flex;
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(3)};
+ gap: ${spacing(2.5)};
+ justify-content: space-between;
+ min-width: 0;
+ padding: ${spacing(2.75)} ${spacing(3)};
+ text-decoration: none;
+ transition:
+ border-color ${DURATION.sm} ease,
+ color ${DURATION.sm} ease;
+
+ &:hover {
+ border-color: ${semanticColor.ink};
+ color: ${semanticColor.ink};
+ }
+`;
+
+const RailLinkLabel = styled.span`
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+const RailLinkIcon = styled(IconArrowUpRight)`
+ flex-shrink: 0;
+ opacity: 0.55;
+`;
+
export function PartnerProfileCtas({
- partnerName,
calendarLink,
- linkedinUrl,
+ links,
+ linkUrls,
}: {
- partnerName: string;
calendarLink: string;
- linkedinUrl: string;
+ links: PartnerLinks;
+ linkUrls?: readonly string[];
}) {
const { i18n } = useLingui();
- const showCalendar = isSafeHttpUrl(calendarLink);
- const showLinkedin = isSafeHttpUrl(linkedinUrl);
- // No booking link → offer a direct "Contact " email instead;
- // LinkedIn, when present, still shows alongside either option.
- const showContactFallback = !showCalendar;
+ const calendarHref = isSafeHttpUrl(calendarLink) ? calendarLink : null;
+ const websiteHref =
+ links.website !== null && isSafeHttpUrl(links.website)
+ ? links.website
+ : null;
+ const rawLinkEntries =
+ linkUrls !== undefined && linkUrls.length > 0
+ ? collectPartnerLinkUrls(linkUrls)
+ : collectPartnerLinks(links);
+ const linkEntries = rawLinkEntries.filter((entry) => {
+ if (calendarHref !== null || websiteHref === null) {
+ return true;
+ }
- // Pre-filled enquiry so the visitor can send in one tap; the two trailing
- // blank lines leave room to paste their project under the prompt.
- const subject = i18n._(msg`Interested in meeting ${partnerName}`);
- const body = `${i18n._(msg`Hey, I'm interested in meeting. Here's my project:`)}\n\n`;
- const mailtoHref = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(
- subject,
- )}&body=${encodeURIComponent(body)}`;
+ return entry.href !== websiteHref;
+ });
return (
{i18n._(msg`Reach out`)}
-
- {showCalendar && (
-
+
+ {calendarHref !== null && (
+
+
+ {i18n._(msg`30 minutes, no commitment`)}
+
)}
- {showLinkedin && (
+ {calendarHref === null && websiteHref !== null && (
+
+
+
+ )}
+ {linkEntries.length > 0 && (
+
+ {linkEntries.map((link) => (
+
+
+ {link.label}
+
+
+
+ ))}
+
+ )}
+
- )}
- {showContactFallback && (
-
- )}
-
-
+
+
);
}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerRatesPanel.tsx b/packages/twenty-website/src/partners-marketplace/PartnerRatesPanel.tsx
index b9b54fedab..70fcb8b99f 100644
--- a/packages/twenty-website/src/partners-marketplace/PartnerRatesPanel.tsx
+++ b/packages/twenty-website/src/partners-marketplace/PartnerRatesPanel.tsx
@@ -8,6 +8,7 @@ import {
FONT_WEIGHT,
fontFamily,
fontSize,
+ radius,
semanticColor,
spacing,
} from '@/tokens';
@@ -15,47 +16,71 @@ import {
import { formatUsdRate } from './format-usd-rate';
import { ProfileEyebrow } from './ProfileEyebrow';
-// A quiet fact list, not a card: a single hairline separates it from the CTAs
-// above, and values stay modest so rates read as a footnote, not a headline.
-const Panel = styled.div`
- border-top: 1px solid ${semanticColor.line};
+const Wrapper = styled.div`
display: flex;
flex-direction: column;
- padding-top: ${spacing(5)};
+ width: 100%;
& > * + * {
- margin-top: ${spacing(2)};
+ margin-top: ${spacing(3)};
}
`;
+const Panel = styled.div`
+ background-color: ${semanticColor.surface};
+ border: 1px solid ${semanticColor.line};
+ border-radius: ${radius(2)};
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(2)};
+ padding: ${spacing(5.5)};
+ width: 100%;
+`;
+
const Row = styled.div`
align-items: baseline;
+ border-bottom: 1px solid ${semanticColor.line};
display: flex;
gap: ${spacing(3)};
justify-content: space-between;
+ padding-block: ${spacing(3.5)};
+
+ &:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+
+ &:first-of-type {
+ padding-top: 0;
+ }
`;
const Label = styled.span`
color: ${semanticColor.inkMuted};
- font-family: ${fontFamily('sans')};
- font-size: ${fontSize(3)};
+ flex: 1 1 auto;
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(2.75)};
+ letter-spacing: 0.06em;
+ min-width: 0;
+ text-transform: uppercase;
`;
const Value = styled.span`
color: ${semanticColor.ink};
+ flex: 0 0 auto;
font-family: ${fontFamily('sans')};
font-size: ${fontSize(4)};
font-weight: ${FONT_WEIGHT.medium};
+ text-align: right;
+ white-space: nowrap;
`;
export function PartnerRatesPanel({
hourlyRateUsd,
projectBudgetMinUsd,
- projectBudgetTypicalUsd,
}: {
hourlyRateUsd: number | null;
projectBudgetMinUsd: number | null;
- projectBudgetTypicalUsd: number | null;
}) {
const { i18n } = useLingui();
const rows: Array<{ label: string; value: string }> = [];
@@ -68,22 +93,20 @@ export function PartnerRatesPanel({
if (minimum) {
rows.push({ label: i18n._(msg`Project minimum`), value: minimum });
}
- const typical = formatUsdRate(projectBudgetTypicalUsd);
- if (typical) {
- rows.push({ label: i18n._(msg`Typical project`), value: typical });
- }
if (rows.length === 0) return null;
return (
-
+
{i18n._(msg`Rates`)}
- {rows.map((row) => (
-
-
- {row.value}
-
- ))}
-
+
+ {rows.map((row) => (
+
+
+ {row.value}
+
+ ))}
+
+
);
}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerReachFacts.tsx b/packages/twenty-website/src/partners-marketplace/PartnerReachFacts.tsx
new file mode 100644
index 0000000000..8d83570315
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/PartnerReachFacts.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { type MessageDescriptor } from '@lingui/core';
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { styled } from '@linaria/react';
+
+import { fontFamily, fontSize, radius, semanticColor, spacing } from '@/tokens';
+
+import { type MarketplacePartner } from './marketplace-partner';
+import { SERVED_GEO_LABELS } from './served-geo-labels';
+import { SPOKEN_LANGUAGE_LABELS } from './spoken-language-labels';
+import { titleCaseFallback } from './title-case-fallback';
+
+const ReachSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(3.5)};
+ width: 100%;
+`;
+
+const FactGroup = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(2)};
+`;
+
+const FactLabel = styled.span`
+ color: ${semanticColor.inkMuted};
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(2.75)};
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+`;
+
+const ChipRow = styled.ul`
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${spacing(1.5)};
+ list-style: none;
+ padding: 0;
+`;
+
+const Chip = styled.li`
+ background-color: ${semanticColor.surface};
+ border: 1px solid ${semanticColor.line};
+ border-radius: ${radius(1.5)};
+ color: ${semanticColor.ink};
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(3.5)};
+ padding: ${spacing(1.5)} ${spacing(2.5)};
+`;
+
+function resolveLabels(
+ values: readonly TValue[],
+ labels: Record,
+ translate: (descriptor: MessageDescriptor) => string,
+): string[] {
+ return values.map((value) => {
+ const descriptor = labels[value];
+ return descriptor ? translate(descriptor) : titleCaseFallback(value);
+ });
+}
+
+export function PartnerReachFacts({
+ languagesSpoken,
+ region,
+}: {
+ languagesSpoken: MarketplacePartner['languagesSpoken'];
+ region: MarketplacePartner['region'];
+}) {
+ const { i18n } = useLingui();
+ const translate = (descriptor: MessageDescriptor) => i18n._(descriptor);
+
+ const regionLabels = resolveLabels(region, SERVED_GEO_LABELS, translate);
+ const languageLabels = resolveLabels(
+ languagesSpoken,
+ SPOKEN_LANGUAGE_LABELS,
+ translate,
+ );
+
+ if (regionLabels.length === 0 && languageLabels.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {regionLabels.length > 0 && (
+
+ {i18n._(msg`Regions served`)}
+
+ {regionLabels.map((label) => (
+ {label}
+ ))}
+
+
+ )}
+ {languageLabels.length > 0 && (
+
+ {i18n._(msg`Languages`)}
+
+ {languageLabels.map((label) => (
+ {label}
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerSelectedWork.tsx b/packages/twenty-website/src/partners-marketplace/PartnerSelectedWork.tsx
new file mode 100644
index 0000000000..613aca371b
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/PartnerSelectedWork.tsx
@@ -0,0 +1,290 @@
+'use client';
+
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { styled } from '@linaria/react';
+import { useState } from 'react';
+
+import {
+ color,
+ EASING,
+ FONT_WEIGHT,
+ fontFamily,
+ fontSize,
+ mediaUp,
+ radius,
+ REDUCED_MOTION,
+ semanticColor,
+ spacing,
+} from '@/tokens';
+
+import { CaseStudyModal } from './CaseStudyModal';
+import { CaseStudyVisual } from './CaseStudyVisual';
+import { CaseStudyVisualHover } from './CaseStudyVisualHover';
+import { CaseStudyPlaceholder } from './CaseStudyPlaceholder';
+import { isSafeHttpUrl } from './is-safe-http-url';
+import { type PartnerCaseStudy } from './marketplace-partner';
+import { ProfileSectionTitle } from './ProfileSectionTitle';
+import { richTextExcerpt } from './rich-text-excerpt';
+
+const Section = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ & > * + * {
+ margin-top: ${spacing(5)};
+ }
+`;
+
+const CasesList = styled.div`
+ display: grid;
+ gap: ${spacing(3.5)};
+ grid-template-columns: minmax(0, 1fr);
+
+ ${mediaUp('md')} {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+`;
+
+const CaseCard = styled.button`
+ animation: caseCardEnter 0.55s ${EASING.standard} both;
+ appearance: none;
+ background-color: ${semanticColor.surface};
+ border: 1px solid ${semanticColor.line};
+ border-radius: ${radius(2)};
+ color: inherit;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ padding: 0;
+ text-align: left;
+ transition:
+ border-color 0.22s ${EASING.standard},
+ box-shadow 0.22s ${EASING.standard},
+ transform 0.22s ${EASING.standard};
+ width: 100%;
+
+ &:nth-child(1) {
+ animation-delay: 0ms;
+ }
+
+ &:nth-child(2) {
+ animation-delay: 70ms;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 140ms;
+ }
+
+ &:nth-child(4) {
+ animation-delay: 210ms;
+ }
+
+ @keyframes caseCardEnter {
+ from {
+ opacity: 0;
+ transform: translate3d(0, 14px, 0);
+ }
+
+ to {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+ }
+
+ &[data-layout='featured'] {
+ ${mediaUp('md')} {
+ align-items: stretch;
+ flex-direction: row;
+ grid-column: 1 / -1;
+ }
+ }
+
+ &[data-layout='featured'] ${CaseStudyVisualHover} {
+ ${mediaUp('md')} {
+ align-self: stretch;
+ flex-shrink: 0;
+ max-width: 46%;
+ width: 46%;
+ }
+ }
+
+ &[data-layout='featured'] [data-size='card'],
+ &[data-layout='featured'] ${CaseStudyVisualHover} > div {
+ ${mediaUp('md')} {
+ aspect-ratio: auto;
+ height: 100%;
+ min-height: ${spacing(52)};
+ }
+ }
+
+ &:hover {
+ border-color: ${color('blue')}44;
+ box-shadow:
+ 0 16px 40px ${color('black-10')},
+ 0 0 0 1px ${color('blue')}18;
+ transform: translateY(-3px);
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${color('blue')};
+ outline-offset: 2px;
+ }
+
+ ${REDUCED_MOTION} {
+ animation: none;
+ transition: none;
+
+ &:hover {
+ box-shadow: none;
+ transform: none;
+ }
+ }
+`;
+
+const CaseBody = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: ${spacing(2)};
+ min-width: 0;
+ padding: ${spacing(4.5)} ${spacing(5)};
+`;
+
+const CaseClient = styled.span`
+ color: ${semanticColor.inkMuted};
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(2.625)};
+ letter-spacing: 0.12em;
+ line-height: 1.2;
+ text-transform: uppercase;
+`;
+
+const CaseTitle = styled.h3`
+ color: ${semanticColor.ink};
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(4)};
+ font-weight: ${FONT_WEIGHT.medium};
+ line-height: 1.25;
+`;
+
+const CaseTeaser = styled.p`
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ color: ${semanticColor.inkMuted};
+ display: -webkit-box;
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(3.5)};
+ line-height: 1.5;
+ overflow: hidden;
+ overflow-wrap: anywhere;
+`;
+
+const CaseActionSlot = styled.div`
+ align-items: flex-end;
+ display: flex;
+ flex-shrink: 0;
+ margin-top: ${spacing(2.5)};
+ min-height: ${spacing(8.5)};
+`;
+
+const CaseAction = styled.span`
+ background: transparent;
+ border-radius: ${radius(1)};
+ color: ${color('blue')};
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(2.625)};
+ letter-spacing: 0.14em;
+ line-height: 1;
+ opacity: 0;
+ padding: ${spacing(2)} ${spacing(3)};
+ text-transform: uppercase;
+ transition:
+ background-color 0.22s ${EASING.standard},
+ opacity 0.22s ${EASING.standard};
+
+ ${CaseCard}:hover &,
+ ${CaseCard}:focus-visible & {
+ background: ${color('blue')}14;
+ opacity: 1;
+ }
+
+ ${REDUCED_MOTION} {
+ background: ${color('blue')}14;
+ opacity: 1;
+ transition: none;
+ }
+`;
+
+function caseLayout(index: number, total: number): 'compact' | 'featured' {
+ return index === 0 && total > 1 ? 'featured' : 'compact';
+}
+
+export function PartnerSelectedWork({
+ portfolio,
+}: {
+ portfolio: readonly PartnerCaseStudy[];
+}) {
+ const { i18n } = useLingui();
+ const [openIndex, setOpenIndex] = useState(null);
+
+ if (portfolio.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {i18n._(msg`Case studies`)}
+
+
+ {/* oxlint-disable eslint-plugin-react(no-array-index-key) -- portfolio lacks stable ids */}
+ {portfolio.map((caseStudy, index) => {
+ const imageUrl =
+ caseStudy.imageUrl !== null && isSafeHttpUrl(caseStudy.imageUrl)
+ ? caseStudy.imageUrl
+ : null;
+ const layout = caseLayout(index, portfolio.length);
+
+ return (
+ setOpenIndex(index)}
+ type="button"
+ >
+
+ {imageUrl !== null ? (
+
+ ) : (
+
+ )}
+
+
+ {caseStudy.client}
+ {caseStudy.title}
+ {richTextExcerpt(caseStudy.body)}
+
+ {i18n._(msg`Read case study`)}
+
+
+
+ );
+ })}
+ {/* oxlint-enable eslint-plugin-react(no-array-index-key) */}
+
+ setOpenIndex(null)}
+ />
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/PartnerServices.tsx b/packages/twenty-website/src/partners-marketplace/PartnerServices.tsx
new file mode 100644
index 0000000000..a9452c8f24
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/PartnerServices.tsx
@@ -0,0 +1,184 @@
+import { msg } from '@lingui/core/macro';
+import { styled } from '@linaria/react';
+
+import { getServerI18n } from '@/platform/i18n/get-server-i18n';
+import {
+ color,
+ EASING,
+ FONT_WEIGHT,
+ fontFamily,
+ fontSize,
+ GRADIENT,
+ mediaUp,
+ radius,
+ REDUCED_MOTION,
+ semanticColor,
+ spacing,
+} from '@/tokens';
+
+import { type PartnerService } from './marketplace-partner';
+import { ProfileSectionTitle } from './ProfileSectionTitle';
+
+const Section = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ & > * + * {
+ margin-top: ${spacing(5)};
+ }
+`;
+
+const ServicesGrid = styled.div`
+ display: grid;
+ gap: ${spacing(3.5)};
+ grid-template-columns: minmax(0, 1fr);
+
+ ${mediaUp('md')} {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+`;
+
+const ServiceCard = styled.article`
+ animation: serviceCardEnter 0.45s ${EASING.standard} both;
+ background-color: ${semanticColor.surface};
+ border: 1px solid ${semanticColor.line};
+ border-radius: ${radius(2)};
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ transition:
+ border-color 0.2s ${EASING.standard},
+ box-shadow 0.2s ${EASING.standard},
+ transform 0.2s ${EASING.standard};
+
+ &:nth-child(1) {
+ animation-delay: 0ms;
+ }
+
+ &:nth-child(2) {
+ animation-delay: 40ms;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 80ms;
+ }
+
+ &:nth-child(4) {
+ animation-delay: 120ms;
+ }
+
+ @keyframes serviceCardEnter {
+ from {
+ opacity: 0;
+ transform: translate3d(0, 8px, 0);
+ }
+
+ to {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+ }
+
+ &::before {
+ background: ${color('blue')};
+ content: '';
+ display: block;
+ flex-shrink: 0;
+ height: 2px;
+ opacity: 0.45;
+ transition: opacity 0.2s ${EASING.standard};
+ }
+
+ &:hover {
+ border-color: ${color('blue')}33;
+ box-shadow: 0 10px 28px ${color('black-10')};
+ transform: translateY(-2px);
+ }
+
+ &:hover::before {
+ opacity: 1;
+ }
+
+ ${REDUCED_MOTION} {
+ animation: none;
+ transition: none;
+
+ &::before {
+ opacity: 1;
+ }
+
+ &:hover {
+ box-shadow: none;
+ transform: none;
+ }
+ }
+`;
+
+const ServiceCap = styled.div`
+ background: ${GRADIENT.heroGlow};
+ border-bottom: 1px solid ${semanticColor.line};
+ flex-shrink: 0;
+ height: ${spacing(3.5)};
+ transition: background 0.2s ${EASING.standard};
+
+ ${ServiceCard}:hover & {
+ background:
+ linear-gradient(90deg, ${color('blue')}0d, transparent 72%),
+ ${GRADIENT.heroGlow};
+ }
+`;
+
+const ServiceBody = styled.div`
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: ${spacing(2)};
+ padding: ${spacing(4.5)} ${spacing(5)} ${spacing(5)};
+`;
+
+const ServiceTitle = styled.h3`
+ color: ${semanticColor.ink};
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(4)};
+ font-weight: ${FONT_WEIGHT.medium};
+ line-height: 1.25;
+`;
+
+const ServiceDescription = styled.p`
+ color: ${semanticColor.inkMuted};
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(3.75)};
+ line-height: 1.52;
+`;
+
+export function PartnerServices({
+ services,
+}: {
+ services: readonly PartnerService[];
+}) {
+ if (services.length === 0) {
+ return null;
+ }
+
+ const i18n = getServerI18n();
+
+ return (
+
+
+ {i18n._(msg`Services`)}
+
+
+ {services.map((service) => (
+
+
+
+ {service.title}
+ {service.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/packages/twenty-website/src/partners-marketplace/ProfileSectionTitle.tsx b/packages/twenty-website/src/partners-marketplace/ProfileSectionTitle.tsx
new file mode 100644
index 0000000000..ec6e021790
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/ProfileSectionTitle.tsx
@@ -0,0 +1,14 @@
+import { styled } from '@linaria/react';
+
+import { FONT_WEIGHT, fontFamily, fontSize, semanticColor } from '@/tokens';
+
+// Primary profile section headings (Case studies, Services). Stronger
+// than ProfileEyebrow, which stays for rail labels and secondary meta blocks.
+export const ProfileSectionTitle = styled.h2`
+ color: ${semanticColor.ink};
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(5.25)};
+ font-weight: ${FONT_WEIGHT.medium};
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+`;
diff --git a/packages/twenty-website/src/partners-marketplace/RichText.tsx b/packages/twenty-website/src/partners-marketplace/RichText.tsx
new file mode 100644
index 0000000000..e3625d6050
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/RichText.tsx
@@ -0,0 +1,40 @@
+import Markdown from 'react-markdown';
+
+import { ExternalLink } from '@/ui';
+
+// h1/h2 are demoted to h3 so partner headings never emit a second on the page
+// (the page title already owns the h1). Without this they'd unwrap to bare inline text.
+const ALLOWED = [
+ 'p',
+ 'strong',
+ 'em',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'ul',
+ 'ol',
+ 'li',
+ 'a',
+ 'br',
+ 'code',
+ 'pre',
+];
+
+export const RichText = ({ markdown }: { markdown: string }) => (
+ {children}
,
+ h2: ({ children }) => {children}
,
+ a: ({ href, title, children }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {markdown}
+
+);
diff --git a/packages/twenty-website/src/partners-marketplace/case-study-card-aspect-ratio.ts b/packages/twenty-website/src/partners-marketplace/case-study-card-aspect-ratio.ts
new file mode 100644
index 0000000000..1ccf5164df
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/case-study-card-aspect-ratio.ts
@@ -0,0 +1,2 @@
+/** Landscape crop for case-study cards on the profile page. */
+export const CASE_STUDY_CARD_ASPECT_RATIO = '16 / 9';
diff --git a/packages/twenty-website/src/partners-marketplace/case-study-modal-image-height.ts b/packages/twenty-website/src/partners-marketplace/case-study-modal-image-height.ts
new file mode 100644
index 0000000000..f1d3fcdb4f
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/case-study-modal-image-height.ts
@@ -0,0 +1,2 @@
+/** Modal hero strip — capped height so copy stays above the fold. */
+export const CASE_STUDY_MODAL_IMAGE_HEIGHT = 'clamp(148px, 18dvh, 192px)';
diff --git a/packages/twenty-website/src/partners-marketplace/case-study-modal.styles.ts b/packages/twenty-website/src/partners-marketplace/case-study-modal.styles.ts
new file mode 100644
index 0000000000..4e5b6bad48
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/case-study-modal.styles.ts
@@ -0,0 +1,260 @@
+import { styled } from '@linaria/react';
+
+import { ExternalLink } from '@/ui';
+import {
+ color,
+ EASING,
+ fontFamily,
+ fontSize,
+ mediaUp,
+ MODAL_SURFACE,
+ radius,
+ REDUCED_MOTION,
+ semanticColor,
+ SHADOW,
+ spacing,
+ Z_INDEX,
+} from '@/tokens';
+
+const ModalRoot = styled.div`
+ display: grid;
+ inset: 0;
+ padding: ${spacing(4)};
+ place-items: center;
+ position: fixed;
+ z-index: ${Z_INDEX.modal};
+
+ ${mediaUp('sm')} {
+ padding: ${spacing(6)};
+ }
+`;
+
+const ModalScrim = styled.div`
+ backdrop-filter: blur(2px);
+ background: ${MODAL_SURFACE.backdrop};
+ inset: 0;
+ position: absolute;
+`;
+
+const ModalPanel = styled.div`
+ background-color: ${semanticColor.surface};
+ border: 1px solid ${semanticColor.lineStrong};
+ border-radius: ${radius(2)};
+ box-shadow: ${SHADOW.card};
+ display: flex;
+ flex-direction: column;
+ max-height: min(88dvh, 760px);
+ overflow: hidden;
+ position: relative;
+ transition: opacity 0.18s ${EASING.standard};
+ width: min(720px, 100%);
+
+ ${REDUCED_MOTION} {
+ transition: none;
+ }
+`;
+
+const ModalHeader = styled.div`
+ flex-shrink: 0;
+ padding: ${spacing(5.5)} ${spacing(6)} ${spacing(4)};
+
+ ${mediaUp('sm')} {
+ padding: ${spacing(7)} ${spacing(7.5)} ${spacing(4)};
+ }
+`;
+
+const ModalHeaderIntro = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(4)};
+ padding-right: ${spacing(7.5)};
+`;
+
+const ModalHeaderCopy = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${spacing(2)};
+ min-width: 0;
+`;
+
+const ModalVisualSlot = styled.div`
+ border: 1px solid ${semanticColor.line};
+ border-radius: ${radius(1.5)};
+ overflow: hidden;
+`;
+
+const CloseButton = styled.button`
+ background: transparent;
+ border: 0;
+ color: ${semanticColor.inkMuted};
+ cursor: pointer;
+ line-height: 1;
+ padding: 0;
+ position: absolute;
+ right: ${spacing(4.5)};
+ top: ${spacing(4)};
+
+ &:hover {
+ color: ${semanticColor.ink};
+ }
+`;
+
+const ModalClient = styled.span`
+ color: ${semanticColor.inkMuted};
+ display: block;
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(2.75)};
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+`;
+
+const ModalTitle = styled.h2`
+ font-size: ${fontSize(5.5)};
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ line-height: 1.2;
+`;
+
+const ModalBody = styled.div`
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: ${spacing(5)} ${spacing(6)};
+
+ ${mediaUp('sm')} {
+ padding: ${spacing(5.5)} ${spacing(7.5)};
+ }
+`;
+
+const ModalDesc = styled.div`
+ color: ${semanticColor.inkMuted};
+ font-size: ${fontSize(4)};
+ line-height: 1.58;
+ overflow-wrap: anywhere;
+
+ & p {
+ margin: 0 0 ${spacing(3)};
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ & strong {
+ color: ${semanticColor.ink};
+ font-weight: 600;
+ }
+
+ & h3,
+ & h4 {
+ color: ${semanticColor.ink};
+ font-size: ${fontSize(3.75)};
+ font-weight: 600;
+ margin: ${spacing(5)} 0 ${spacing(2.5)};
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 4px;
+ }
+
+ & ul {
+ display: grid;
+ gap: ${spacing(2)};
+ list-style: none;
+ padding: 0;
+ }
+
+ & ul li {
+ padding-left: ${spacing(5)};
+ position: relative;
+ }
+
+ & ul li::before {
+ background: ${color('blue')};
+ border-radius: 50%;
+ content: '';
+ height: 5px;
+ left: 3px;
+ position: absolute;
+ top: 9px;
+ width: 5px;
+ }
+
+ & a {
+ color: ${color('blue')};
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+`;
+
+const ModalFoot = styled.div`
+ align-items: center;
+ border-top: 1px solid ${semanticColor.line};
+ display: flex;
+ flex-shrink: 0;
+ gap: ${spacing(4)};
+ padding: ${spacing(4.5)} ${spacing(6)};
+
+ ${mediaUp('sm')} {
+ padding: ${spacing(4.5)} ${spacing(7.5)};
+ }
+`;
+
+const ModalLink = styled(ExternalLink)`
+ color: ${color('blue')};
+ display: inline-flex;
+ font-family: ${fontFamily('mono')};
+ font-size: ${fontSize(3)};
+ gap: ${spacing(1.75)};
+ letter-spacing: 0.04em;
+ text-decoration: none;
+`;
+
+const ModalNav = styled.div`
+ display: flex;
+ gap: ${spacing(2)};
+ margin-left: auto;
+`;
+
+const NavButton = styled.button`
+ background: transparent;
+ border: 1px solid ${semanticColor.lineStrong};
+ border-radius: ${radius(1.5)};
+ color: ${semanticColor.ink};
+ cursor: pointer;
+ font-family: ${fontFamily('sans')};
+ font-size: ${fontSize(3.25)};
+ font-weight: 600;
+ padding: ${spacing(2.25)} ${spacing(4)};
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.35;
+ }
+`;
+
+const NavButtonPrimary = styled(NavButton)`
+ background: ${semanticColor.ink};
+ border-color: ${semanticColor.ink};
+ color: ${color('white')};
+`;
+
+export const caseStudyModalStyles = {
+ CloseButton,
+ ModalBody,
+ ModalClient,
+ ModalDesc,
+ ModalFoot,
+ ModalHeader,
+ ModalHeaderCopy,
+ ModalHeaderIntro,
+ ModalLink,
+ ModalNav,
+ ModalPanel,
+ ModalRoot,
+ ModalScrim,
+ ModalTitle,
+ ModalVisualSlot,
+ NavButton,
+ NavButtonPrimary,
+};
diff --git a/packages/twenty-website/src/partners-marketplace/case-study-sample-image.ts b/packages/twenty-website/src/partners-marketplace/case-study-sample-image.ts
new file mode 100644
index 0000000000..11ace8624a
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/case-study-sample-image.ts
@@ -0,0 +1,3 @@
+/** PartnerPage-style featured-work screenshot (contain on white), for local dummy data. */
+export const CASE_STUDY_PARTNERPAGE_SAMPLE =
+ 'https://content.partnerpage.io/eyJidWNrZXQiOiJwYXJ0bmVycGFnZS5wcm9kIiwia2V5IjoibWVkaWEvZmVhdHVyZWRfd29yay9mYzA5MzYzYi0yOWE5LTQ1M2EtYWEyNy1jMDUxNzI2MjE0ZTEvMTc5YWM3MzAtY2NiNy00MzcxLTkzZGQtNzljMWVkMTg2NTRjLnBuZyIsImVkaXRzIjp7InRvRm9ybWF0Ijoid2VicCIsInJlc2l6ZSI6eyJmaXQiOiJjb250YWluIiwiYmFja2dyb3VuZCI6eyJyIjoyNTUsImciOjI1NSwiYiI6MjU1LCJhbHphaCI6MH19fX0=';
diff --git a/packages/twenty-website/src/partners-marketplace/collect-partner-link-urls.ts b/packages/twenty-website/src/partners-marketplace/collect-partner-link-urls.ts
new file mode 100644
index 0000000000..ca8838fc73
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/collect-partner-link-urls.ts
@@ -0,0 +1,23 @@
+import { isSafeHttpUrl } from './is-safe-http-url';
+import { formatPartnerLinkLabel } from './format-partner-link-label';
+import { type PartnerLinkEntry } from './partner-link-entry';
+
+export function collectPartnerLinkUrls(
+ urls: readonly string[],
+): readonly PartnerLinkEntry[] {
+ const seen = new Set();
+
+ return urls.flatMap((raw) => {
+ if (!isSafeHttpUrl(raw)) {
+ return [];
+ }
+
+ const href = raw.includes('://') ? raw : `https://${raw}`;
+ if (seen.has(href)) {
+ return [];
+ }
+
+ seen.add(href);
+ return [{ href, label: formatPartnerLinkLabel(href) }];
+ });
+}
diff --git a/packages/twenty-website/src/partners-marketplace/collect-partner-links.ts b/packages/twenty-website/src/partners-marketplace/collect-partner-links.ts
new file mode 100644
index 0000000000..909f7fe44f
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/collect-partner-links.ts
@@ -0,0 +1,32 @@
+import { isSafeHttpUrl } from './is-safe-http-url';
+import { type PartnerLinks } from './marketplace-partner';
+import { formatPartnerLinkLabel } from './format-partner-link-label';
+import { type PartnerLinkEntry } from './partner-link-entry';
+
+const LINK_ORDER: readonly (keyof PartnerLinks)[] = [
+ 'website',
+ 'linkedin',
+ 'x',
+ 'github',
+];
+
+export function collectPartnerLinks(
+ links: PartnerLinks,
+): readonly PartnerLinkEntry[] {
+ const seen = new Set();
+
+ return LINK_ORDER.flatMap((key) => {
+ const raw = links[key];
+ if (raw === null || !isSafeHttpUrl(raw)) {
+ return [];
+ }
+
+ const href = raw.includes('://') ? raw : `https://${raw}`;
+ if (seen.has(href)) {
+ return [];
+ }
+
+ seen.add(href);
+ return [{ href, label: formatPartnerLinkLabel(href) }];
+ });
+}
diff --git a/packages/twenty-website/src/partners-marketplace/completeness-score.ts b/packages/twenty-website/src/partners-marketplace/completeness-score.ts
new file mode 100644
index 0000000000..fd8f7ef401
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/completeness-score.ts
@@ -0,0 +1,14 @@
+import { type MarketplacePartner } from './marketplace-partner';
+
+export const completenessScore = (p: MarketplacePartner): number => {
+ let s = 0;
+ if (p.description.trim().length >= 120) s += 2;
+ if (p.profilePictureUrl) s += 1;
+ if (p.services.length >= 1) s += 2;
+ if (p.portfolio.length >= 1) s += 2;
+ if (p.clients.length >= 1) s += 1;
+ if (p.calendarLink) s += 1;
+ if (p.hourlyRateUsd != null || p.projectBudgetMinUsd != null) s += 1;
+ if (p.partnerScope.length >= 1) s += 1;
+ return s;
+};
diff --git a/packages/twenty-website/src/partners-marketplace/data/partner-scope-options.ts b/packages/twenty-website/src/partners-marketplace/data/partner-scope-options.ts
new file mode 100644
index 0000000000..3f3c225e78
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/data/partner-scope-options.ts
@@ -0,0 +1,36 @@
+import { msg } from '@lingui/core/macro';
+
+// Kept in sync with partner-application wizard options — duplicated here so
+// partners-marketplace does not import from partner-application.
+export const PARTNER_SCOPE_OPTIONS = [
+ {
+ value: 'ADVISORY',
+ label: msg`Advisory & Discovery`,
+ description: msg`Upfront consulting, scoping, strategy.`,
+ examples: msg`CRM audit · Requirements · Process mapping · ROI · RevOps · Vendor selection`,
+ },
+ {
+ value: 'SOLUTIONING',
+ label: msg`Solutioning`,
+ description: msg`What an admin can do without writing code.`,
+ examples: msg`Data modeling · Migrations · No-code workflows · Dashboards · SSO/SCIM · Integrations`,
+ },
+ {
+ value: 'DEVELOPMENT',
+ label: msg`Custom Development`,
+ description: msg`Anything that needs a developer.`,
+ examples: msg`Custom Apps · Scripts · AI/agent integrations`,
+ },
+ {
+ value: 'HOSTING',
+ label: msg`Hosting & Infrastructure`,
+ description: msg`Anything that needs devops skills.`,
+ examples: msg`Self-hosted (Docker/K8s) · Cloud architecture · Scaling · Security · Monitoring`,
+ },
+ {
+ value: 'SUPPORT',
+ label: msg`Training, Adoption & Support`,
+ description: msg`User-side rollout & ongoing support.`,
+ examples: msg`Onboarding · Documentation · Change management · L1/L2 support · Managed services`,
+ },
+] as const;
diff --git a/packages/twenty-website/src/partners-marketplace/dummy-marketplace-partners.ts b/packages/twenty-website/src/partners-marketplace/dummy-marketplace-partners.ts
new file mode 100644
index 0000000000..02948d250a
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/dummy-marketplace-partners.ts
@@ -0,0 +1,408 @@
+import { type MarketplacePartner } from './marketplace-partner';
+
+export const DUMMY_PARTNERS: MarketplacePartner[] = [
+ {
+ slug: 'atelier-sigma',
+ name: 'Atelier Sigma',
+ description: `**Senior CRM partner for venture-backed teams, with Twenty at the core.**
+
+We migrate startups, scale-ups and investment firms onto Twenty and design the RevOps layer around it: data model, pipelines, automations and the integrations that keep everything clean. Five years in, with more than 40 rollouts behind us across France, the UK and the wider EU.
+
+Twenty is the foundation. Around it we build modular GTM stacks that are structured, automated and genuinely maintainable, so your team can run them without us in the room.
+
+#### Who we work with
+- Seed to Series C teams moving off spreadsheets or a first CRM
+- Teams migrating from Salesforce, HubSpot, Pipedrive, Affinity or Zoho
+- Operators who want a system they own, not a black box
+
+#### Why teams pick us
+- 100% focused on Twenty and its ecosystem
+- Five years and 40+ rollouts running in production
+- Open-source-native: we build on the core, not around it`,
+ calendarLink: 'https://cal.com/atelier-sigma/intro',
+ partnerScope: ['SOLUTIONING', 'DEVELOPMENT', 'HOSTING'],
+ region: ['EUROPE', 'MENA'],
+ languagesSpoken: ['FRENCH', 'ENGLISH'],
+ hourlyRateUsd: 140,
+ projectBudgetMinUsd: 8000,
+ links: {
+ website: 'https://ateliersigma.com',
+ linkedin: 'https://linkedin.com/company/atelier-sigma',
+ x: 'https://x.com/ateliersigma',
+ github: 'https://github.com/atelier-sigma',
+ },
+ profilePictureUrl: 'https://picsum.photos/seed/atelier-sigma-team/600/600',
+ city: 'Paris',
+ country: 'France',
+ skills: ['Migrations', 'RevOps', 'API & SDK'],
+ services: [
+ {
+ title: 'CRM migration',
+ description:
+ 'Controlled moves from Salesforce, HubSpot, Pipedrive or spreadsheets. We restructure the data model first, then cut over with no lost history.',
+ },
+ {
+ title: 'RevOps architecture',
+ description:
+ 'Pipelines, stages, scoring and reporting designed around how your team actually sells, not a generic template.',
+ },
+ {
+ title: 'Custom Twenty development',
+ description:
+ "Workflows, custom objects and API or SDK integrations built on Twenty's open-source core.",
+ },
+ {
+ title: 'Ongoing support',
+ description:
+ 'A monthly retainer for changes, training and new automations as the team grows.',
+ },
+ ],
+ portfolio: [
+ {
+ client: 'Brevio Health',
+ title: 'Salesforce to Twenty, 22k contacts, zero downtime',
+ body: `We restructured a nine-year-old Salesforce org into a clean Twenty data model, then cut a 40-person sales team over in a single weekend.
+
+- 22,000 contacts migrated with full history preserved
+- Live on Monday morning with no downtime
+- 14 legacy custom objects collapsed into 5`,
+ imageUrl: 'https://picsum.photos/seed/brevio-health/960/540',
+ link: 'https://example.com/case/brevio',
+ },
+ {
+ client: 'Lumen Freight',
+ title: 'A RevOps stack for an outbound team',
+ body: `Designed the pipelines, lead scoring and Clay plus Lemlist integrations that an outbound growth team runs on day to day.
+
+- Lead scoring wired to enrichment from Clay
+- Sequenced outreach synced back into Twenty
+- Qualified-meeting throughput up across the quarter`,
+ imageUrl: 'https://picsum.photos/seed/lumen-freight/960/540',
+ link: null,
+ },
+ {
+ client: 'Maison Valette',
+ title: 'Self-hosted Twenty for a regulated client',
+ body: `Deployed and hardened a self-hosted Twenty instance for a private-bank client with strict EU data residency.
+
+- SSO and audit logging on a private VPC
+- EU-only data residency, no third-party processors
+- Handover with runbooks and on-call training`,
+ imageUrl: null,
+ link: 'https://example.com/case/valette',
+ },
+ ],
+ clients: [
+ {
+ name: 'Stripe',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/b/ba/Stripe_Logo%2C_revised_2016.svg',
+ },
+ {
+ name: 'Notion',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/e/e9/Notion-logo.svg',
+ },
+ {
+ name: 'Figma',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/3/33/Figma-logo.svg',
+ },
+ {
+ name: 'Slack',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/b/b9/Slack_Technologies_Logo.svg',
+ },
+ {
+ name: 'Airtable',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/4/4b/Airtable_Logo.svg',
+ },
+ {
+ name: 'Intercom',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/0/0b/Intercom_logotype.png',
+ },
+ {
+ name: 'GitLab',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/e/e1/GitLab_logo.svg',
+ },
+ {
+ name: 'Asana',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/3/3b/Asana_logo.svg',
+ },
+ {
+ name: 'Vercel',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/5/5e/Vercel_logo_black.svg',
+ },
+ {
+ name: 'Dropbox',
+ logoUrl:
+ 'https://upload.wikimedia.org/wikipedia/commons/c/cb/Dropbox_logo_2017.svg',
+ },
+ ],
+ },
+ {
+ slug: 'northbeam-studio',
+ name: 'Northbeam Studio',
+ description:
+ 'RevOps and reporting for B2B SaaS teams that have outgrown spreadsheets and their first CRM. We turn messy pipelines into forecasts leadership actually trusts, redesign stages and scoring around how reps really sell, wire up the dashboards your board keeps asking for, connect the enrichment and outreach tools your growth team already runs, and document every last piece of it so the system keeps working long after we hand it back over. Based in London, working across the UK and EU in English, and yes this sentence is deliberately far too long to prove the card never breaks no matter how much a partner writes.',
+ calendarLink: 'https://cal.com/northbeam-studio/intro',
+ partnerScope: ['ADVISORY', 'SOLUTIONING'],
+ region: ['EUROPE', 'US'],
+ languagesSpoken: ['ENGLISH'],
+ hourlyRateUsd: 175,
+ projectBudgetMinUsd: 12000,
+ links: {
+ website: 'https://northbeamstudio.co.uk',
+ linkedin: 'https://linkedin.com/company/northbeam-studio',
+ x: 'https://x.com/northbeamstudio',
+ github: null,
+ },
+ profilePictureUrl: '',
+ city: 'London',
+ country: 'United Kingdom',
+ skills: ['RevOps', 'Reporting', 'Forecasting'],
+ services: [
+ {
+ title: 'RevOps advisory',
+ description:
+ 'Pipeline design, stage definitions and forecasting models tuned to how your team actually sells.',
+ },
+ ],
+ portfolio: [
+ {
+ client: 'Helio Analytics',
+ title: 'Board-ready forecasting on Twenty',
+ body: `Rebuilt pipeline stages and a weighted forecast model so leadership could trust weekly numbers again.
+
+- Stage definitions aligned to rep behaviour
+- Board dashboard wired to live Twenty data
+- Forecast accuracy improved within one quarter`,
+ imageUrl: 'https://picsum.photos/seed/northbeam-helio/900/360',
+ link: 'https://example.com/case/helio',
+ },
+ ],
+ clients: [
+ { name: 'HubSpot', logoUrl: 'https://cdn.simpleicons.org/hubspot' },
+ { name: 'Segment', logoUrl: 'https://cdn.simpleicons.org/segment' },
+ { name: 'Amplitude', logoUrl: 'https://cdn.simpleicons.org/amplitude' },
+ ],
+ },
+ {
+ slug: '9-dots-ventures',
+ name: '9 Dots Ventures',
+ description:
+ 'Boutique CRM implementer for real-estate workflows and WhatsApp automation, with self-hosted Twenty deployments across APAC.',
+ calendarLink: 'https://cal.com/9dots-ventures/intro',
+ partnerScope: ['DEVELOPMENT', 'HOSTING'],
+ region: ['APAC'],
+ languagesSpoken: ['ENGLISH', 'CHINESE'],
+ hourlyRateUsd: 95,
+ projectBudgetMinUsd: 6000,
+ links: {
+ website: 'https://9dotsventures.sg',
+ linkedin: 'https://linkedin.com/company/9-dots-ventures',
+ x: null,
+ github: 'https://github.com/9dots-ventures',
+ },
+ profilePictureUrl: '',
+ city: 'Singapore',
+ country: 'Singapore',
+ skills: ['Self-hosted', 'WhatsApp automation', 'Real estate'],
+ services: [
+ {
+ title: 'Self-hosted Twenty',
+ description:
+ 'Private deployments on your infrastructure with backups, monitoring and upgrade runbooks.',
+ },
+ {
+ title: 'WhatsApp workflow automation',
+ description:
+ 'Lead capture and follow-up flows that connect WhatsApp conversations back into Twenty.',
+ },
+ ],
+ portfolio: [
+ {
+ client: 'Harbour Estates',
+ title: 'WhatsApp leads into Twenty for a property group',
+ body: `Connected WhatsApp Business to Twenty so inbound property enquiries land as qualified leads automatically.
+
+- WhatsApp conversations synced to contact records
+- Agent assignment rules based on listing region
+- Response-time SLAs tracked in Twenty`,
+ imageUrl: 'https://picsum.photos/seed/9dots-harbour/900/360',
+ link: 'https://example.com/case/harbour',
+ },
+ ],
+ clients: [
+ { name: 'Twilio', logoUrl: 'https://cdn.simpleicons.org/twilio' },
+ { name: 'Docker', logoUrl: 'https://cdn.simpleicons.org/docker' },
+ {
+ name: 'DigitalOcean',
+ logoUrl: 'https://cdn.simpleicons.org/digitalocean',
+ },
+ ],
+ },
+ {
+ slug: 'halden-roe',
+ name: 'Halden & Roe',
+ description:
+ 'Migrations off legacy CRMs for mid-market teams. German and English, with EU data residency by default.',
+ calendarLink: 'https://cal.com/halden-roe/intro',
+ partnerScope: ['SOLUTIONING', 'SUPPORT'],
+ region: ['EUROPE'],
+ languagesSpoken: ['GERMAN', 'ENGLISH'],
+ hourlyRateUsd: 120,
+ projectBudgetMinUsd: 10000,
+ links: {
+ website: 'https://haldenroe.de',
+ linkedin: 'https://linkedin.com/company/halden-roe',
+ x: null,
+ github: null,
+ },
+ profilePictureUrl: '',
+ city: 'Berlin',
+ country: 'Germany',
+ skills: ['Migrations', 'EU compliance'],
+ services: [
+ {
+ title: 'Legacy CRM migration',
+ description:
+ 'Structured cutovers from Salesforce, Dynamics or Zoho with data cleanup and user training.',
+ },
+ {
+ title: 'Post-go-live support',
+ description:
+ 'Retained support for schema changes, automations and onboarding new team members.',
+ },
+ ],
+ portfolio: [],
+ clients: [],
+ },
+ {
+ slug: 'verza-collective',
+ name: 'Verza Collective',
+ description:
+ 'No-code operations and automations for scaling startups. 150 projects shipped across Europe and LATAM, from first pipeline to full RevOps tooling.',
+ calendarLink: 'https://cal.com/verza-collective/intro',
+ partnerScope: [
+ 'ADVISORY',
+ 'SOLUTIONING',
+ 'DEVELOPMENT',
+ 'HOSTING',
+ 'SUPPORT',
+ ],
+ region: ['EUROPE', 'LATAM'],
+ languagesSpoken: ['SPANISH', 'ENGLISH'],
+ hourlyRateUsd: 110,
+ projectBudgetMinUsd: 7500,
+ links: {
+ website: 'https://verzacollective.com',
+ linkedin: 'https://linkedin.com/company/verza-collective',
+ x: 'https://x.com/verzacollective',
+ github: null,
+ },
+ profilePictureUrl: '',
+ city: 'Madrid',
+ country: 'Spain',
+ skills: ['Automations', 'No-code ops', 'RevOps'],
+ services: [
+ {
+ title: 'Operations automation',
+ description:
+ 'Workflows and integrations that remove manual handoffs between sales, success and finance.',
+ },
+ {
+ title: 'Twenty rollout',
+ description:
+ 'End-to-end implementation from data model design through team training and documentation.',
+ },
+ ],
+ portfolio: [],
+ clients: [],
+ },
+ {
+ slug: 'kioko-labs',
+ name: 'Kioko Labs',
+ description:
+ 'Self-hosted Twenty deployments and API work for teams across Africa and the Gulf.',
+ calendarLink: 'https://cal.com/kioko-labs/intro',
+ partnerScope: ['HOSTING', 'DEVELOPMENT'],
+ region: ['AFRICA', 'MENA'],
+ languagesSpoken: ['ENGLISH', 'SWAHILI'],
+ hourlyRateUsd: 85,
+ projectBudgetMinUsd: 5000,
+ links: {
+ website: 'https://kiokolabs.co.ke',
+ linkedin: 'https://linkedin.com/company/kioko-labs',
+ x: null,
+ github: 'https://github.com/kioko-labs',
+ },
+ profilePictureUrl: '',
+ city: 'Nairobi',
+ country: 'Kenya',
+ skills: ['Self-hosted', 'API integrations'],
+ services: [
+ {
+ title: 'Managed Twenty hosting',
+ description:
+ 'Secure self-hosted instances with monitoring, backups and regional data residency options.',
+ },
+ ],
+ portfolio: [],
+ clients: [],
+ },
+ {
+ slug: 'tomas-brandt',
+ name: 'Tomas Brandt',
+ description:
+ 'Independent Twenty consultant, ex-Salesforce admin. Available for short solutioning engagements.',
+ calendarLink: 'https://cal.com/tomas-brandt/intro',
+ partnerScope: ['SOLUTIONING'],
+ region: ['US'],
+ languagesSpoken: ['ENGLISH'],
+ hourlyRateUsd: null,
+ projectBudgetMinUsd: null,
+ links: {
+ website: null,
+ linkedin: 'https://linkedin.com/in/tomas-brandt',
+ x: null,
+ github: null,
+ },
+ profilePictureUrl: '',
+ city: 'Oakland',
+ country: 'United States',
+ skills: ['Solutioning'],
+ services: [],
+ portfolio: [],
+ clients: [],
+ },
+ {
+ slug: 'benjamin-reynolds',
+ name: 'Benjamin Reynolds',
+ description: '',
+ calendarLink: '',
+ partnerScope: ['SOLUTIONING'],
+ region: ['US'],
+ languagesSpoken: ['ENGLISH'],
+ hourlyRateUsd: null,
+ projectBudgetMinUsd: null,
+ links: {
+ website: null,
+ linkedin: null,
+ x: null,
+ github: null,
+ },
+ profilePictureUrl: '',
+ city: 'Oakland',
+ country: 'United States',
+ skills: [],
+ services: [],
+ portfolio: [],
+ clients: [],
+ },
+];
diff --git a/packages/twenty-website/src/partners-marketplace/fetch-live-marketplace-partners.test.ts b/packages/twenty-website/src/partners-marketplace/fetch-live-marketplace-partners.test.ts
index 64c21e46b7..d020042876 100644
--- a/packages/twenty-website/src/partners-marketplace/fetch-live-marketplace-partners.test.ts
+++ b/packages/twenty-website/src/partners-marketplace/fetch-live-marketplace-partners.test.ts
@@ -12,6 +12,8 @@ describe('fetchLiveMarketplacePartners', () => {
it('normalizes the CRM payload (micros -> USD, links -> URLs, nulls)', async () => {
mockedFetch.mockResolvedValue({
+ ok: true,
+ count: 1,
partners: [
{
name: 'Acme',
@@ -23,8 +25,8 @@ describe('fetchLiveMarketplacePartners', () => {
calendarLink: { primaryLinkUrl: 'cal.com/acme' },
hourlyRate: { amountMicros: 150_000_000, currencyCode: 'USD' },
projectBudgetMin: null,
- projectBudgetTypical: null,
linkedin: { primaryLinkUrl: 'https://linkedin.com/acme' },
+ website: { primaryLinkUrl: 'https://agency-twenty.com' },
profilePicture: null,
skills: null,
city: null,
@@ -37,19 +39,26 @@ describe('fetchLiveMarketplacePartners', () => {
{
slug: 'acme',
name: 'Acme',
- introduction: 'Hi',
+ description: 'Hi',
languagesSpoken: ['ENGLISH'],
partnerScope: ['ADVISORY'],
region: ['US'],
calendarLink: 'https://cal.com/acme',
hourlyRateUsd: 150,
projectBudgetMinUsd: null,
- projectBudgetTypicalUsd: null,
- linkedinUrl: 'https://linkedin.com/acme',
+ links: {
+ website: 'https://agency-twenty.com',
+ linkedin: 'https://linkedin.com/acme',
+ x: null,
+ github: null,
+ },
profilePictureUrl: '',
skills: [],
city: '',
country: '',
+ services: [],
+ portfolio: [],
+ clients: [],
},
]);
});
diff --git a/packages/twenty-website/src/partners-marketplace/fetch-live-marketplace-partners.ts b/packages/twenty-website/src/partners-marketplace/fetch-live-marketplace-partners.ts
index c8146cce8f..cad4bf7cb2 100644
--- a/packages/twenty-website/src/partners-marketplace/fetch-live-marketplace-partners.ts
+++ b/packages/twenty-website/src/partners-marketplace/fetch-live-marketplace-partners.ts
@@ -1,14 +1,12 @@
import { type MarketplacePartner } from './marketplace-partner';
+import { type CurrencyValue, type LinkValue } from './marketplace-api-types';
+import { linkUrl } from './link-url';
+import { microsToUsd } from './micros-to-usd';
import { partnersApiFetch } from './partners-api-fetch';
import { type PartnerScope } from './partner-scopes';
import { type ServedGeo } from './served-geos';
import { type SpokenLanguage } from './spoken-languages';
-type CurrencyValue = { amountMicros: number; currencyCode: string } | null;
-type LinkValue = { primaryLinkUrl: string | null } | null;
-
-// The raw CRM shape, before normalization (currency wrappers, link objects,
-// nullable multi-selects).
type ApiPartner = {
name: string;
slug: string;
@@ -19,29 +17,19 @@ type ApiPartner = {
calendarLink: LinkValue;
hourlyRate: CurrencyValue;
projectBudgetMin: CurrencyValue;
- projectBudgetTypical: CurrencyValue;
linkedin: LinkValue;
+ website: LinkValue;
profilePicture: LinkValue;
skills: string[] | null;
city: string | null;
country: string | null;
};
-type ApiResponse = { partners: ApiPartner[] };
-
-const normalizeUrl = (raw: string | null | undefined): string => {
- if (!raw) return '';
- return raw.includes('://') ? raw : `https://${raw}`;
+type ApiResponse = {
+ partners?: ApiPartner[];
+ ok?: boolean;
};
-const linkUrl = (link: LinkValue): string =>
- normalizeUrl(link?.primaryLinkUrl ?? '');
-
-const microsToUsd = (currency: CurrencyValue): number | null =>
- currency && typeof currency.amountMicros === 'number'
- ? Math.round(currency.amountMicros / 1_000_000)
- : null;
-
// The live source: normalize the CRM payload into MarketplacePartner. Degrades
// to [] on any failure (matching the old getPartners) so the page renders the
// empty state rather than crashing.
@@ -50,25 +38,33 @@ export async function fetchLiveMarketplacePartners(): Promise<
> {
try {
const data = (await partnersApiFetch('/s/partners')) as ApiResponse;
- if (!Array.isArray(data.partners)) {
+ const partners = data.partners;
+ if (!Array.isArray(partners)) {
throw new Error('partners API response missing partners array');
}
- return data.partners.map((apiPartner) => ({
+ return partners.map((apiPartner) => ({
slug: apiPartner.slug,
name: apiPartner.name,
- introduction: apiPartner.introduction,
+ description: apiPartner.introduction ?? '',
languagesSpoken: apiPartner.languagesSpoken,
partnerScope: apiPartner.partnerScope ?? [],
region: apiPartner.region,
calendarLink: linkUrl(apiPartner.calendarLink),
hourlyRateUsd: microsToUsd(apiPartner.hourlyRate),
projectBudgetMinUsd: microsToUsd(apiPartner.projectBudgetMin),
- projectBudgetTypicalUsd: microsToUsd(apiPartner.projectBudgetTypical),
- linkedinUrl: linkUrl(apiPartner.linkedin),
+ links: {
+ linkedin: linkUrl(apiPartner.linkedin) || null,
+ website: linkUrl(apiPartner.website) || null,
+ x: null,
+ github: null,
+ },
profilePictureUrl: linkUrl(apiPartner.profilePicture),
skills: apiPartner.skills ?? [],
city: apiPartner.city ?? '',
country: apiPartner.country ?? '',
+ services: [],
+ portfolio: [],
+ clients: [],
}));
} catch (error) {
console.error('[partners-marketplace] live fetch failed:', error);
diff --git a/packages/twenty-website/src/partners-marketplace/fetch-live-partner-profile.test.ts b/packages/twenty-website/src/partners-marketplace/fetch-live-partner-profile.test.ts
new file mode 100644
index 0000000000..f7e92e881d
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/fetch-live-partner-profile.test.ts
@@ -0,0 +1,88 @@
+import { fetchLivePartnerProfile } from './fetch-live-partner-profile';
+import { partnersApiFetch } from './partners-api-fetch';
+
+jest.mock('./partners-api-fetch');
+
+const mockedFetch = partnersApiFetch as jest.MockedFunction<
+ typeof partnersApiFetch
+>;
+
+describe('fetchLivePartnerProfile', () => {
+ afterEach(() => jest.clearAllMocks());
+
+ it('maps profile payload including flat link URLs and nested collections', async () => {
+ mockedFetch.mockResolvedValue({
+ ok: true,
+ partner: {
+ name: 'Acme',
+ slug: 'acme',
+ introduction: '## About\n\nFull markdown bio.',
+ languagesSpoken: ['ENGLISH'],
+ partnerScope: ['ADVISORY'],
+ region: ['US'],
+ calendarLink: { primaryLinkUrl: 'https://cal.com/acme' },
+ hourlyRate: { amountMicros: 150_000_000, currencyCode: 'USD' },
+ projectBudgetMin: { amountMicros: 10_000_000_000, currencyCode: 'USD' },
+ linkedin: { primaryLinkUrl: 'https://linkedin.com/acme' },
+ website: { primaryLinkUrl: 'https://agency-twenty.com' },
+ profilePicture: { primaryLinkUrl: 'https://cdn.example/photo.jpg' },
+ profileLinks: [
+ { primaryLinkUrl: 'https://agency-twenty.com' },
+ { primaryLinkUrl: 'https://github.com/acme' },
+ ],
+ skills: ['CRM'],
+ city: 'Paris',
+ country: 'FRANCE',
+ services: [{ title: 'Discovery', description: 'Workshops' }],
+ portfolio: [
+ {
+ client: 'Globex',
+ title: 'Rollout',
+ body: 'Markdown case study',
+ imageUrl: null,
+ link: 'https://example.com/case',
+ },
+ ],
+ },
+ });
+
+ expect(await fetchLivePartnerProfile('acme')).toEqual({
+ slug: 'acme',
+ name: 'Acme',
+ description: '## About\n\nFull markdown bio.',
+ languagesSpoken: ['ENGLISH'],
+ partnerScope: ['ADVISORY'],
+ region: ['US'],
+ calendarLink: 'https://cal.com/acme',
+ hourlyRateUsd: 150,
+ projectBudgetMinUsd: 10000,
+ links: {
+ website: 'https://agency-twenty.com',
+ linkedin: 'https://linkedin.com/acme',
+ x: null,
+ github: null,
+ },
+ linkUrls: ['https://agency-twenty.com', 'https://github.com/acme'],
+ profilePictureUrl: 'https://cdn.example/photo.jpg',
+ skills: ['CRM'],
+ city: 'Paris',
+ country: 'FRANCE',
+ services: [{ title: 'Discovery', description: 'Workshops' }],
+ portfolio: [
+ {
+ client: 'Globex',
+ title: 'Rollout',
+ body: 'Markdown case study',
+ imageUrl: null,
+ link: 'https://example.com/case',
+ },
+ ],
+ clients: [],
+ });
+ });
+
+ it('returns undefined when the API reports not found', async () => {
+ mockedFetch.mockResolvedValue({ ok: false, reason: 'NOT_FOUND' });
+ expect(await fetchLivePartnerProfile('missing')).toBeUndefined();
+ });
+});
diff --git a/packages/twenty-website/src/partners-marketplace/fetch-live-partner-profile.ts b/packages/twenty-website/src/partners-marketplace/fetch-live-partner-profile.ts
new file mode 100644
index 0000000000..c273d9a1d2
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/fetch-live-partner-profile.ts
@@ -0,0 +1,91 @@
+import { type CurrencyValue, type LinkValue } from './marketplace-api-types';
+import { linkUrl } from './link-url';
+import { microsToUsd } from './micros-to-usd';
+import {
+ type MarketplacePartner,
+ type PartnerCaseStudy,
+ type PartnerService,
+} from './marketplace-partner';
+import { partnersApiFetch } from './partners-api-fetch';
+import { type PartnerScope } from './partner-scopes';
+import { type ServedGeo } from './served-geos';
+import { type SpokenLanguage } from './spoken-languages';
+
+type ApiProfilePartner = {
+ name: string;
+ slug: string;
+ introduction: string;
+ languagesSpoken: SpokenLanguage[];
+ partnerScope: PartnerScope[] | null;
+ region: ServedGeo[];
+ calendarLink: LinkValue;
+ hourlyRate: CurrencyValue;
+ projectBudgetMin: CurrencyValue;
+ linkedin: LinkValue;
+ website: LinkValue;
+ profilePicture: LinkValue;
+ profileLinks: LinkValue[];
+ skills: string[] | null;
+ city: string | null;
+ country: string | null;
+ services: PartnerService[];
+ portfolio: PartnerCaseStudy[];
+};
+
+type ApiProfileResponse =
+ | { ok: true; partner: ApiProfilePartner }
+ | { ok: false; reason: string };
+
+const mapProfilePartner = (
+ apiPartner: ApiProfilePartner,
+): MarketplacePartner => {
+ const linkUrls = apiPartner.profileLinks
+ .map((link) => linkUrl(link))
+ .filter((url) => url.length > 0);
+
+ return {
+ slug: apiPartner.slug,
+ name: apiPartner.name,
+ description: apiPartner.introduction ?? '',
+ languagesSpoken: apiPartner.languagesSpoken,
+ partnerScope: apiPartner.partnerScope ?? [],
+ region: apiPartner.region,
+ calendarLink: linkUrl(apiPartner.calendarLink),
+ hourlyRateUsd: microsToUsd(apiPartner.hourlyRate),
+ projectBudgetMinUsd: microsToUsd(apiPartner.projectBudgetMin),
+ links: {
+ linkedin: linkUrl(apiPartner.linkedin) || null,
+ website: linkUrl(apiPartner.website) || null,
+ x: null,
+ github: null,
+ },
+ linkUrls,
+ profilePictureUrl: linkUrl(apiPartner.profilePicture),
+ skills: apiPartner.skills ?? [],
+ city: apiPartner.city ?? '',
+ country: apiPartner.country ?? '',
+ services: apiPartner.services ?? [],
+ portfolio: apiPartner.portfolio ?? [],
+ clients: [],
+ };
+};
+
+export async function fetchLivePartnerProfile(
+ slug: string,
+): Promise {
+ try {
+ const data = (await partnersApiFetch(
+ `/s/partner-by-slug?slug=${encodeURIComponent(slug)}`,
+ { cache: 'no-store' },
+ )) as ApiProfileResponse;
+
+ if (!data.ok || !('partner' in data)) {
+ return undefined;
+ }
+
+ return mapProfilePartner(data.partner);
+ } catch (error) {
+ console.error('[partners-marketplace] profile fetch failed:', error);
+ return undefined;
+ }
+}
diff --git a/packages/twenty-website/src/partners-marketplace/filter-partners.test.ts b/packages/twenty-website/src/partners-marketplace/filter-partners.test.ts
index aeb686f910..415e005cd7 100644
--- a/packages/twenty-website/src/partners-marketplace/filter-partners.test.ts
+++ b/packages/twenty-website/src/partners-marketplace/filter-partners.test.ts
@@ -8,19 +8,26 @@ const partner = (
): MarketplacePartner => ({
slug: 'p',
name: 'P',
- introduction: '',
+ description: '',
calendarLink: '',
partnerScope: [],
region: [],
languagesSpoken: [],
hourlyRateUsd: null,
projectBudgetMinUsd: null,
- projectBudgetTypicalUsd: null,
- linkedinUrl: '',
+ links: {
+ website: null,
+ linkedin: null,
+ x: null,
+ github: null,
+ },
profilePictureUrl: '',
city: '',
country: '',
skills: [],
+ services: [],
+ portfolio: [],
+ clients: [],
...overrides,
});
diff --git a/packages/twenty-website/src/partners-marketplace/format-partner-link-label.ts b/packages/twenty-website/src/partners-marketplace/format-partner-link-label.ts
new file mode 100644
index 0000000000..0b7547a4ca
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/format-partner-link-label.ts
@@ -0,0 +1,14 @@
+/** Human-readable label from a URL — usually the hostname, path included when useful. */
+export function formatPartnerLinkLabel(url: string): string {
+ try {
+ const parsed = new URL(url.includes('://') ? url : `https://${url}`);
+ const host = parsed.hostname.replace(/^www\./i, '');
+ const path =
+ parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/$/, '');
+ const label = path ? `${host}${path}` : host;
+
+ return label.length > 52 ? `${label.slice(0, 49)}…` : label;
+ } catch {
+ return url;
+ }
+}
diff --git a/packages/twenty-website/src/partners-marketplace/get-marketplace-partner-by-slug.ts b/packages/twenty-website/src/partners-marketplace/get-marketplace-partner-by-slug.ts
index 9f5eaea7b2..5c4cb1846a 100644
--- a/packages/twenty-website/src/partners-marketplace/get-marketplace-partner-by-slug.ts
+++ b/packages/twenty-website/src/partners-marketplace/get-marketplace-partner-by-slug.ts
@@ -1,9 +1,15 @@
-import { fetchLiveMarketplacePartners } from './fetch-live-marketplace-partners';
+import { DUMMY_PARTNERS } from './dummy-marketplace-partners';
+import { fetchLivePartnerProfile } from './fetch-live-partner-profile';
import { type MarketplacePartner } from './marketplace-partner';
-export async function getMarketplacePartnerBySlug(
+const useDummy = process.env.NEXT_PUBLIC_USE_DUMMY_PARTNERS === '1';
+
+export const getMarketplacePartnerBySlug = async (
slug: string,
-): Promise {
- const partners = await fetchLiveMarketplacePartners();
- return partners.find((partner) => partner.slug === slug) ?? null;
-}
+): Promise => {
+ if (useDummy) {
+ return DUMMY_PARTNERS.find((partner) => partner.slug === slug);
+ }
+
+ return fetchLivePartnerProfile(slug);
+};
diff --git a/packages/twenty-website/src/partners-marketplace/get-marketplace-partners.ts b/packages/twenty-website/src/partners-marketplace/get-marketplace-partners.ts
new file mode 100644
index 0000000000..fee98f2ac6
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/get-marketplace-partners.ts
@@ -0,0 +1,15 @@
+import { DUMMY_PARTNERS } from './dummy-marketplace-partners';
+import { fetchLiveMarketplacePartners } from './fetch-live-marketplace-partners';
+import { type MarketplacePartner } from './marketplace-partner';
+import { rankPartners } from './rank-partners';
+
+const useDummy = process.env.NEXT_PUBLIC_USE_DUMMY_PARTNERS === '1';
+
+export const getMarketplacePartners = async (): Promise<
+ MarketplacePartner[]
+> => {
+ const partners = useDummy
+ ? DUMMY_PARTNERS
+ : await fetchLiveMarketplacePartners();
+ return rankPartners(partners);
+};
diff --git a/packages/twenty-website/src/partners-marketplace/is-ghost-partner.ts b/packages/twenty-website/src/partners-marketplace/is-ghost-partner.ts
new file mode 100644
index 0000000000..d24f2e9091
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/is-ghost-partner.ts
@@ -0,0 +1,4 @@
+import { type MarketplacePartner } from './marketplace-partner';
+
+export const isGhost = (p: MarketplacePartner): boolean =>
+ p.description.trim().length < 40 && !p.profilePictureUrl;
diff --git a/packages/twenty-website/src/partners-marketplace/link-url.ts b/packages/twenty-website/src/partners-marketplace/link-url.ts
new file mode 100644
index 0000000000..e90edb9cff
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/link-url.ts
@@ -0,0 +1,5 @@
+import { type LinkValue } from './marketplace-api-types';
+import { normalizeUrl } from './normalize-url';
+
+export const linkUrl = (link: LinkValue): string =>
+ normalizeUrl(link?.primaryLinkUrl ?? '');
diff --git a/packages/twenty-website/src/partners-marketplace/marketplace-api-types.ts b/packages/twenty-website/src/partners-marketplace/marketplace-api-types.ts
new file mode 100644
index 0000000000..af562acf8f
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/marketplace-api-types.ts
@@ -0,0 +1,5 @@
+export type CurrencyValue = {
+ amountMicros: number;
+ currencyCode: string;
+} | null;
+export type LinkValue = { primaryLinkUrl: string | null } | null;
diff --git a/packages/twenty-website/src/partners-marketplace/marketplace-copy.ts b/packages/twenty-website/src/partners-marketplace/marketplace-copy.ts
new file mode 100644
index 0000000000..21d0e17c35
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/marketplace-copy.ts
@@ -0,0 +1,6 @@
+import { msg } from '@lingui/core/macro';
+
+// Shared marketplace vocabulary — keep list filters and profile sections aligned.
+export const MARKETPLACE_COPY = {
+ partnerScopeHeading: msg`What they cover`,
+} as const;
diff --git a/packages/twenty-website/src/partners-marketplace/marketplace-partner.ts b/packages/twenty-website/src/partners-marketplace/marketplace-partner.ts
index f42bb29df0..a2f892b8c5 100644
--- a/packages/twenty-website/src/partners-marketplace/marketplace-partner.ts
+++ b/packages/twenty-website/src/partners-marketplace/marketplace-partner.ts
@@ -2,22 +2,40 @@ import { type PartnerScope } from './partner-scopes';
import { type ServedGeo } from './served-geos';
import { type SpokenLanguage } from './spoken-languages';
-// The normalized partner shape the marketplace renders — already mapped out of
-// the CRM's currency/link wrappers (micros → USD, link objects → URL strings).
+export type PartnerService = { title: string; description: string };
+export type PartnerCaseStudy = {
+ client: string;
+ title: string;
+ body: string; // markdown
+ imageUrl: string | null;
+ link: string | null;
+};
+export type PartnerClient = { name: string; logoUrl: string | null };
+export type PartnerLinks = {
+ website: string | null;
+ linkedin: string | null;
+ x: string | null;
+ github: string | null;
+};
+
export type MarketplacePartner = {
slug: string;
name: string;
- introduction: string;
+ description: string; // markdown; was `introduction`
calendarLink: string;
partnerScope: readonly PartnerScope[];
region: readonly ServedGeo[];
languagesSpoken: readonly SpokenLanguage[];
hourlyRateUsd: number | null;
projectBudgetMinUsd: number | null;
- projectBudgetTypicalUsd: number | null;
- linkedinUrl: string;
+ links: PartnerLinks;
+ /** Flat profile URLs from `/s/partner-by-slug`; preferred over typed `links` on profile pages. */
+ linkUrls?: readonly string[];
profilePictureUrl: string;
city: string;
country: string;
skills: readonly string[];
+ services: readonly PartnerService[];
+ portfolio: readonly PartnerCaseStudy[];
+ clients: readonly PartnerClient[];
};
diff --git a/packages/twenty-website/src/partners-marketplace/marketplace-partners-source.ts b/packages/twenty-website/src/partners-marketplace/marketplace-partners-source.ts
new file mode 100644
index 0000000000..f7671fc179
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/marketplace-partners-source.ts
@@ -0,0 +1,2 @@
+export { getMarketplacePartners } from './get-marketplace-partners';
+export { getMarketplacePartnerBySlug } from './get-marketplace-partner-by-slug';
diff --git a/packages/twenty-website/src/partners-marketplace/micros-to-usd.ts b/packages/twenty-website/src/partners-marketplace/micros-to-usd.ts
new file mode 100644
index 0000000000..c7dea26552
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/micros-to-usd.ts
@@ -0,0 +1,6 @@
+import { type CurrencyValue } from './marketplace-api-types';
+
+export const microsToUsd = (currency: CurrencyValue): number | null =>
+ currency && typeof currency.amountMicros === 'number'
+ ? Math.round(currency.amountMicros / 1_000_000)
+ : null;
diff --git a/packages/twenty-website/src/partners-marketplace/normalize-url.ts b/packages/twenty-website/src/partners-marketplace/normalize-url.ts
new file mode 100644
index 0000000000..e403e568ce
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/normalize-url.ts
@@ -0,0 +1,7 @@
+export const normalizeUrl = (raw: string | null | undefined): string => {
+ if (!raw) {
+ return '';
+ }
+
+ return raw.includes('://') ? raw : `https://${raw}`;
+};
diff --git a/packages/twenty-website/src/partners-marketplace/partner-link-entries.test.ts b/packages/twenty-website/src/partners-marketplace/partner-link-entries.test.ts
new file mode 100644
index 0000000000..4f50a895cd
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/partner-link-entries.test.ts
@@ -0,0 +1,58 @@
+import { collectPartnerLinkUrls } from './collect-partner-link-urls';
+import { collectPartnerLinks } from './collect-partner-links';
+import { formatPartnerLinkLabel } from './format-partner-link-label';
+
+describe('formatPartnerLinkLabel', () => {
+ it('shows the hostname for a website URL', () => {
+ expect(formatPartnerLinkLabel('https://www.agency-twenty.com')).toBe(
+ 'agency-twenty.com',
+ );
+ });
+
+ it('includes a path when present', () => {
+ expect(
+ formatPartnerLinkLabel('https://linkedin.com/company/atelier-sigma'),
+ ).toBe('linkedin.com/company/atelier-sigma');
+ });
+});
+
+describe('collectPartnerLinks', () => {
+ it('returns website before social links and dedupes identical URLs', () => {
+ expect(
+ collectPartnerLinks({
+ website: 'https://agency-twenty.com',
+ linkedin: 'https://linkedin.com/company/acme',
+ x: 'https://x.com/acme',
+ github: 'https://agency-twenty.com',
+ }),
+ ).toEqual([
+ {
+ href: 'https://agency-twenty.com',
+ label: 'agency-twenty.com',
+ },
+ {
+ href: 'https://linkedin.com/company/acme',
+ label: 'linkedin.com/company/acme',
+ },
+ {
+ href: 'https://x.com/acme',
+ label: 'x.com/acme',
+ },
+ ]);
+ });
+});
+
+describe('collectPartnerLinkUrls', () => {
+ it('preserves API order and dedupes identical URLs', () => {
+ expect(
+ collectPartnerLinkUrls([
+ 'https://agency-twenty.com',
+ 'https://github.com/acme',
+ 'https://agency-twenty.com',
+ ]),
+ ).toEqual([
+ { href: 'https://agency-twenty.com', label: 'agency-twenty.com' },
+ { href: 'https://github.com/acme', label: 'github.com/acme' },
+ ]);
+ });
+});
diff --git a/packages/twenty-website/src/partners-marketplace/partner-link-entry.ts b/packages/twenty-website/src/partners-marketplace/partner-link-entry.ts
new file mode 100644
index 0000000000..209dd8011c
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/partner-link-entry.ts
@@ -0,0 +1,4 @@
+export type PartnerLinkEntry = {
+ href: string;
+ label: string;
+};
diff --git a/packages/twenty-website/src/partners-marketplace/partner-scope-labels.ts b/packages/twenty-website/src/partners-marketplace/partner-scope-labels.ts
index 27c9a50fd7..4fcffdf966 100644
--- a/packages/twenty-website/src/partners-marketplace/partner-scope-labels.ts
+++ b/packages/twenty-website/src/partners-marketplace/partner-scope-labels.ts
@@ -1,14 +1,8 @@
import { type MessageDescriptor } from '@lingui/core';
-import { msg } from '@lingui/core/macro';
+import { PARTNER_SCOPE_OPTIONS } from './data/partner-scope-options';
import { type PartnerScope } from './partner-scopes';
-// Mirrors the Partner.partnerScope ("Categories") option labels in the
-// twenty-partners CRM.
-export const PARTNER_SCOPE_LABELS: Record = {
- ADVISORY: msg`Advisory & Discovery`,
- SOLUTIONING: msg`Solutioning`,
- DEVELOPMENT: msg`Custom Development`,
- HOSTING: msg`Hosting & Infrastructure`,
- SUPPORT: msg`Training & Adoption`,
-};
+export const PARTNER_SCOPE_LABELS = Object.fromEntries(
+ PARTNER_SCOPE_OPTIONS.map((option) => [option.value, option.label]),
+) as Record;
diff --git a/packages/twenty-website/src/partners-marketplace/partner-scope-priority.test.ts b/packages/twenty-website/src/partners-marketplace/partner-scope-priority.test.ts
new file mode 100644
index 0000000000..7b12ffba72
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/partner-scope-priority.test.ts
@@ -0,0 +1,16 @@
+import { topScopes } from './top-scopes';
+
+test('keeps the two highest-priority scopes and counts the rest', () => {
+ const result = topScopes([
+ 'SUPPORT',
+ 'SOLUTIONING',
+ 'HOSTING',
+ 'DEVELOPMENT',
+ 'ADVISORY',
+ ]);
+ expect(result.shown).toEqual(['SOLUTIONING', 'DEVELOPMENT']);
+ expect(result.rest).toBe(3);
+});
+test('no overflow counter when two or fewer', () => {
+ expect(topScopes(['ADVISORY', 'SUPPORT']).rest).toBe(0);
+});
diff --git a/packages/twenty-website/src/partners-marketplace/partners-api-fetch.ts b/packages/twenty-website/src/partners-marketplace/partners-api-fetch.ts
index 0fef59dbea..2315776611 100644
--- a/packages/twenty-website/src/partners-marketplace/partners-api-fetch.ts
+++ b/packages/twenty-website/src/partners-marketplace/partners-api-fetch.ts
@@ -3,7 +3,15 @@
// missing so the seam's catch can fall back to [] cleanly.
const REVALIDATE_SECONDS = 300;
-export async function partnersApiFetch(path: string): Promise {
+type PartnersApiFetchOptions = {
+ /** Profile pages are force-dynamic; skip the Data Cache so edits show immediately. */
+ cache?: RequestCache;
+};
+
+export async function partnersApiFetch(
+ path: string,
+ options: PartnersApiFetchOptions = {},
+): Promise {
const baseUrl = process.env.TWENTY_PARTNERS_API_URL;
const apiKey = process.env.TWENTY_PARTNERS_API_KEY;
if (baseUrl === undefined || apiKey === undefined) {
@@ -15,7 +23,9 @@ export async function partnersApiFetch(path: string): Promise {
Accept: 'application/json',
Authorization: `Bearer ${apiKey}`,
},
- next: { revalidate: REVALIDATE_SECONDS },
+ ...(options.cache === 'no-store'
+ ? { cache: 'no-store' as const }
+ : { next: { revalidate: REVALIDATE_SECONDS } }),
});
if (!response.ok) {
diff --git a/packages/twenty-website/src/partners-marketplace/rank-partners.test.ts b/packages/twenty-website/src/partners-marketplace/rank-partners.test.ts
new file mode 100644
index 0000000000..ae6e3150f9
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/rank-partners.test.ts
@@ -0,0 +1,70 @@
+import { type MarketplacePartner } from './marketplace-partner';
+import { completenessScore } from './completeness-score';
+import { isGhost } from './is-ghost-partner';
+import { rankPartners } from './rank-partners';
+
+const base: MarketplacePartner = {
+ slug: 'x',
+ name: 'X',
+ description: '',
+ calendarLink: '',
+ partnerScope: [],
+ region: [],
+ languagesSpoken: [],
+ hourlyRateUsd: null,
+ projectBudgetMinUsd: null,
+ links: {
+ website: null,
+ linkedin: null,
+ x: null,
+ github: null,
+ },
+ profilePictureUrl: '',
+ city: '',
+ country: '',
+ skills: [],
+ services: [],
+ portfolio: [],
+ clients: [],
+};
+
+const rich: MarketplacePartner = {
+ ...base,
+ slug: 'rich',
+ name: 'Rich',
+ description: 'x'.repeat(200),
+ profilePictureUrl: 'http://img',
+ partnerScope: ['SOLUTIONING'],
+ hourlyRateUsd: 100,
+ calendarLink: 'http://cal',
+ services: [{ title: 's', description: 'd' }],
+ portfolio: [
+ {
+ client: 'c',
+ title: 't',
+ body: 'b',
+ imageUrl: null,
+ link: null,
+ },
+ ],
+ clients: [{ name: 'n', logoUrl: null }],
+};
+const thin: MarketplacePartner = {
+ ...base,
+ slug: 'thin',
+ name: 'Thin',
+ description: 'a real short blurb about us — thin but visible',
+};
+const ghost: MarketplacePartner = { ...base, slug: 'ghost', name: 'Ghost' };
+
+test('rich scores higher than thin', () => {
+ expect(completenessScore(rich)).toBeGreaterThan(completenessScore(thin));
+});
+test('ghost detected, thin is not', () => {
+ expect(isGhost(ghost)).toBe(true);
+ expect(isGhost(thin)).toBe(false);
+});
+test('rankPartners hides ghosts and orders rich before thin', () => {
+ const out = rankPartners([thin, ghost, rich]);
+ expect(out.map((p) => p.slug)).toEqual(['rich', 'thin']);
+});
diff --git a/packages/twenty-website/src/partners-marketplace/rank-partners.ts b/packages/twenty-website/src/partners-marketplace/rank-partners.ts
new file mode 100644
index 0000000000..a816e5fcc2
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/rank-partners.ts
@@ -0,0 +1,15 @@
+import { completenessScore } from './completeness-score';
+import { isGhost } from './is-ghost-partner';
+import { type MarketplacePartner } from './marketplace-partner';
+
+export const rankPartners = (
+ partners: readonly MarketplacePartner[],
+): MarketplacePartner[] =>
+ partners
+ .filter((p) => !isGhost(p))
+ .slice()
+ .sort(
+ (a, b) =>
+ completenessScore(b) - completenessScore(a) ||
+ a.name.localeCompare(b.name),
+ );
diff --git a/packages/twenty-website/src/partners-marketplace/resolve-partner-scope-cards.test.ts b/packages/twenty-website/src/partners-marketplace/resolve-partner-scope-cards.test.ts
new file mode 100644
index 0000000000..5596526fae
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/resolve-partner-scope-cards.test.ts
@@ -0,0 +1,33 @@
+import { PARTNER_SCOPE_OPTIONS } from './data/partner-scope-options';
+import { PARTNER_SCOPES } from './partner-scopes';
+import { resolvePartnerScopeCards } from './resolve-partner-scope-cards';
+
+describe('resolvePartnerScopeCards', () => {
+ it('returns only selected scopes in priority order', () => {
+ const cards = resolvePartnerScopeCards([
+ 'HOSTING',
+ 'ADVISORY',
+ 'SOLUTIONING',
+ ]);
+
+ expect(cards.map((card) => card.value)).toEqual([
+ 'SOLUTIONING',
+ 'ADVISORY',
+ 'HOSTING',
+ ]);
+ });
+
+ it('returns an empty list when nothing is selected', () => {
+ expect(resolvePartnerScopeCards([])).toEqual([]);
+ });
+
+ it('has a scope-card option for every PartnerScope value', () => {
+ const coveredValues = new Set(
+ PARTNER_SCOPE_OPTIONS.map((option) => option.value),
+ );
+
+ for (const scope of PARTNER_SCOPES) {
+ expect(coveredValues.has(scope)).toBe(true);
+ }
+ });
+});
diff --git a/packages/twenty-website/src/partners-marketplace/resolve-partner-scope-cards.ts b/packages/twenty-website/src/partners-marketplace/resolve-partner-scope-cards.ts
new file mode 100644
index 0000000000..e0507f4934
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/resolve-partner-scope-cards.ts
@@ -0,0 +1,19 @@
+import { PARTNER_SCOPE_OPTIONS } from './data/partner-scope-options';
+import { SCOPE_PRIORITY } from './scope-priority-order';
+import { type PartnerScope } from './partner-scopes';
+
+export function resolvePartnerScopeCards(scopes: readonly PartnerScope[]) {
+ const selected = new Set(scopes);
+
+ return SCOPE_PRIORITY.flatMap((value) => {
+ if (!selected.has(value)) {
+ return [];
+ }
+
+ const option = PARTNER_SCOPE_OPTIONS.find(
+ (scopeOption) => scopeOption.value === value,
+ );
+
+ return option ? [option] : [];
+ });
+}
diff --git a/packages/twenty-website/src/partners-marketplace/rich-text-excerpt.test.ts b/packages/twenty-website/src/partners-marketplace/rich-text-excerpt.test.ts
new file mode 100644
index 0000000000..d059b650ef
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/rich-text-excerpt.test.ts
@@ -0,0 +1,23 @@
+import { richTextExcerpt } from './rich-text-excerpt';
+
+test('strips markdown syntax to plain text', () => {
+ expect(
+ richTextExcerpt('**Bold** lead.\n\n#### Heading\n\n- item one\n- item two'),
+ ).toBe('Bold lead. Heading item one item two');
+});
+test('truncates with an ellipsis', () => {
+ const long = 'word '.repeat(60).trim();
+ const out = richTextExcerpt(long, 50);
+ expect(out.length).toBeLessThanOrEqual(51);
+ expect(out.endsWith('…')).toBe(true);
+});
+test('preserves markdown-marker characters used inline, not as syntax', () => {
+ expect(richTextExcerpt('We use C# and ship up-to-date tooling.')).toBe(
+ 'We use C# and ship up-to-date tooling.',
+ );
+});
+test('strips a blockquote marker only at the start of a line', () => {
+ expect(richTextExcerpt('> A quote\n\nRegular text > inline.')).toBe(
+ 'A quote Regular text > inline.',
+ );
+});
diff --git a/packages/twenty-website/src/partners-marketplace/rich-text-excerpt.ts b/packages/twenty-website/src/partners-marketplace/rich-text-excerpt.ts
new file mode 100644
index 0000000000..de0473dc41
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/rich-text-excerpt.ts
@@ -0,0 +1,14 @@
+export const richTextExcerpt = (markdown: string, maxChars = 220): string => {
+ const plain = markdown
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
+ .replace(/^\s{0,3}>\s?/gm, '')
+ .replace(/^\s{0,3}[-*+]\s+/gm, '')
+ .replace(/[*_~`]/g, '')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return plain.length > maxChars
+ ? plain.slice(0, maxChars).trimEnd() + '…'
+ : plain;
+};
diff --git a/packages/twenty-website/src/partners-marketplace/scope-priority-order.ts b/packages/twenty-website/src/partners-marketplace/scope-priority-order.ts
new file mode 100644
index 0000000000..c657b0dbf0
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/scope-priority-order.ts
@@ -0,0 +1,9 @@
+import { type PartnerScope } from './partner-scopes';
+
+export const SCOPE_PRIORITY: PartnerScope[] = [
+ 'SOLUTIONING',
+ 'DEVELOPMENT',
+ 'ADVISORY',
+ 'HOSTING',
+ 'SUPPORT',
+];
diff --git a/packages/twenty-website/src/partners-marketplace/top-scopes.ts b/packages/twenty-website/src/partners-marketplace/top-scopes.ts
new file mode 100644
index 0000000000..30e387c262
--- /dev/null
+++ b/packages/twenty-website/src/partners-marketplace/top-scopes.ts
@@ -0,0 +1,13 @@
+import { type PartnerScope } from './partner-scopes';
+import { SCOPE_PRIORITY } from './scope-priority-order';
+
+export const topScopes = (scope: readonly PartnerScope[], n = 2) => {
+ const sorted = scope.toSorted(
+ (scopeA, scopeB) =>
+ SCOPE_PRIORITY.indexOf(scopeA) - SCOPE_PRIORITY.indexOf(scopeB),
+ );
+ return {
+ shown: sorted.slice(0, n),
+ rest: Math.max(0, sorted.length - n),
+ };
+};
diff --git a/yarn.lock b/yarn.lock
index a2b8863543..38683df291 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -53465,6 +53465,7 @@ __metadata:
next-with-linaria: "npm:^1.3.0"
react: "npm:19.2.3"
react-dom: "npm:19.2.3"
+ react-markdown: "npm:^10.1.0"
server-only: "npm:^0.0.1"
stripe: "npm:^20.3.1"
three: "npm:^0.184.0"