diff --git a/packages/twenty-website/.env.example b/packages/twenty-website/.env.example index 4a4f9e322b..24b69aa89a 100644 --- a/packages/twenty-website/.env.example +++ b/packages/twenty-website/.env.example @@ -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= diff --git a/packages/twenty-website/src/app/[locale]/partners/list/MarketplaceClient.tsx b/packages/twenty-website/src/app/[locale]/partners/list/MarketplaceClient.tsx new file mode 100644 index 0000000000..6bfd09bb38 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/MarketplaceClient.tsx @@ -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 ( + <> + + + + + + {filteredPartners.length === 0 ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerCard.test.tsx b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerCard.test.tsx new file mode 100644 index 0000000000..0c64a88cba --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerCard.test.tsx @@ -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( + + + , + ); + +describe('PartnerCard', () => { + it('renders the partner name as the article heading', () => { + const html = renderCard(); + expect(html).toMatch(new RegExp(`]*>${FIXTURE.name}`, '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(/]*>/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,', + 'vbscript:msgbox(1)', + '', + 'not-a-url', + ])('suppresses the CTA when calendarLink is %s', (unsafeLink) => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain('href='); + }); +}); diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-partners.test.ts b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-partners.test.ts new file mode 100644 index 0000000000..7bfd65cf79 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-partners.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-url-helpers.test.ts b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-url-helpers.test.ts new file mode 100644 index 0000000000..9dd17cff65 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/filter-url-helpers.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/use-filter-state.test.tsx b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/use-filter-state.test.tsx new file mode 100644 index 0000000000..070dfff7ef --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/use-filter-state.test.tsx @@ -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; + +function Capture({ onCapture }: { onCapture: (s: FilterStateResult) => void }) { + const state = useFilterState(); + onCapture(state); + return null; +} + +const getState = (): FilterStateResult => { + let captured!: FilterStateResult; + renderToStaticMarkup( + { + 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); + }); +}); diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/ClearFiltersButton.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/ClearFiltersButton.tsx new file mode 100644 index 0000000000..b54786ee85 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/ClearFiltersButton.tsx @@ -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; +}; + +export function ClearFiltersButton({ + children, + className, + onClick, +}: ClearFiltersButtonProps) { + return ( + + + + ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/EmptyState.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/EmptyState.tsx new file mode 100644 index 0000000000..967653e7f4 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/EmptyState.tsx @@ -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 ( + + {i18n._(msg`No partners match your filters`)} + + {i18n._(msg`Try removing some filters or browse all partners.`)} + + + {i18n._(msg`Clear filters`)} + + + ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/FilterBar.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterBar.tsx new file mode 100644 index 0000000000..b64a7c0f78 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterBar.tsx @@ -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 ( + + + + +
+ + {hasAnyFilter + ? i18n._(msg`Showing ${visibleCount} of ${totalCount} partners`) + : i18n._(msg`Showing all ${totalCount} partners`)} + + {hasAnyFilter && ( + + {i18n._(msg`Clear filters`)} + + )} +
+
+ ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/FilterChipRow.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterChipRow.tsx new file mode 100644 index 0000000000..6428790e65 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterChipRow.tsx @@ -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 = { + label: MessageDescriptor; + values: readonly T[]; + valueLabels: Record; + selected: ReadonlySet; + 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({ + label, + values, + valueLabels, + selected, + onToggle, +}: FilterChipRowProps) { + const { i18n } = useLingui(); + + return ( +
+ {i18n._(label)} + + {values.map((value) => { + const isPressed = selected.has(value); + return ( + onToggle(value)} + > + {i18n._(valueLabels[value])} + + ); + })} + +
+ ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceGrid.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceGrid.tsx new file mode 100644 index 0000000000..40a36b4e10 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceGrid.tsx @@ -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 ( +
+ + + {partners.map((partner, index) => ( + + ))} + + +
+ ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceHeader.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceHeader.tsx new file mode 100644 index 0000000000..a8d9feded4 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/MarketplaceHeader.tsx @@ -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 ( +
+ + + + {i18n._(msg`Marketplace`)} + + + + {i18n._(msg`Find your`)}{' '} + + {i18n._(msg`Twenty partner`)} + + + + {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.`, + )} + + +
+ ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx new file mode 100644 index 0000000000..733120c339 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx @@ -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 ( + + ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx new file mode 100644 index 0000000000..193be337e3 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx @@ -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 ( + + + + + {partner.name} + {countryLine} + + + + {partner.introduction} + + + ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx new file mode 100644 index 0000000000..a7eeb6458b --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx @@ -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 = { + label: MessageDescriptor; + values: readonly TValue[]; + valueLabels: Record; +}; + +export function PartnerChipRow({ + label, + values, + valueLabels, +}: PartnerChipRowProps) { + const { i18n } = useLingui(); + + return ( + + {i18n._(label)} + + + {values.map((value) => ( +
  • + {i18n._(valueLabels[value])} +
  • + ))} +
    +
    +
    + ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/chip-labels.ts b/packages/twenty-website/src/app/[locale]/partners/list/components/chip-labels.ts new file mode 100644 index 0000000000..1b7b6cf80b --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/chip-labels.ts @@ -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 = { + EUROPE: msg`Europe`, + US: msg`US`, + LATAM: msg`LATAM`, + MENA: msg`MENA`, + APAC: msg`APAC`, + AFRICA: msg`Africa`, +}; + +export const SPOKEN_LANGUAGE_LABELS: Record = + { + 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`, +}; diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/chip-styles.ts b/packages/twenty-website/src/app/[locale]/partners/list/components/chip-styles.ts new file mode 100644 index 0000000000..fb6f616dc5 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/chip-styles.ts @@ -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; +`; diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/index.ts b/packages/twenty-website/src/app/[locale]/partners/list/components/index.ts new file mode 100644 index 0000000000..77902d5da0 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/index.ts @@ -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'; diff --git a/packages/twenty-website/src/app/[locale]/partners/list/filter-partners.ts b/packages/twenty-website/src/app/[locale]/partners/list/filter-partners.ts new file mode 100644 index 0000000000..c0431382ad --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/filter-partners.ts @@ -0,0 +1,39 @@ +import type { + DeploymentExpertise, + MarketplacePartner, + ServedGeo, + SpokenLanguage, +} from '@/lib/partners-api'; + +export type FilterCriteria = { + regions: ReadonlySet; + languages: ReadonlySet; + deployments: ReadonlySet; +}; + +export const EMPTY_CRITERIA: FilterCriteria = { + regions: new Set(), + languages: new Set(), + deployments: new Set(), +}; + +const facetMatches = ( + values: readonly T[], + selected: ReadonlySet, +): 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; diff --git a/packages/twenty-website/src/app/[locale]/partners/list/filter-url-helpers.ts b/packages/twenty-website/src/app/[locale]/partners/list/filter-url-helpers.ts new file mode 100644 index 0000000000..5ba11753f5 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/filter-url-helpers.ts @@ -0,0 +1,52 @@ +import { + DEPLOYMENT_EXPERTISES, + SERVED_GEOS, + SPOKEN_LANGUAGES, +} from '@/lib/partners-api'; + +import type { FilterCriteria } from './filter-partners'; + +const parseFacet = ( + raw: string | null, + allowed: readonly T[], +): ReadonlySet => { + if (!raw) return new Set(); + const allowedSet = new Set(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 | 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 = ( + set: ReadonlySet, + value: T, +): ReadonlySet => { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; +}; diff --git a/packages/twenty-website/src/app/[locale]/partners/list/page.tsx b/packages/twenty-website/src/app/[locale]/partners/list/page.tsx new file mode 100644 index 0000000000..eddfe0faab --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/page.tsx @@ -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; +}; + +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 ( + <> + + + + + + + + + + + + + + ); +} diff --git a/packages/twenty-website/src/app/[locale]/partners/list/use-filter-state.ts b/packages/twenty-website/src/app/[locale]/partners/list/use-filter-state.ts new file mode 100644 index 0000000000..40b8fbd61e --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/use-filter-state.ts @@ -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), + }; +}; diff --git a/packages/twenty-website/src/lib/partners-api/__tests__/client.test.ts b/packages/twenty-website/src/lib/partners-api/__tests__/client.test.ts new file mode 100644 index 0000000000..f6dd6b5b3c --- /dev/null +++ b/packages/twenty-website/src/lib/partners-api/__tests__/client.test.ts @@ -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', + }); + }); +}); diff --git a/packages/twenty-website/src/lib/partners-api/client.ts b/packages/twenty-website/src/lib/partners-api/client.ts new file mode 100644 index 0000000000..29b494fb79 --- /dev/null +++ b/packages/twenty-website/src/lib/partners-api/client.ts @@ -0,0 +1,37 @@ +export const partnersApiFetch = async ( + path: string, + init: RequestInit = {}, +): Promise => { + 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(); +}; diff --git a/packages/twenty-website/src/lib/partners-api/get-partners.ts b/packages/twenty-website/src/lib/partners-api/get-partners.ts new file mode 100644 index 0000000000..66c20d280f --- /dev/null +++ b/packages/twenty-website/src/lib/partners-api/get-partners.ts @@ -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 => { + 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 []; + } +}; diff --git a/packages/twenty-website/src/lib/partners-api/index.ts b/packages/twenty-website/src/lib/partners-api/index.ts new file mode 100644 index 0000000000..e8c9c9ac1c --- /dev/null +++ b/packages/twenty-website/src/lib/partners-api/index.ts @@ -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'; diff --git a/packages/twenty-website/src/lib/partners-api/partner-facets.ts b/packages/twenty-website/src/lib/partners-api/partner-facets.ts new file mode 100644 index 0000000000..bb64248757 --- /dev/null +++ b/packages/twenty-website/src/lib/partners-api/partner-facets.ts @@ -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]; diff --git a/packages/twenty-website/src/lib/partners-api/partner-types.ts b/packages/twenty-website/src/lib/partners-api/partner-types.ts new file mode 100644 index 0000000000..0d6a900394 --- /dev/null +++ b/packages/twenty-website/src/lib/partners-api/partner-types.ts @@ -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[]; +}; diff --git a/packages/twenty-website/src/lib/website-routing/static-website-routes.ts b/packages/twenty-website/src/lib/website-routing/static-website-routes.ts index be73fe438c..a241bf11ca 100644 --- a/packages/twenty-website/src/lib/website-routing/static-website-routes.ts +++ b/packages/twenty-website/src/lib/website-routing/static-website-routes.ts @@ -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', diff --git a/packages/twenty-website/src/lib/website-routing/types.ts b/packages/twenty-website/src/lib/website-routing/types.ts index ed77a2a1cd..272b7228b8 100644 --- a/packages/twenty-website/src/lib/website-routing/types.ts +++ b/packages/twenty-website/src/lib/website-routing/types.ts @@ -8,6 +8,7 @@ export type WebsiteRouteId = | 'product' | 'pricing' | 'partners' + | 'partnersList' | 'releases' | 'customers' | 'articles'