## 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)
43 lines
1.8 KiB
Bash
43 lines
1.8 KiB
Bash
PARTNER_APPLICATION_WEBHOOK_URL=
|
||
|
||
# Optional GitHub personal-access token (no scopes required) used by the
|
||
# community-stats fetcher (`src/lib/community/fetch-github-star-count.ts`).
|
||
# When set, requests use the 5000/hr authenticated limit instead of the
|
||
# 60/hr unauthenticated per-IP limit. Both responses are also cached for
|
||
# one hour via `unstable_cache`, so dev without a token still works.
|
||
# GITHUB_TOKEN=
|
||
|
||
# Public site URL — canonical origin used by `metadataBase`, sitemap, robots,
|
||
# OG/Twitter card URLs, Stripe checkout success URL, and billing portal return.
|
||
# No trailing slash. Defaults to https://twenty.com when unset.
|
||
NEXT_PUBLIC_WEBSITE_URL=
|
||
|
||
# --- Visual runtime kill switches ---------------------------------------
|
||
# Hard kill switch for every WebGL/Three/R3F decorative visual on the site.
|
||
# Set to "1" / "true" to ship a build with all heavy visuals statically
|
||
# replaced by their fallbacks (e.g. during a GPU-driver-related incident).
|
||
# NEXT_PUBLIC_DISABLE_HEAVY_VISUALS=
|
||
|
||
# Soft cap on the number of concurrent WebGL contexts the page is allowed
|
||
# to spin up. The browser's own hard cap is typically 8–16; we default to 8
|
||
# so a single page that mounts every illustration cannot exhaust the GPU
|
||
# context pool on integrated graphics.
|
||
# NEXT_PUBLIC_MAX_WEBGL_CONTEXTS=8
|
||
|
||
# Stripe — self-hosted enterprise checkout & subscription APIs
|
||
STRIPE_SECRET_KEY=
|
||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=
|
||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=
|
||
|
||
# RS256 key pair used to sign enterprise license JWTs (use literal \n in PEM for env)
|
||
ENTERPRISE_JWT_PRIVATE_KEY=
|
||
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=
|