[Website] Partners directory (#20632)

## What this PR does

Adds the **Partners Marketplace** page to the Twenty marketing website
(`/partners-marketplace`), built with Next.js App Router. The page
fetches live partner data from the Twenty API and presents it in a
responsive grid with an interactive filter bar.

## Partners grid

- Fetches partners from the `/s/partners` endpoint via a typed
`getPartners()` server-side fetcher
- Responsive 1 → 2 → 3 column grid (mobile / tablet / desktop)
- Each card shows name, region eyebrow, intro text, chip rows (Regions /
Languages / Deploys), and a Calendly CTA
- Stagger entrance animation (700ms cascade, respects
`prefers-reduced-motion`)

## Filter bar

- Three facets: **Region**, **Language**, **Deployment** — multi-select
chips
- **Selection model:** OR within a facet, AND across facets (e.g.
`Europe OR US` AND `French`)
- Filter state lives in URL search params
(`?regions=EUROPE,US&languages=FRENCH`) — filtered views are shareable
and browser-back works correctly
- Client-side filtering — no server round-trip per interaction
- Result count ("Showing 3 of 8 partners") updates live with
`aria-live="polite"`
- "Clear filters" button resets all facets in one URL update, only shown
when filters are active
- Empty state ("No partners match your filters") replaces the grid when
nothing matches
- 200ms opacity fade-out on card removal; initial stagger animation
preserved on first load
- `prefers-reduced-motion: reduce` disables all transitions

## Architecture

- `page.tsx` stays a **Server Component** — fetches partners
server-side, all partner HTML is in the initial response for SEO
- `<MarketplaceClient>` is the client boundary — owns filter state via
`useFilterState()` (backed by `useSearchParams`)
- Canonical URL set in page metadata so `?regions=...` deep-links don't
get indexed as duplicates
- `<Suspense>` wrapper around `MarketplaceClient` for Next.js 15
`useSearchParams` compliance
- No new npm dependencies

## Test coverage

31 tests across three suites:
- `filter-partners.test.ts` — pure filter logic (OR / AND semantics,
empty results)
- `filter-url-helpers.test.ts` — URL param encode / decode / toggle /
round-trip
- `use-filter-state.test.tsx` — hook behaviour with mocked
`next/navigation`

## Screenshot

<img width="1783" height="1196" alt="Screenshot 2026-05-17 at 15 01 54"
src="https://github.com/user-attachments/assets/9dddf827-f440-4cad-8ec3-81ede6d46434"
/>


## Test plan

- [ ] Navigate to `/partners-marketplace` — all live partners render
- [ ] Click a Region chip — URL updates with `?regions=...`, cards
filter, count updates
- [ ] Click the same chip again — selection removed, all cards return
- [ ] Select chips from two different facets — AND behaviour narrows
results correctly
- [ ] Trigger empty state (e.g. filter to a region with no partners) —
empty state shown with "Clear filters" button
- [ ] Click "Clear filters" — all cards return, URL cleared
- [ ] Deep-link to `?regions=EUROPE&languages=FRENCH` — page loads with
filters applied
- [ ] Browser back button restores previous filter state

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Rashad Karanouh
2026-05-21 23:13:14 +04:00
committed by GitHub
parent 138eb5a74a
commit 07a20cba5e
30 changed files with 1755 additions and 0 deletions
+5
View File
@@ -35,3 +35,8 @@ ENTERPRISE_JWT_PUBLIC_KEY=
# Optional: short-lived validity token length in days (default 30)
# ENTERPRISE_VALIDITY_TOKEN_DURATION_DAYS=
# Twenty workspace the partners marketplace reads partner data from
# (server-side only) via the /s/partners REST endpoint.
TWENTY_PARTNERS_API_URL=
TWENTY_PARTNERS_API_KEY=
@@ -0,0 +1,77 @@
'use client';
import { useMemo } from 'react';
import { Container } from '@/design-system/components';
import type { MarketplacePartner } from '@/lib/partners-api';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { filterPartners } from './filter-partners';
import { useFilterState } from './use-filter-state';
import { EmptyState } from './components/EmptyState';
import { FilterBar } from './components/FilterBar';
import { MarketplaceGrid } from './components/MarketplaceGrid';
const FilterBarOuter = styled.div`
background-color: ${theme.colors.primary.background[100]};
width: 100%;
`;
const FilterBarInner = styled(Container)`
padding-bottom: ${theme.spacing(6)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(4)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(0)};
}
`;
type MarketplaceClientProps = {
partners: readonly MarketplacePartner[];
};
export function MarketplaceClient({ partners }: MarketplaceClientProps) {
const {
criteria,
toggleRegion,
toggleLanguage,
toggleDeployment,
clearAll,
hasAnyFilter,
} = useFilterState();
const filteredPartners = useMemo(
() => filterPartners(partners, criteria),
[partners, criteria],
);
return (
<>
<FilterBarOuter>
<FilterBarInner>
<FilterBar
criteria={criteria}
totalCount={partners.length}
visibleCount={filteredPartners.length}
hasAnyFilter={hasAnyFilter}
onToggleRegion={toggleRegion}
onToggleLanguage={toggleLanguage}
onToggleDeployment={toggleDeployment}
onClearAll={clearAll}
/>
</FilterBarInner>
</FilterBarOuter>
{filteredPartners.length === 0 ? (
<EmptyState onClearFilters={clearAll} />
) : (
<MarketplaceGrid partners={filteredPartners} />
)}
</>
);
}
@@ -0,0 +1,82 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import type { MarketplacePartner } from '@/lib/partners-api';
import { PartnerCard } from '../components/PartnerCard';
beforeAll(() => {
i18n.load(SOURCE_LOCALE, {});
i18n.activate(SOURCE_LOCALE);
});
const FIXTURE: MarketplacePartner = {
slug: 'test-partner',
name: 'Test Partner',
introduction: 'A reliable partner for testing purposes.',
calendarLink: 'https://calendly.com/test-partner',
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
region: ['EUROPE', 'US'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
};
const renderCard = () =>
renderToStaticMarkup(
<I18nProvider i18n={i18n}>
<PartnerCard partner={FIXTURE} index={0} />
</I18nProvider>,
);
describe('PartnerCard', () => {
it('renders the partner name as the article heading', () => {
const html = renderCard();
expect(html).toMatch(new RegExp(`<h3[^>]*>${FIXTURE.name}</h3>`, 'i'));
});
it('renders the geo eyebrow with the first served region', () => {
const html = renderCard();
expect(html).toContain(FIXTURE.region[0]);
});
it('renders the full introduction text', () => {
const html = renderCard();
expect(html).toContain(FIXTURE.introduction);
});
it('renders one chip per value across the three chip rows', () => {
const html = renderCard();
const expectedChipCount =
FIXTURE.region.length +
FIXTURE.languagesSpoken.length +
FIXTURE.deploymentExpertise.length;
const liMatches = html.match(/<li[^>]*>/g) ?? [];
expect(liMatches.length).toBe(expectedChipCount);
});
it('renders the Calendly CTA pointing at the partner link in a new tab', () => {
const html = renderCard();
expect(html).toContain(`href="${FIXTURE.calendarLink}"`);
expect(html).toContain('target="_blank"');
expect(html).toContain('noopener');
});
it.each([
'javascript:alert(document.cookie)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'',
'not-a-url',
])('suppresses the CTA when calendarLink is %s', (unsafeLink) => {
const html = renderToStaticMarkup(
<I18nProvider i18n={i18n}>
<PartnerCard
partner={{ ...FIXTURE, calendarLink: unsafeLink }}
index={0}
/>
</I18nProvider>,
);
expect(html).not.toContain('href=');
});
});
@@ -0,0 +1,109 @@
import type { MarketplacePartner } from '@/lib/partners-api';
import {
EMPTY_CRITERIA,
filterPartners,
hasAnyFilter,
type FilterCriteria,
} from '../filter-partners';
const make = (overrides: Partial<MarketplacePartner>): MarketplacePartner => ({
slug: 'p',
name: 'Partner',
introduction: 'intro',
calendarLink: 'https://calendly.com/p',
region: [],
languagesSpoken: [],
deploymentExpertise: [],
...overrides,
});
const felix = make({
slug: 'felix',
region: ['EUROPE', 'US', 'APAC'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
});
const rashad = make({
slug: 'rashad',
region: ['US', 'EUROPE'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
deploymentExpertise: ['CLOUD'],
});
const acme = make({
slug: 'acme',
region: ['MENA'],
languagesSpoken: ['ENGLISH'],
deploymentExpertise: ['SELF_HOST'],
});
const all = [felix, rashad, acme] as const;
describe('filterPartners', () => {
it('returns every partner when criteria is empty', () => {
expect(filterPartners(all, EMPTY_CRITERIA)).toEqual(all);
});
it('filters by a single region (single value)', () => {
const c: FilterCriteria = {
regions: new Set(['MENA']),
languages: new Set(),
deployments: new Set(),
};
expect(filterPartners(all, c)).toEqual([acme]);
});
it('filters by multiple regions (OR within facet)', () => {
const c: FilterCriteria = {
regions: new Set(['APAC', 'MENA']),
languages: new Set(),
deployments: new Set(),
};
expect(filterPartners(all, c)).toEqual([felix, acme]);
});
it('combines facets with AND', () => {
const c: FilterCriteria = {
regions: new Set(['EUROPE']),
languages: new Set(['FRENCH']),
deployments: new Set(),
};
expect(filterPartners(all, c)).toEqual([felix, rashad]);
});
it('returns empty when no partner matches', () => {
const c: FilterCriteria = {
regions: new Set(),
languages: new Set(['GERMAN']),
deployments: new Set(),
};
expect(filterPartners(all, c)).toEqual([]);
});
it('AND across all three facets', () => {
const c: FilterCriteria = {
regions: new Set(['EUROPE']),
languages: new Set(['FRENCH']),
deployments: new Set(['SELF_HOST']),
};
expect(filterPartners(all, c)).toEqual([felix]);
});
});
describe('hasAnyFilter', () => {
it('returns false for empty criteria', () => {
expect(hasAnyFilter(EMPTY_CRITERIA)).toBe(false);
});
it('returns true when any facet has a value', () => {
expect(
hasAnyFilter({ ...EMPTY_CRITERIA, regions: new Set(['EUROPE']) }),
).toBe(true);
expect(
hasAnyFilter({ ...EMPTY_CRITERIA, languages: new Set(['ENGLISH']) }),
).toBe(true);
expect(
hasAnyFilter({ ...EMPTY_CRITERIA, deployments: new Set(['CLOUD']) }),
).toBe(true);
});
});
@@ -0,0 +1,104 @@
import {
DEPLOYMENT_EXPERTISES,
SERVED_GEOS,
SPOKEN_LANGUAGES,
} from '@/lib/partners-api';
import {
buildQueryString,
parseCriteriaFromParams,
toggleInSet,
} from '../filter-url-helpers';
describe('parseCriteriaFromParams', () => {
it('returns empty sets when params are empty', () => {
const c = parseCriteriaFromParams(new URLSearchParams());
expect(c.regions.size).toBe(0);
expect(c.languages.size).toBe(0);
expect(c.deployments.size).toBe(0);
});
it('parses a CSV of valid values', () => {
const c = parseCriteriaFromParams(
new URLSearchParams(
'regions=EUROPE,US&languages=FRENCH&deployments=CLOUD',
),
);
expect(c.regions).toEqual(new Set(['EUROPE', 'US']));
expect(c.languages).toEqual(new Set(['FRENCH']));
expect(c.deployments).toEqual(new Set(['CLOUD']));
});
it('silently drops unknown values', () => {
const c = parseCriteriaFromParams(
new URLSearchParams('regions=EUROPE,MARS,US&languages=KLINGON'),
);
expect(c.regions).toEqual(new Set(['EUROPE', 'US']));
expect(c.languages.size).toBe(0);
});
it('handles whitespace inside CSV values', () => {
const c = parseCriteriaFromParams(
new URLSearchParams('regions=EUROPE, US , LATAM'),
);
expect(c.regions).toEqual(new Set(['EUROPE', 'US', 'LATAM']));
});
});
describe('buildQueryString', () => {
it('returns empty string when criteria is empty', () => {
expect(
buildQueryString({
regions: new Set(),
languages: new Set(),
deployments: new Set(),
}),
).toBe('');
});
it('round-trips with parseCriteriaFromParams', () => {
const original = {
regions: new Set(['EUROPE', 'US']) as ReadonlySet<'EUROPE' | 'US'>,
languages: new Set(['FRENCH']) as ReadonlySet<'FRENCH'>,
deployments: new Set(['CLOUD']) as ReadonlySet<'CLOUD'>,
};
const qs = buildQueryString(original as never);
const parsed = parseCriteriaFromParams(new URLSearchParams(qs));
expect(parsed.regions).toEqual(original.regions);
expect(parsed.languages).toEqual(original.languages);
expect(parsed.deployments).toEqual(original.deployments);
});
it('omits facets whose sets are empty', () => {
const qs = buildQueryString({
regions: new Set(['EUROPE']),
languages: new Set(),
deployments: new Set(),
});
expect(qs).toBe('regions=EUROPE');
});
});
describe('toggleInSet', () => {
it('adds value when absent', () => {
expect(toggleInSet(new Set(['A']), 'B')).toEqual(new Set(['A', 'B']));
});
it('removes value when present', () => {
expect(toggleInSet(new Set(['A', 'B']), 'B')).toEqual(new Set(['A']));
});
it('does not mutate the original set', () => {
const original = new Set(['A']);
toggleInSet(original, 'B');
expect(original).toEqual(new Set(['A']));
});
});
describe('enum constants are non-empty', () => {
it('covers all known regions, languages, deployments', () => {
expect(SERVED_GEOS.length).toBe(6);
expect(SPOKEN_LANGUAGES.length).toBe(5);
expect(DEPLOYMENT_EXPERTISES.length).toBe(2);
});
});
@@ -0,0 +1,93 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { useFilterState } from '../use-filter-state';
let mockSearchParams = new URLSearchParams();
const mockReplace = jest.fn();
jest.mock('next/navigation', () => ({
useSearchParams: () => mockSearchParams,
useRouter: () => ({ replace: mockReplace }),
usePathname: () => '/en/partners/list',
}));
type FilterStateResult = ReturnType<typeof useFilterState>;
function Capture({ onCapture }: { onCapture: (s: FilterStateResult) => void }) {
const state = useFilterState();
onCapture(state);
return null;
}
const getState = (): FilterStateResult => {
let captured!: FilterStateResult;
renderToStaticMarkup(
<Capture
onCapture={(s) => {
captured = s;
}}
/>,
);
return captured;
};
describe('useFilterState', () => {
beforeEach(() => {
mockSearchParams = new URLSearchParams();
mockReplace.mockReset();
});
it('returns empty criteria and hasAnyFilter=false for empty params', () => {
const state = getState();
expect(state.hasAnyFilter).toBe(false);
expect(state.criteria.regions.size).toBe(0);
expect(state.criteria.languages.size).toBe(0);
expect(state.criteria.deployments.size).toBe(0);
});
it('parses regions=EUROPE,US into Set {EUROPE, US}', () => {
mockSearchParams = new URLSearchParams('regions=EUROPE,US');
const state = getState();
expect(state.criteria.regions).toEqual(new Set(['EUROPE', 'US']));
expect(state.hasAnyFilter).toBe(true);
});
it('silently drops unknown values', () => {
mockSearchParams = new URLSearchParams('regions=EUROPE,MARS');
const state = getState();
expect(state.criteria.regions).toEqual(new Set(['EUROPE']));
});
it('toggleRegion adds a value to the URL when absent', () => {
const state = getState();
state.toggleRegion('EUROPE');
expect(mockReplace).toHaveBeenCalledTimes(1);
const [url, options] = mockReplace.mock.calls[0] as [string, unknown];
expect(url).toContain('regions=EUROPE');
expect(options).toEqual({ scroll: false });
});
it('toggleRegion removes a value from the URL when present', () => {
mockSearchParams = new URLSearchParams('regions=EUROPE,US');
const state = getState();
state.toggleRegion('EUROPE');
const [url] = mockReplace.mock.calls[0] as [string, unknown];
expect(url).toContain('US');
expect(url).not.toMatch(/EUROPE/);
});
it('clearAll navigates to the bare pathname with no query string', () => {
mockSearchParams = new URLSearchParams('regions=EUROPE&languages=FRENCH');
const state = getState();
state.clearAll();
expect(mockReplace).toHaveBeenCalledWith('/en/partners/list', {
scroll: false,
});
});
it('hasAnyFilter is true when languages facet has values', () => {
mockSearchParams = new URLSearchParams('languages=FRENCH');
const state = getState();
expect(state.hasAnyFilter).toBe(true);
});
});
@@ -0,0 +1,42 @@
'use client';
import {
BaseButton,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
import { styled } from '@linaria/react';
import type { MouseEventHandler, ReactNode } from 'react';
const StyledClearFiltersButton = styled.button`
${buttonBaseStyles}
`;
type ClearFiltersButtonProps = {
children: ReactNode;
className?: string;
onClick: MouseEventHandler<HTMLButtonElement>;
};
export function ClearFiltersButton({
children,
className,
onClick,
}: ClearFiltersButtonProps) {
return (
<StyledClearFiltersButton
className={className}
data-color="secondary"
data-size="small"
data-variant="outlined"
onClick={onClick}
type="button"
>
<BaseButton
color="secondary"
label={children}
size="small"
variant="outlined"
/>
</StyledClearFiltersButton>
);
}
@@ -0,0 +1,60 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { theme } from '@/theme';
import { ClearFiltersButton } from './ClearFiltersButton';
type EmptyStateProps = {
onClearFilters: () => void;
};
const Wrapper = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
padding: ${theme.spacing(12)} ${theme.spacing(4)};
text-align: center;
`;
const Heading = styled.h2`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4.5)};
font-weight: ${theme.font.weight.medium};
letter-spacing: 0;
line-height: ${theme.lineHeight(6)};
margin: 0;
`;
const Subtitle = styled.p`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
margin: 0;
`;
const EmptyStateClearButton = styled(ClearFiltersButton)`
margin-top: ${theme.spacing(4)};
`;
export function EmptyState({ onClearFilters }: EmptyStateProps) {
const { i18n } = useLingui();
return (
<Wrapper>
<Heading>{i18n._(msg`No partners match your filters`)}</Heading>
<Subtitle>
{i18n._(msg`Try removing some filters or browse all partners.`)}
</Subtitle>
<EmptyStateClearButton onClick={onClearFilters}>
{i18n._(msg`Clear filters`)}
</EmptyStateClearButton>
</Wrapper>
);
}
@@ -0,0 +1,109 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import {
DEPLOYMENT_EXPERTISES,
SERVED_GEOS,
SPOKEN_LANGUAGES,
type DeploymentExpertise,
type ServedGeo,
type SpokenLanguage,
} from '@/lib/partners-api';
import { theme } from '@/theme';
import type { FilterCriteria } from '../filter-partners';
import {
DEPLOYMENT_EXPERTISE_LABELS,
SERVED_GEO_LABELS,
SPOKEN_LANGUAGE_LABELS,
} from './chip-labels';
import { ClearFiltersButton } from './ClearFiltersButton';
import { FilterChipRow } from './FilterChipRow';
type FilterBarProps = {
criteria: FilterCriteria;
totalCount: number;
visibleCount: number;
hasAnyFilter: boolean;
onToggleRegion: (geo: ServedGeo) => void;
onToggleLanguage: (lang: SpokenLanguage) => void;
onToggleDeployment: (dep: DeploymentExpertise) => void;
onClearAll: () => void;
};
const BarSection = styled.section`
display: flex;
flex-direction: column;
gap: ${theme.spacing(4)};
`;
const Footer = styled.div`
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(3)};
justify-content: space-between;
padding-top: ${theme.spacing(2)};
`;
const ResultCount = styled.p`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3)};
line-height: ${theme.lineHeight(4)};
margin: 0;
`;
export function FilterBar({
criteria,
totalCount,
visibleCount,
hasAnyFilter,
onToggleRegion,
onToggleLanguage,
onToggleDeployment,
onClearAll,
}: FilterBarProps) {
const { i18n } = useLingui();
return (
<BarSection aria-label={i18n._(msg`Filter partners`)}>
<FilterChipRow
label={msg`Regions`}
values={SERVED_GEOS}
valueLabels={SERVED_GEO_LABELS}
selected={criteria.regions}
onToggle={onToggleRegion}
/>
<FilterChipRow
label={msg`Languages`}
values={SPOKEN_LANGUAGES}
valueLabels={SPOKEN_LANGUAGE_LABELS}
selected={criteria.languages}
onToggle={onToggleLanguage}
/>
<FilterChipRow
label={msg`Deploys`}
values={DEPLOYMENT_EXPERTISES}
valueLabels={DEPLOYMENT_EXPERTISE_LABELS}
selected={criteria.deployments}
onToggle={onToggleDeployment}
/>
<Footer>
<ResultCount aria-live="polite">
{hasAnyFilter
? i18n._(msg`Showing ${visibleCount} of ${totalCount} partners`)
: i18n._(msg`Showing all ${totalCount} partners`)}
</ResultCount>
{hasAnyFilter && (
<ClearFiltersButton onClick={onClearAll}>
{i18n._(msg`Clear filters`)}
</ClearFiltersButton>
)}
</Footer>
</BarSection>
);
}
@@ -0,0 +1,98 @@
'use client';
import type { MessageDescriptor } from '@lingui/core';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { theme } from '@/theme';
import { chipBaseStyles } from './chip-styles';
type FilterChipRowProps<T extends string> = {
label: MessageDescriptor;
values: readonly T[];
valueLabels: Record<T, MessageDescriptor>;
selected: ReadonlySet<T>;
onToggle: (value: T) => void;
};
const Section = styled.section`
align-items: baseline;
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
@media (min-width: ${theme.breakpoints.md}px) {
flex-direction: row;
gap: ${theme.spacing(4)};
}
`;
const RowLabel = styled.h3`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
letter-spacing: 0.08em;
line-height: ${theme.lineHeight(4)};
margin: 0;
min-width: 80px;
text-transform: uppercase;
`;
const ChipList = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(2)};
`;
const Chip = styled.button`
cursor: pointer;
&[aria-pressed='true'] {
background-color: ${theme.colors.primary.text[100]};
border-color: ${theme.colors.primary.text[100]};
color: ${theme.colors.primary.background[100]};
}
&:hover:not([aria-pressed='true']) {
background-color: rgba(0, 0, 0, 0.04);
}
&:focus-visible {
outline: 2px solid ${theme.colors.primary.border[40]};
outline-offset: 2px;
}
`;
export function FilterChipRow<T extends string>({
label,
values,
valueLabels,
selected,
onToggle,
}: FilterChipRowProps<T>) {
const { i18n } = useLingui();
return (
<Section>
<RowLabel>{i18n._(label)}</RowLabel>
<ChipList role="group" aria-label={i18n._(label)}>
{values.map((value) => {
const isPressed = selected.has(value);
return (
<Chip
key={value}
type="button"
aria-pressed={isPressed}
className={chipBaseStyles}
onClick={() => onToggle(value)}
>
{i18n._(valueLabels[value])}
</Chip>
);
})}
</ChipList>
</Section>
);
}
@@ -0,0 +1,58 @@
import { Container } from '@/design-system/components';
import type { MarketplacePartner } from '@/lib/partners-api';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { PartnerCard } from './PartnerCard';
const Section = styled.section`
background-color: ${theme.colors.primary.background[100]};
min-width: 0;
width: 100%;
`;
const StyledContainer = styled(Container)`
padding-bottom: ${theme.spacing(28)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(4)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(36)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
}
`;
const CardGrid = styled.div`
display: grid;
gap: ${theme.spacing(6)};
grid-template-columns: 1fr;
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(8)};
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: ${theme.breakpoints.lg}px) {
grid-template-columns: repeat(3, 1fr);
}
`;
type MarketplaceGridProps = {
partners: readonly MarketplacePartner[];
};
export function MarketplaceGrid({ partners }: MarketplaceGridProps) {
return (
<Section>
<StyledContainer>
<CardGrid>
{partners.map((partner, index) => (
<PartnerCard key={partner.slug} partner={partner} index={index} />
))}
</CardGrid>
</StyledContainer>
</Section>
);
}
@@ -0,0 +1,65 @@
import { msg } from '@lingui/core/macro';
import {
Body,
Container,
Eyebrow,
Heading,
HeadingPart,
} from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const Section = styled.section`
background-color: ${theme.colors.primary.background[100]};
width: 100%;
`;
const StyledContainer = styled(Container)`
display: flex;
flex-direction: column;
gap: ${theme.spacing(4)};
padding-bottom: ${theme.spacing(10)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(20)};
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(6)};
padding-bottom: ${theme.spacing(14)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(28)};
}
`;
const HeaderBody = styled(Body)`
max-width: 640px;
`;
export function MarketplaceHeader() {
const i18n = getServerI18n();
return (
<Section>
<StyledContainer>
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Marketplace`)}
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">{i18n._(msg`Find your`)}</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`Twenty partner`)}
</HeadingPart>
</Heading>
<HeaderBody>
{i18n._(
msg`Twenty's certified partners help teams migrate, customise, and operate the open source CRM across regions, languages, and deployment models. Browse profiles and book a call.`,
)}
</HeaderBody>
</StyledContainer>
</Section>
);
}
@@ -0,0 +1,53 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const AVATAR_PALETTE = [
theme.colors.accent.blue[100],
theme.colors.accent.pink[100],
theme.colors.accent.green[100],
];
const AvatarBlock = styled.span`
align-items: center;
border-radius: ${theme.radius(1.5)};
color: ${theme.colors.secondary.text[100]};
display: inline-flex;
flex-shrink: 0;
font-family: ${theme.font.family.serif};
font-size: ${theme.font.size(5)};
font-weight: ${theme.font.weight.light};
height: 48px;
justify-content: center;
letter-spacing: -0.02em;
line-height: 1;
width: 48px;
`;
function pickPaletteColor(slug: string): string {
if (slug.length === 0) return AVATAR_PALETTE[0];
const index = (slug.charCodeAt(0) + slug.length) % AVATAR_PALETTE.length;
return AVATAR_PALETTE[index];
}
function pickInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[1][0]).toUpperCase();
}
type PartnerAvatarProps = {
name: string;
slug: string;
};
export function PartnerAvatar({ name, slug }: PartnerAvatarProps) {
const backgroundColor = pickPaletteColor(slug);
const initials = pickInitials(name);
return (
<AvatarBlock aria-hidden="true" style={{ backgroundColor }}>
{initials}
</AvatarBlock>
);
}
@@ -0,0 +1,197 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { LinkButton } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { CSSProperties } from 'react';
import type { MarketplacePartner } from '@/lib/partners-api';
import { PartnerAvatar } from './PartnerAvatar';
import { PartnerChipRow } from './PartnerChipRow';
import {
DEPLOYMENT_EXPERTISE_LABELS,
SERVED_GEO_LABELS,
SPOKEN_LANGUAGE_LABELS,
} from './chip-labels';
type PartnerCardStyle = CSSProperties & {
'--partner-card-index': number;
};
const CardArticle = styled.article`
@keyframes partnerCardEnter {
from {
opacity: 0;
transform: translate3d(0, 18px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
animation: partnerCardEnter 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--partner-card-index) * 90ms + 180ms);
background-color: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
display: flex;
flex-direction: column;
gap: ${theme.spacing(5)};
padding: ${theme.spacing(6)};
transition:
border-color 0.25s ease,
box-shadow 0.25s ease,
transform 0.25s ease;
will-change: transform;
&:hover {
border-color: ${theme.colors.primary.border[20]};
box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18);
transform: translateY(-2px);
}
@media (prefers-reduced-motion: reduce) {
animation: none;
transition: none;
&:hover {
transform: none;
}
}
`;
const CardHeader = styled.div`
align-items: center;
display: flex;
gap: ${theme.spacing(4)};
`;
const HeaderText = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
min-width: 0;
`;
const PartnerName = styled.h3`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.serif};
font-size: ${theme.font.size(6)};
font-weight: ${theme.font.weight.light};
letter-spacing: -0.02em;
line-height: ${theme.lineHeight(7)};
margin: 0;
`;
const CountryEyebrow = styled.span`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
letter-spacing: 0.08em;
line-height: ${theme.lineHeight(4)};
text-transform: uppercase;
`;
const Introduction = styled.p`
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
color: ${theme.colors.primary.text[60]};
display: -webkit-box;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
margin: 0;
overflow: hidden;
`;
const Divider = styled.hr`
background-color: ${theme.colors.primary.border[10]};
border: 0;
height: 1px;
margin: 0;
width: 100%;
`;
const ChipRows = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(3)};
`;
const CtaWrapper = styled.div`
display: flex;
margin-top: auto;
`;
const isSafeHttpUrl = (raw: string) => {
try {
return ['https:', 'http:'].includes(new URL(raw).protocol);
} catch {
return false;
}
};
type PartnerCardProps = {
partner: MarketplacePartner;
index: number;
};
export function PartnerCard({ partner, index }: PartnerCardProps) {
const { i18n } = useLingui();
const headingId = `partner-card-heading-${partner.slug}`;
const style: PartnerCardStyle = { '--partner-card-index': index };
const firstGeo = partner.region[0];
const countryLine = firstGeo
? i18n._(SERVED_GEO_LABELS[firstGeo]).toUpperCase()
: '';
return (
<CardArticle aria-labelledby={headingId} style={style}>
<CardHeader>
<PartnerAvatar name={partner.name} slug={partner.slug} />
<HeaderText>
<PartnerName id={headingId}>{partner.name}</PartnerName>
<CountryEyebrow>{countryLine}</CountryEyebrow>
</HeaderText>
</CardHeader>
<Introduction>{partner.introduction}</Introduction>
<Divider aria-hidden="true" />
<ChipRows>
<PartnerChipRow
label={msg`Regions`}
values={partner.region}
valueLabels={SERVED_GEO_LABELS}
/>
<PartnerChipRow
label={msg`Languages`}
values={partner.languagesSpoken}
valueLabels={SPOKEN_LANGUAGE_LABELS}
/>
<PartnerChipRow
label={msg`Deploys`}
values={partner.deploymentExpertise}
valueLabels={DEPLOYMENT_EXPERTISE_LABELS}
/>
</ChipRows>
{isSafeHttpUrl(partner.calendarLink) && (
<CtaWrapper>
<LinkButton
color="secondary"
href={partner.calendarLink}
label={i18n._(msg`Book a call`)}
variant="contained"
/>
</CtaWrapper>
)}
</CardArticle>
);
}
@@ -0,0 +1,80 @@
'use client';
import type { MessageDescriptor } from '@lingui/core';
import { useLingui } from '@lingui/react';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { chipBaseStyles } from './chip-styles';
const Row = styled.dl`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
margin: 0;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
flex-direction: row;
gap: ${theme.spacing(4)};
}
`;
const RowLabel = styled.dt`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
letter-spacing: 0.08em;
line-height: ${theme.lineHeight(4)};
margin: 0;
text-transform: uppercase;
@media (min-width: ${theme.breakpoints.md}px) {
flex-shrink: 0;
width: 80px;
}
`;
const ChipListWrapper = styled.dd`
margin: 0;
`;
const ChipList = styled.ul`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(2)};
list-style: none;
margin: 0;
padding: 0;
`;
type PartnerChipRowProps<TValue extends string> = {
label: MessageDescriptor;
values: readonly TValue[];
valueLabels: Record<TValue, MessageDescriptor>;
};
export function PartnerChipRow<TValue extends string>({
label,
values,
valueLabels,
}: PartnerChipRowProps<TValue>) {
const { i18n } = useLingui();
return (
<Row>
<RowLabel>{i18n._(label)}</RowLabel>
<ChipListWrapper>
<ChipList>
{values.map((value) => (
<li key={value} className={chipBaseStyles}>
{i18n._(valueLabels[value])}
</li>
))}
</ChipList>
</ChipListWrapper>
</Row>
);
}
@@ -0,0 +1,34 @@
import { msg } from '@lingui/core/macro';
import type { MessageDescriptor } from '@lingui/core';
import type {
DeploymentExpertise,
ServedGeo,
SpokenLanguage,
} from '@/lib/partners-api';
export const SERVED_GEO_LABELS: Record<ServedGeo, MessageDescriptor> = {
EUROPE: msg`Europe`,
US: msg`US`,
LATAM: msg`LATAM`,
MENA: msg`MENA`,
APAC: msg`APAC`,
AFRICA: msg`Africa`,
};
export const SPOKEN_LANGUAGE_LABELS: Record<SpokenLanguage, MessageDescriptor> =
{
ENGLISH: msg`English`,
FRENCH: msg`French`,
GERMAN: msg`German`,
CHINESE: msg`Chinese`,
SPANISH: msg`Spanish`,
};
export const DEPLOYMENT_EXPERTISE_LABELS: Record<
DeploymentExpertise,
MessageDescriptor
> = {
CLOUD: msg`Cloud`,
SELF_HOST: msg`Self-host`,
};
@@ -0,0 +1,16 @@
import { css } from '@linaria/core';
import { theme } from '@/theme';
export const chipBaseStyles = css`
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(4)};
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
letter-spacing: 0.04em;
line-height: ${theme.lineHeight(4)};
padding: ${theme.spacing(1)} ${theme.spacing(2.5)};
text-transform: uppercase;
`;
@@ -0,0 +1,6 @@
export { MarketplaceGrid } from './MarketplaceGrid';
export { MarketplaceHeader } from './MarketplaceHeader';
export { PartnerCard } from './PartnerCard';
export { EmptyState } from './EmptyState';
export { FilterBar } from './FilterBar';
export { FilterChipRow } from './FilterChipRow';
@@ -0,0 +1,39 @@
import type {
DeploymentExpertise,
MarketplacePartner,
ServedGeo,
SpokenLanguage,
} from '@/lib/partners-api';
export type FilterCriteria = {
regions: ReadonlySet<ServedGeo>;
languages: ReadonlySet<SpokenLanguage>;
deployments: ReadonlySet<DeploymentExpertise>;
};
export const EMPTY_CRITERIA: FilterCriteria = {
regions: new Set(),
languages: new Set(),
deployments: new Set(),
};
const facetMatches = <T>(
values: readonly T[],
selected: ReadonlySet<T>,
): boolean => selected.size === 0 || values.some((v) => selected.has(v));
export const filterPartners = (
partners: readonly MarketplacePartner[],
criteria: FilterCriteria,
): readonly MarketplacePartner[] =>
partners.filter(
(p) =>
facetMatches(p.region, criteria.regions) &&
facetMatches(p.languagesSpoken, criteria.languages) &&
facetMatches(p.deploymentExpertise, criteria.deployments),
);
export const hasAnyFilter = (criteria: FilterCriteria): boolean =>
criteria.regions.size > 0 ||
criteria.languages.size > 0 ||
criteria.deployments.size > 0;
@@ -0,0 +1,52 @@
import {
DEPLOYMENT_EXPERTISES,
SERVED_GEOS,
SPOKEN_LANGUAGES,
} from '@/lib/partners-api';
import type { FilterCriteria } from './filter-partners';
const parseFacet = <T extends string>(
raw: string | null,
allowed: readonly T[],
): ReadonlySet<T> => {
if (!raw) return new Set();
const allowedSet = new Set<string>(allowed);
const parsed = raw
.split(',')
.map((s) => s.trim())
.filter((s): s is T => allowedSet.has(s));
return new Set(parsed);
};
export const parseCriteriaFromParams = (
params: URLSearchParams,
): FilterCriteria => ({
regions: parseFacet(params.get('regions'), SERVED_GEOS),
languages: parseFacet(params.get('languages'), SPOKEN_LANGUAGES),
deployments: parseFacet(params.get('deployments'), DEPLOYMENT_EXPERTISES),
});
const encodeFacet = (set: ReadonlySet<string>): string | null =>
set.size === 0 ? null : Array.from(set).sort().join(',');
export const buildQueryString = (criteria: FilterCriteria): string => {
const params = new URLSearchParams();
const r = encodeFacet(criteria.regions);
const l = encodeFacet(criteria.languages);
const d = encodeFacet(criteria.deployments);
if (r) params.set('regions', r);
if (l) params.set('languages', l);
if (d) params.set('deployments', d);
return params.toString();
};
export const toggleInSet = <T>(
set: ReadonlySet<T>,
value: T,
): ReadonlySet<T> => {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
};
@@ -0,0 +1,54 @@
import { Suspense } from 'react';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { theme } from '@/theme';
import { buildRouteMetadata } from '@/lib/seo';
import { getPartners } from '@/lib/partners-api/get-partners';
import { MarketplaceHeader } from './components';
import { MarketplaceClient } from './MarketplaceClient';
export const generateMetadata = buildRouteMetadata('partnersList');
type PartnersMarketplacePageProps = {
params: Promise<LocaleRouteParams>;
};
export default async function PartnersMarketplacePage({
params,
}: PartnersMarketplacePageProps) {
const [, stats, livePartners] = await Promise.all([
getRouteI18n(params),
fetchCommunityStats(),
getPartners(),
]);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<MarketplaceHeader />
<Suspense fallback={null}>
<MarketplaceClient partners={livePartners} />
</Suspense>
</>
);
}
@@ -0,0 +1,96 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useMemo } from 'react';
import type {
DeploymentExpertise,
ServedGeo,
SpokenLanguage,
} from '@/lib/partners-api';
import {
type FilterCriteria,
hasAnyFilter as computeHasAnyFilter,
} from './filter-partners';
import {
buildQueryString,
parseCriteriaFromParams,
toggleInSet,
} from './filter-url-helpers';
type UseFilterStateReturn = {
criteria: FilterCriteria;
toggleRegion: (geo: ServedGeo) => void;
toggleLanguage: (lang: SpokenLanguage) => void;
toggleDeployment: (dep: DeploymentExpertise) => void;
clearAll: () => void;
hasAnyFilter: boolean;
};
export const useFilterState = (): UseFilterStateReturn => {
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const criteria = useMemo(
() => parseCriteriaFromParams(new URLSearchParams(params.toString())),
[params],
);
const writeCriteria = useCallback(
(next: FilterCriteria) => {
const qs = buildQueryString(next);
const url = qs ? `${pathname}?${qs}` : pathname;
router.replace(url, { scroll: false });
},
[pathname, router],
);
const toggleRegion = useCallback(
(geo: ServedGeo) => {
writeCriteria({
...criteria,
regions: toggleInSet(criteria.regions, geo),
});
},
[criteria, writeCriteria],
);
const toggleLanguage = useCallback(
(lang: SpokenLanguage) => {
writeCriteria({
...criteria,
languages: toggleInSet(criteria.languages, lang),
});
},
[criteria, writeCriteria],
);
const toggleDeployment = useCallback(
(dep: DeploymentExpertise) => {
writeCriteria({
...criteria,
deployments: toggleInSet(criteria.deployments, dep),
});
},
[criteria, writeCriteria],
);
const clearAll = useCallback(() => {
writeCriteria({
regions: new Set(),
languages: new Set(),
deployments: new Set(),
});
}, [writeCriteria]);
return {
criteria,
toggleRegion,
toggleLanguage,
toggleDeployment,
clearAll,
hasAnyFilter: computeHasAnyFilter(criteria),
};
};
@@ -0,0 +1,84 @@
import { partnersApiFetch } from '@/lib/partners-api/client';
describe('partnersApiFetch', () => {
const ORIGINAL_ENV = process.env;
beforeEach(() => {
process.env = {
...ORIGINAL_ENV,
TWENTY_PARTNERS_API_URL: 'https://twenty.example.com',
TWENTY_PARTNERS_API_KEY: 'test-key-123',
};
});
afterEach(() => {
process.env = ORIGINAL_ENV;
jest.restoreAllMocks();
});
it('throws a clear error when TWENTY_PARTNERS_API_URL is missing', async () => {
delete process.env.TWENTY_PARTNERS_API_URL;
await expect(partnersApiFetch('/rest/people')).rejects.toThrow(
/TWENTY_PARTNERS_API_URL/,
);
});
it('throws a clear error when TWENTY_PARTNERS_API_KEY is missing', async () => {
delete process.env.TWENTY_PARTNERS_API_KEY;
await expect(partnersApiFetch('/rest/people')).rejects.toThrow(
/TWENTY_PARTNERS_API_KEY/,
);
});
it('sends Authorization: Bearer header and returns parsed JSON on 2xx', async () => {
const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const result = await partnersApiFetch('/rest/people');
expect(result).toEqual({ ok: true });
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('https://twenty.example.com/rest/people');
expect((init as RequestInit).headers).toMatchObject({
Authorization: 'Bearer test-key-123',
Accept: 'application/json',
});
expect((init as RequestInit).cache).toBe('no-store');
});
it('throws on non-2xx with status, path, and body snippet', async () => {
jest
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('Workspace not found', { status: 404 }));
await expect(partnersApiFetch('/rest/people')).rejects.toThrow(
/404.*\/rest\/people.*Workspace not found/s,
);
});
it('lets the caller override cache and merges headers', async () => {
const fetchSpy = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }));
await partnersApiFetch('/rest/people', {
cache: 'force-cache',
headers: { 'X-Custom': 'yes' },
});
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.cache).toBe('force-cache');
expect(init.headers).toMatchObject({
Authorization: 'Bearer test-key-123',
Accept: 'application/json',
'x-custom': 'yes',
});
});
});
@@ -0,0 +1,37 @@
export const partnersApiFetch = async (
path: string,
init: RequestInit = {},
): Promise<unknown> => {
const baseUrl = process.env.TWENTY_PARTNERS_API_URL;
const apiKey = process.env.TWENTY_PARTNERS_API_KEY;
if (!baseUrl) {
throw new Error('Missing TWENTY_PARTNERS_API_URL env var');
}
if (!apiKey) {
throw new Error('Missing TWENTY_PARTNERS_API_KEY env var');
}
const callerHeaders = Object.fromEntries(
new Headers(init.headers ?? {}).entries(),
);
const base = baseUrl.replace(/\/$/, '');
const response = await fetch(`${base}${path}`, {
cache: 'no-store',
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
...callerHeaders,
},
});
if (!response.ok) {
const body = await response.text();
throw new Error(
`Twenty API request failed: ${response.status} ${path}${body.slice(0, 500)}`,
);
}
return response.json();
};
@@ -0,0 +1,44 @@
import { partnersApiFetch } from './client';
import type { MarketplacePartner } from './partner-types';
type ApiPartner = {
id: string;
name: string;
slug: string;
introduction: string;
languagesSpoken: MarketplacePartner['languagesSpoken'][number][];
deploymentExpertise: MarketplacePartner['deploymentExpertise'][number][];
region: MarketplacePartner['region'][number][];
calendarLink: { primaryLinkUrl: string | null } | null;
};
type ApiResponse = { ok: boolean; count: number; partners: ApiPartner[] };
// Bare domains stored in the CRM (e.g. "calendly.com/x") lack a scheme.
// Prepend https:// so the URL is absolute; isSafeHttpUrl in PartnerCard will
// still reject anything that doesn't parse as a valid http(s) URL.
const normalizeUrl = (raw: string): string =>
raw && !raw.includes('://') ? `https://${raw}` : raw;
export const getPartners = async (): Promise<readonly MarketplacePartner[]> => {
try {
const data = (await partnersApiFetch('/s/partners')) as ApiResponse;
if (!Array.isArray(data.partners)) {
throw new Error(
'[partners-api] Unexpected API shape: missing partners array',
);
}
return data.partners.map((p) => ({
slug: p.slug,
name: p.name,
introduction: p.introduction,
languagesSpoken: p.languagesSpoken,
deploymentExpertise: p.deploymentExpertise,
region: p.region,
calendarLink: normalizeUrl(p.calendarLink?.primaryLinkUrl ?? ''),
}));
} catch (error) {
console.error('[partners-api] getPartners failed:', error);
return [];
}
};
@@ -0,0 +1,11 @@
export {
DEPLOYMENT_EXPERTISES,
SERVED_GEOS,
SPOKEN_LANGUAGES,
} from '@/lib/partners-api/partner-facets';
export type {
DeploymentExpertise,
ServedGeo,
SpokenLanguage,
} from '@/lib/partners-api/partner-facets';
export type { MarketplacePartner } from '@/lib/partners-api/partner-types';
@@ -0,0 +1,25 @@
// Facet vocabularies are the single source of truth for partner filtering:
// the `as const` arrays drive both the runtime filter options and the union
// types derived from them, so the two can never drift out of sync.
export const SERVED_GEOS = [
'EUROPE',
'US',
'LATAM',
'MENA',
'APAC',
'AFRICA',
] as const;
export type ServedGeo = (typeof SERVED_GEOS)[number];
export const SPOKEN_LANGUAGES = [
'ENGLISH',
'FRENCH',
'GERMAN',
'CHINESE',
'SPANISH',
] as const;
export type SpokenLanguage = (typeof SPOKEN_LANGUAGES)[number];
export const DEPLOYMENT_EXPERTISES = ['CLOUD', 'SELF_HOST'] as const;
export type DeploymentExpertise = (typeof DEPLOYMENT_EXPERTISES)[number];
@@ -0,0 +1,15 @@
import type {
DeploymentExpertise,
ServedGeo,
SpokenLanguage,
} from './partner-facets';
export type MarketplacePartner = {
slug: string;
name: string;
introduction: string;
calendarLink: string;
deploymentExpertise: readonly DeploymentExpertise[];
region: readonly ServedGeo[];
languagesSpoken: readonly SpokenLanguage[];
};
@@ -48,6 +48,15 @@ export const STATIC_WEBSITE_ROUTES = [
priority: 0.7,
indexed: true,
},
{
id: 'partnersList',
path: '/partners/list',
title: msg`Find a Twenty Partner — Certified Open Source CRM Implementers`,
description: msg`Browse Twenty's certified partner network: regional coverage, languages, deployment expertise, and direct booking links.`,
changeFrequency: 'weekly',
priority: 0.7,
indexed: true,
},
{
id: 'releases',
path: '/releases',
@@ -8,6 +8,7 @@ export type WebsiteRouteId =
| 'product'
| 'pricing'
| 'partners'
| 'partnersList'
| 'releases'
| 'customers'
| 'articles'