[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:
@@ -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=');
|
||||
});
|
||||
});
|
||||
+109
@@ -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);
|
||||
});
|
||||
});
|
||||
+104
@@ -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);
|
||||
});
|
||||
});
|
||||
+93
@@ -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);
|
||||
});
|
||||
});
|
||||
+42
@@ -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>
|
||||
);
|
||||
}
|
||||
+65
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user