Marketplace glowup — live partner profiles, case studies & matching (website) (#23016)

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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23016?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Rashad Karanouh
2026-07-21 11:03:46 +02:00
committed by GitHub
parent c3975e8243
commit 6742cfe861
61 changed files with 3015 additions and 338 deletions
+1
View File
@@ -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",
@@ -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({
<Menu communityStats={communityStats} scheme="light" />
<main>
<MarketplaceHeader />
<Suspense fallback={null}>
<Suspense fallback={<MarketplaceListSkeleton />}>
<MarketplaceClient partners={partners} />
</Suspense>
</main>
@@ -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<Array<{ slug: string }>> {
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),
};
}
@@ -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<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<Element | null>(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<HTMLElement>(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(
<modal.ModalRoot>
<modal.ModalScrim aria-hidden="true" onClick={onClose} />
<modal.ModalPanel
ref={panelRef}
aria-label={i18n._(msg`Case study`)}
aria-modal="true"
role="dialog"
>
<modal.ModalHeader>
<modal.CloseButton
ref={closeButtonRef}
aria-label={i18n._(msg`Close`)}
onClick={onClose}
type="button"
>
<IconX aria-hidden="true" size={24} stroke={1.5} />
</modal.CloseButton>
<modal.ModalHeaderIntro>
<modal.ModalHeaderCopy>
<modal.ModalClient>{caseStudy.client}</modal.ModalClient>
<modal.ModalTitle>{caseStudy.title}</modal.ModalTitle>
</modal.ModalHeaderCopy>
{imageUrl !== null ? (
<modal.ModalVisualSlot>
<CaseStudyVisual
alt={caseStudy.title}
imageUrl={imageUrl}
size="modal"
/>
</modal.ModalVisualSlot>
) : null}
</modal.ModalHeaderIntro>
</modal.ModalHeader>
<modal.ModalBody ref={bodyRef}>
<modal.ModalDesc>
<RichText markdown={caseStudy.body} />
</modal.ModalDesc>
</modal.ModalBody>
<modal.ModalFoot>
{outboundLink !== null ? (
<modal.ModalLink href={outboundLink}>
{i18n._(msg`View case study`)}
</modal.ModalLink>
) : null}
<modal.ModalNav>
<modal.NavButton
disabled={isPreviousDisabled}
onClick={handlePrevious}
type="button"
>
{i18n._(msg`Previous`)}
</modal.NavButton>
<modal.NavButtonPrimary
disabled={isNextDisabled}
onClick={handleNext}
type="button"
>
{i18n._(msg`Next`)}
</modal.NavButtonPrimary>
</modal.ModalNav>
</modal.ModalFoot>
</modal.ModalPanel>
</modal.ModalRoot>,
document.body,
);
}
@@ -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 (
<PlaceholderFrame aria-hidden="true">
<PlaceholderInner>
<ClientMark>{clientInitials(client)}</ClientMark>
</PlaceholderInner>
</PlaceholderFrame>
);
}
@@ -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 (
<VisualFrame data-size={size}>
<img alt={alt ?? ''} loading="lazy" src={imageUrl} />
</VisualFrame>
);
}
@@ -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);
}
`;
@@ -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}
/>
<FilterDropdown
label={msg`Categories`}
label={MARKETPLACE_COPY.partnerScopeHeading}
onToggle={onToggleCategory}
optionLabels={PARTNER_SCOPE_LABELS}
options={PARTNER_SCOPES}
@@ -13,11 +13,11 @@ const CardGrid = styled.div`
${mediaUp('md')} {
gap: ${spacing(8)};
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
${mediaUp('lg')} {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
`;
@@ -0,0 +1,157 @@
import { styled } from '@linaria/react';
import {
color,
mediaUp,
radius,
REDUCED_MOTION,
semanticColor,
spacing,
} from '@/tokens';
import { SectionShell } from '@/ui';
const AVATAR_SIZE_PX = 48;
const SKELETON_CARD_COUNT = 6;
const SkeletonBlock = styled.div`
animation: marketplaceSkeletonPulse 1.4s ease-in-out infinite;
background-color: ${semanticColor.line};
border-radius: ${radius(1)};
@keyframes marketplaceSkeletonPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
${REDUCED_MOTION} {
animation: none;
}
`;
const CardGrid = styled.div`
display: grid;
gap: ${spacing(6)};
grid-template-columns: 1fr;
${mediaUp('md')} {
gap: ${spacing(8)};
grid-template-columns: repeat(2, minmax(0, 1fr));
}
${mediaUp('lg')} {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
`;
const CardShell = styled.div`
background-color: ${color('white')};
border: 1px solid ${semanticColor.line};
border-radius: ${radius(2)};
display: flex;
flex-direction: column;
gap: ${spacing(3.5)};
padding: ${spacing(5.5)} ${spacing(5.5)} ${spacing(4.5)};
`;
const CardTop = styled.div`
align-items: center;
display: flex;
gap: ${spacing(3.25)};
`;
const HeaderText = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${spacing(0.75)};
min-width: 0;
`;
const AvatarBlock = styled(SkeletonBlock)`
border-radius: ${radius(1.5)};
flex-shrink: 0;
height: ${AVATAR_SIZE_PX}px;
width: ${AVATAR_SIZE_PX}px;
`;
const NameBar = styled(SkeletonBlock)`
height: ${spacing(4.5)};
width: 62%;
`;
const LocationBar = styled(SkeletonBlock)`
height: ${spacing(3)};
width: 42%;
`;
const IntroBlock = styled.div`
display: flex;
flex-direction: column;
gap: ${spacing(1.25)};
`;
const IntroLine = styled(SkeletonBlock)<{ $width?: string }>`
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 (
<CardShell aria-hidden="true">
<CardTop>
<AvatarBlock />
<HeaderText>
<NameBar />
<LocationBar />
</HeaderText>
</CardTop>
<IntroBlock>
<IntroLine />
<IntroLine />
<IntroLine $width="78%" />
</IntroBlock>
<CardFoot>
<ScopeBar />
<CtaBar />
</CardFoot>
</CardShell>
);
}
export function MarketplaceListSkeleton() {
return (
<SectionShell rhythm="section" scheme="light">
<CardGrid>
{Array.from({ length: SKELETON_CARD_COUNT }, (_, index) => (
<PartnerCardSkeleton key={index} />
))}
</CardGrid>
</SectionShell>
);
}
@@ -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 (
<CardArticle aria-labelledby={headingId} style={style}>
@@ -186,20 +192,22 @@ export function PartnerCard({ partner, index }: PartnerCardProps) {
<PartnerName id={headingId}>
<NameLink href={profileHref}>{partner.name}</NameLink>
</PartnerName>
{linkedinSafe && (
{linkedinHref !== null && (
<LinkedinIconLink
href={partner.linkedinUrl}
href={linkedinHref}
aria-label={i18n._(msg`View ${partner.name} on LinkedIn`)}
>
<IconBrandLinkedin size={16} aria-hidden="true" />
</LinkedinIconLink>
)}
</NameRow>
<LocationEyebrow>{locationLine}</LocationEyebrow>
{locationLine.length > 0 && (
<LocationEyebrow>{locationLine}</LocationEyebrow>
)}
</HeaderText>
</CardHeader>
<Introduction>{partner.introduction}</Introduction>
<Introduction>{descriptionExcerpt}</Introduction>
<Divider aria-hidden="true" />
@@ -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 (
<Section aria-labelledby="partner-coverage-title">
<ProfileSectionTitle id="partner-coverage-title">
{i18n._(MARKETPLACE_COPY.partnerScopeHeading)}
</ProfileSectionTitle>
{hasScopes && (
<CoverageGroups>
{scopeCards.map((scope) => (
<CoverageGroup key={scope.value}>
<CoverageLabel>{i18n._(scope.label)}</CoverageLabel>
<Body as="p" muted size="sm">
{i18n._(msg`ex. ${i18n._(scope.examples)}`)}
</Body>
</CoverageGroup>
))}
</CoverageGroups>
)}
{hasSkills && (
<SkillsBlock data-separated={hasScopes ? 'true' : undefined}>
<ProfileEyebrow>{i18n._(msg`Technical skills`)}</ProfileEyebrow>
<SkillsRow aria-label={i18n._(msg`Skills`)}>
{skills.map((skill) => (
<SkillChip key={skill}>{skill}</SkillChip>
))}
</SkillsRow>
</SkillsBlock>
)}
</Section>
);
}
@@ -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<TValue extends string>(
values: readonly TValue[],
labels: Record<TValue, MessageDescriptor>,
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 (
<FactsDl>
{regionText && (
<FactRow>
<FactLabel>{i18n._(msg`Regions`)}</FactLabel>
<FactValue>{regionText}</FactValue>
</FactRow>
)}
{languageText && (
<FactRow>
<FactLabel>{i18n._(msg`Languages`)}</FactLabel>
<FactValue>{languageText}</FactValue>
</FactRow>
)}
{categoryText && (
<FactRow>
<FactLabel>{i18n._(msg`Categories`)}</FactLabel>
<FactValue>{categoryText}</FactValue>
</FactRow>
)}
</FactsDl>
);
}
@@ -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 }) {
<MainColumn>
<BackToMarketplaceLink />
<PartnerProfileHeader partner={partner} />
<PartnerProfileIntro introduction={partner.introduction} />
{partner.skills.length > 0 && (
<Block>
<ProfileEyebrow>{i18n._(msg`What they do`)}</ProfileEyebrow>
<SkillsRow aria-label={i18n._(msg`Skills`)}>
{partner.skills.map((skill) => (
<SkillChip key={skill}>{skill}</SkillChip>
))}
</SkillsRow>
</Block>
)}
<Divider aria-hidden="true" />
<Block>
<ProfileEyebrow>{i18n._(msg`Where & how`)}</ProfileEyebrow>
<PartnerFactsList
region={partner.region}
languagesSpoken={partner.languagesSpoken}
partnerScope={partner.partnerScope}
/>
</Block>
<PartnerProfileAbout description={partner.description} />
<PartnerCoverageSection
partnerScope={partner.partnerScope}
skills={partner.skills}
/>
<PartnerSelectedWork portfolio={partner.portfolio} />
<PartnerServices services={partner.services} />
</MainColumn>
<GutterColumn />
<RailColumn aria-label={i18n._(msg`Partner facts and contact`)}>
<GuideCrosshair
crossX={`calc(100% - ${spacing(3)})`}
@@ -192,13 +142,16 @@ export function PartnerProfile({ partner }: { partner: MarketplacePartner }) {
/>
<PartnerProfileCtas
calendarLink={partner.calendarLink}
linkedinUrl={partner.linkedinUrl}
partnerName={partner.name}
links={partner.links}
linkUrls={partner.linkUrls}
/>
<PartnerRatesPanel
hourlyRateUsd={partner.hourlyRateUsd}
projectBudgetMinUsd={partner.projectBudgetMinUsd}
projectBudgetTypicalUsd={partner.projectBudgetTypicalUsd}
/>
<PartnerReachFacts
languagesSpoken={partner.languagesSpoken}
region={partner.region}
/>
</RailColumn>
</ContentGrid>
@@ -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 (
<About>
<RichText markdown={description} />
</About>
);
}
@@ -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 <partner>" 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 (
<Wrapper>
<ProfileEyebrow>{i18n._(msg`Reach out`)}</ProfileEyebrow>
<ButtonStack>
{showCalendar && (
<Button
href={calendarLink}
label={i18n._(msg`Book a call`)}
variant="filled"
/>
<CtaCard>
{calendarHref !== null && (
<PrimaryAction>
<Button
href={calendarHref}
label={i18n._(msg`Book intro call`)}
variant="filled"
/>
<SubNote>{i18n._(msg`30 minutes, no commitment`)}</SubNote>
</PrimaryAction>
)}
{showLinkedin && (
{calendarHref === null && websiteHref !== null && (
<PrimaryAction>
<Button
href={websiteHref}
label={i18n._(msg`Contact`)}
variant="outlined"
/>
</PrimaryAction>
)}
{linkEntries.length > 0 && (
<RailLinks>
{linkEntries.map((link) => (
<li key={link.href}>
<RailLink href={link.href} title={link.label}>
<RailLinkLabel>{link.label}</RailLinkLabel>
<RailLinkIcon aria-hidden="true" size={16} stroke={1.5} />
</RailLink>
</li>
))}
</RailLinks>
)}
<PrimaryAction>
<Button
href={linkedinUrl}
label={i18n._(msg`View on LinkedIn`)}
href="/partners/brief"
label={i18n._(msg`Submit a brief`)}
variant="outlined"
/>
)}
{showContactFallback && (
<Button
href={mailtoHref}
label={i18n._(msg`Contact ${partnerName}`)}
variant="filled"
/>
)}
<Button
href="/partners/brief"
label={i18n._(msg`Submit a brief`)}
variant="outlined"
/>
</ButtonStack>
</PrimaryAction>
</CtaCard>
</Wrapper>
);
}
@@ -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 (
<Panel>
<Wrapper>
<ProfileEyebrow>{i18n._(msg`Rates`)}</ProfileEyebrow>
{rows.map((row) => (
<Row key={row.label}>
<Label>{row.label}</Label>
<Value>{row.value}</Value>
</Row>
))}
</Panel>
<Panel>
{rows.map((row) => (
<Row key={row.label}>
<Label>{row.label}</Label>
<Value>{row.value}</Value>
</Row>
))}
</Panel>
</Wrapper>
);
}
@@ -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<TValue extends string>(
values: readonly TValue[],
labels: Record<TValue, MessageDescriptor>,
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 (
<ReachSection>
{regionLabels.length > 0 && (
<FactGroup>
<FactLabel>{i18n._(msg`Regions served`)}</FactLabel>
<ChipRow aria-label={i18n._(msg`Regions`)}>
{regionLabels.map((label) => (
<Chip key={label}>{label}</Chip>
))}
</ChipRow>
</FactGroup>
)}
{languageLabels.length > 0 && (
<FactGroup>
<FactLabel>{i18n._(msg`Languages`)}</FactLabel>
<ChipRow aria-label={i18n._(msg`Languages spoken`)}>
{languageLabels.map((label) => (
<Chip key={label}>{label}</Chip>
))}
</ChipRow>
</FactGroup>
)}
</ReachSection>
);
}
@@ -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<number | null>(null);
if (portfolio.length === 0) {
return null;
}
return (
<Section aria-labelledby="partner-case-studies-heading">
<ProfileSectionTitle id="partner-case-studies-heading">
{i18n._(msg`Case studies`)}
</ProfileSectionTitle>
<CasesList>
{/* 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 (
<CaseCard
key={`${index}-${caseStudy.client}-${caseStudy.title}`}
aria-label={i18n._(msg`Open case study: ${caseStudy.title}`)}
data-layout={layout}
onClick={() => setOpenIndex(index)}
type="button"
>
<CaseStudyVisualHover data-layout={layout}>
{imageUrl !== null ? (
<CaseStudyVisual
alt={caseStudy.title}
imageUrl={imageUrl}
size="card"
/>
) : (
<CaseStudyPlaceholder client={caseStudy.client} />
)}
</CaseStudyVisualHover>
<CaseBody>
<CaseClient>{caseStudy.client}</CaseClient>
<CaseTitle>{caseStudy.title}</CaseTitle>
<CaseTeaser>{richTextExcerpt(caseStudy.body)}</CaseTeaser>
<CaseActionSlot>
<CaseAction>{i18n._(msg`Read case study`)}</CaseAction>
</CaseActionSlot>
</CaseBody>
</CaseCard>
);
})}
{/* oxlint-enable eslint-plugin-react(no-array-index-key) */}
</CasesList>
<CaseStudyModal
cases={portfolio}
openIndex={openIndex}
onClose={() => setOpenIndex(null)}
/>
</Section>
);
}
@@ -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 (
<Section aria-labelledby="partner-services-heading">
<ProfileSectionTitle id="partner-services-heading">
{i18n._(msg`Services`)}
</ProfileSectionTitle>
<ServicesGrid>
{services.map((service) => (
<ServiceCard key={service.title}>
<ServiceCap aria-hidden="true" />
<ServiceBody>
<ServiceTitle>{service.title}</ServiceTitle>
<ServiceDescription>{service.description}</ServiceDescription>
</ServiceBody>
</ServiceCard>
))}
</ServicesGrid>
</Section>
);
}
@@ -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;
`;
@@ -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 <h1> 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 }) => (
<Markdown
allowedElements={ALLOWED}
unwrapDisallowed
components={{
h1: ({ children }) => <h3>{children}</h3>,
h2: ({ children }) => <h3>{children}</h3>,
a: ({ href, title, children }) => (
<ExternalLink href={href ?? '#'} title={title}>
{children}
</ExternalLink>
),
}}
>
{markdown}
</Markdown>
);
@@ -0,0 +1,2 @@
/** Landscape crop for case-study cards on the profile page. */
export const CASE_STUDY_CARD_ASPECT_RATIO = '16 / 9';
@@ -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)';
@@ -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,
};
@@ -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=';
@@ -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<string>();
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) }];
});
}
@@ -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<string>();
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) }];
});
}
@@ -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;
};
@@ -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;
@@ -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: [],
},
];
@@ -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: [],
},
]);
});
@@ -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);
@@ -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();
});
});
@@ -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<MarketplacePartner | undefined> {
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;
}
}
@@ -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,
});
@@ -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;
}
}
@@ -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<MarketplacePartner | null> {
const partners = await fetchLiveMarketplacePartners();
return partners.find((partner) => partner.slug === slug) ?? null;
}
): Promise<MarketplacePartner | undefined> => {
if (useDummy) {
return DUMMY_PARTNERS.find((partner) => partner.slug === slug);
}
return fetchLivePartnerProfile(slug);
};
@@ -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);
};
@@ -0,0 +1,4 @@
import { type MarketplacePartner } from './marketplace-partner';
export const isGhost = (p: MarketplacePartner): boolean =>
p.description.trim().length < 40 && !p.profilePictureUrl;
@@ -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 ?? '');
@@ -0,0 +1,5 @@
export type CurrencyValue = {
amountMicros: number;
currencyCode: string;
} | null;
export type LinkValue = { primaryLinkUrl: string | null } | null;
@@ -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;
@@ -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[];
};
@@ -0,0 +1,2 @@
export { getMarketplacePartners } from './get-marketplace-partners';
export { getMarketplacePartnerBySlug } from './get-marketplace-partner-by-slug';
@@ -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;
@@ -0,0 +1,7 @@
export const normalizeUrl = (raw: string | null | undefined): string => {
if (!raw) {
return '';
}
return raw.includes('://') ? raw : `https://${raw}`;
};
@@ -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' },
]);
});
});
@@ -0,0 +1,4 @@
export type PartnerLinkEntry = {
href: string;
label: string;
};
@@ -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<PartnerScope, MessageDescriptor> = {
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<PartnerScope, MessageDescriptor>;
@@ -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);
});
@@ -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<unknown> {
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<unknown> {
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<unknown> {
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) {
@@ -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']);
});
@@ -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),
);
@@ -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);
}
});
});
@@ -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] : [];
});
}
@@ -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.',
);
});
@@ -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;
};
@@ -0,0 +1,9 @@
import { type PartnerScope } from './partner-scopes';
export const SCOPE_PRIORITY: PartnerScope[] = [
'SOLUTIONING',
'DEVELOPMENT',
'ADVISORY',
'HOSTING',
'SUPPORT',
];
@@ -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),
};
};
+1
View File
@@ -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"