From 7e034f711f88c5a3ca2558b10a15b6b5bd6ce5b7 Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:32:54 +0400 Subject: [PATCH] feat(website): surface partner Categories (partnerScope) in marketplace, drop deploymentExpertise facet (#21127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Rebinds the marketplace's expertise facet from `deploymentExpertise` (Cloud / Self-host) to **`partnerScope`** — the five partner Categories: Advisory & Discovery · Solutioning · Custom Development · Hosting & Infrastructure · Training & Adoption. Moves the card chip, the profile facts row, the dropdown filter, the `?categories=` URL param, and the API-boundary normalization onto `partnerScope`. The standalone Cloud/Self-host facet is **dropped** (hosting is now the `HOSTING` category), per the harmonization decision. ## Depends on - The app exposing `partnerScope` — companion app PR #21126. - The new `partnerScope` options + data migration — signup app PR #21040. ## Tests TDD red→green on: `filter-partners`, both API normalizers, `filter-url-helpers`, `PartnerCard`, `use-filter-state`. 53/53 pass; typecheck + lint + format clean. ## Merge order (we'll decide) Independent diff. Suggested last of the four, after the signup PRs (#21039 / #21040) and the app PR (#21126). Run `lingui:extract` once after #21039 merges so the `.po` files don't conflict twice. Deploy the app + migrate before the website ships. --- DESIGN.md | 159 ++++++++ PRODUCT.md | 80 ++++ packages/twenty-website/package.json | 1 + .../partners/list/MarketplaceClient.tsx | 12 +- .../list/__tests__/PartnerCard.test.tsx | 24 +- .../list/__tests__/filter-partners.test.ts | 48 ++- .../list/__tests__/filter-url-helpers.test.ts | 26 +- .../list/__tests__/use-filter-state.test.tsx | 2 +- .../list/components/ActiveFilterPills.tsx | 86 ++++ .../partners/list/components/FilterBar.tsx | 82 ++-- .../list/components/FilterChipRow.tsx | 98 ----- .../list/components/FilterDropdown.tsx | 160 ++++++++ .../list/components/MarketplaceGrid.tsx | 10 +- .../partners/list/components/PartnerCard.tsx | 88 ++++- .../list/components/PartnerChipRow.tsx | 26 +- .../list/components/PartnerMoneyRow.tsx | 64 +++ .../partners/list/components/chip-labels.ts | 46 ++- .../partners/list/components/index.ts | 5 +- .../[locale]/partners/list/filter-partners.ts | 10 +- .../partners/list/filter-url-helpers.ts | 8 +- .../src/app/[locale]/partners/list/page.tsx | 5 +- .../partners/list/use-filter-state.ts | 14 +- .../components/BackToMarketplaceLink.tsx | 32 ++ .../[slug]/components/PartnerFactsList.tsx | 125 ++++++ .../[slug]/components/PartnerProfileCtas.tsx | 79 ++++ .../components/PartnerProfileHeader.tsx | 71 ++++ .../[slug]/components/PartnerProfileIntro.tsx | 26 ++ .../[slug]/components/PartnerProfilePhoto.tsx | 67 ++++ .../[slug]/components/PartnerRatesPanel.tsx | 89 +++++ .../profile/[slug]/components/index.ts | 7 + .../[locale]/partners/profile/[slug]/page.tsx | 289 ++++++++++++++ .../lib/format/__tests__/format-usd.test.ts | 34 ++ .../src/lib/format/format-usd.ts | 23 ++ .../__tests__/get-partner-by-slug.test.ts | 75 ++++ .../__tests__/get-partners.test.ts | 106 +++++ .../lib/partners-api/get-partner-by-slug.ts | 85 ++++ .../src/lib/partners-api/get-partners.ts | 44 ++- .../src/lib/partners-api/index.ts | 6 +- .../src/lib/partners-api/partner-facets.ts | 50 ++- .../src/lib/partners-api/partner-types.ts | 12 +- yarn.lock | 371 +++++++++++++++++- 41 files changed, 2429 insertions(+), 216 deletions(-) create mode 100644 DESIGN.md create mode 100644 PRODUCT.md create mode 100644 packages/twenty-website/src/app/[locale]/partners/list/components/ActiveFilterPills.tsx delete mode 100644 packages/twenty-website/src/app/[locale]/partners/list/components/FilterChipRow.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/list/components/FilterDropdown.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/list/components/PartnerMoneyRow.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/BackToMarketplaceLink.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerFactsList.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerProfileCtas.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerProfileHeader.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerProfileIntro.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerProfilePhoto.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerRatesPanel.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/index.ts create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/page.tsx create mode 100644 packages/twenty-website/src/lib/format/__tests__/format-usd.test.ts create mode 100644 packages/twenty-website/src/lib/format/format-usd.ts create mode 100644 packages/twenty-website/src/lib/partners-api/__tests__/get-partner-by-slug.test.ts create mode 100644 packages/twenty-website/src/lib/partners-api/__tests__/get-partners.test.ts create mode 100644 packages/twenty-website/src/lib/partners-api/get-partner-by-slug.ts diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000000..9d3db7f5a37 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,159 @@ +# Twenty Website — DESIGN.md + +> Visual system for the Twenty marketing site. Distilled from `packages/twenty-website/src/theme/`. Loaded by every `impeccable` invocation alongside PRODUCT.md. + +## Theme + +**Light by default.** A founder browsing a partner profile in daylight on a 14–27 inch monitor is the default scene. The site does ship a `data-scheme="dark"` override (see `css-variables.ts`), but no current public page opts into it. Treat dark as a deferred surface. + +## Color + +Palette is OKLCH-equivalent neutrals at the surface level. The brand accents (blue, pink, yellow, green) are present in the token system but used sparingly — none of them appear on the partner pages. + +### Strategy: Restrained + +Tinted neutrals + one accent ≤10%. The accent for partner pages is the deep ink black (`var(--color-black-100)`) used in CTAs and hover states. Anything beyond a hairline border, an icon glyph, or a primary CTA should question whether it needs color at all. + +### Tokens (from `src/theme/colors.ts` + `css-variables.ts`) + +Neutrals (the workhorses): + +| Token | Hex (computed) | Role | +| --- | --- | --- | +| `colors.primary.background[100]` | `#ffffff` | Page + card surface | +| `colors.primary.text[100]` | `#1c1c1c` | Headlines, primary text | +| `colors.primary.text[80]` | `#1c1c1ccc` | Body text | +| `colors.primary.text[60]` | `#1c1c1c99` | Eyebrows, meta, captions | +| `colors.primary.text[40]` | `#1c1c1c66` | Disabled / placeholder | +| `colors.primary.text[20]` | `#1c1c1c33` | Subtle separators | +| `colors.primary.text[10]` | `#1c1c1c1a` | Hairline borders | +| `colors.primary.text[5]` | `#1c1c1c0d` | Subtle fills (rates panel, skill chips) | +| `colors.primary.border[10]` | `#1c1c1c1a` | Default border | +| `colors.primary.border[20]` | `#1c1c1c33` | Hover border | + +Reverse palette (for dark CTAs): + +| Token | Role | +| --- | --- | +| `colors.secondary.background[100]` | Filled CTA background (deep ink) | +| `colors.secondary.text[100]` | Filled CTA text (white) | + +Brand accents (currently absent from partner pages; available if needed): + +- `colors.accent.blue` — `#4a38f5` / `#8174f8` +- `colors.accent.pink` — `#ed87fc` / `#f3abfd` +- `colors.accent.yellow` — `#feffb7` / `#feffd9` +- `colors.accent.green` — `#89fc9a` / `#b0fdbe` +- `colors.highlight` — same hue as blue accent + +**Do not introduce gradients, glass blurs, or saturated fills on partner pages.** Color is conviction here, not decoration. + +## Typography + +Three families, each load-balanced via CSS variables: + +| Family | Var | Use | +| --- | --- | --- | +| `theme.font.family.serif` | `--font-serif` | Headlines, partner names, headline values | +| `theme.font.family.sans` | `--font-sans` | Body, prose, interactive labels | +| `theme.font.family.mono` | `--font-mono` | Eyebrows, meta, currency labels, tabular numerics | +| `theme.font.family.retro` | `--font-retro` | Reserved (not used on partner pages) | + +### Weight + Size Contrast + +Weights: `light: 300`, `regular: 400`, `medium: 500`. No bold. Hierarchy is driven by scale and family contrast, never by weight alone. + +Scale (`theme.font.size(n)` → `calc(var(--font-base) * n)`, where `--font-base: 0.25rem` ≈ 4px): + +- Display / h1: size 9–12 (36–48px) +- h2 / section heads: size 7–8 (28–32px) +- h3 / card heads: size 5–6 (20–24px) +- Body / prose: size 4–5 (16–20px) +- Eyebrow / meta: size 3 (12px) with `letter-spacing: 0.06–0.08em` and `text-transform: uppercase` + +Body line length: cap at 65–75ch (the existing `PartnerProfileIntro` uses `max-width: 62ch` — keep that order of magnitude). + +### Hierarchy contract + +- A serif `

` at size 9 light reads as a partner's name on the detail page. +- A mono eyebrow above or below it locates the partner (region · city · country). +- A serif size 6 light reads as a section head. +- Body prose is sans regular. +- Currency values are serif (they read as headline numbers, not stats). +- Currency labels and meta are mono. + +## Spacing & Layout + +Base unit `4px`. Spacing helper `theme.spacing(n)` returns `n * 4px`. Common rhythms on the partner pages: + +- Inter-section gap on the detail page: `theme.spacing(10–14)` — generous, editorial breathing room. +- Inter-element gap inside a section: `theme.spacing(3–5)`. +- Card padding: `theme.spacing(6)`. +- Page horizontal padding: `theme.spacing(4)` mobile, `theme.spacing(10)` ≥ md breakpoint. + +### Radius + +`theme.radius(n)` returns `n * 2px`. The default card radius is `theme.radius(2)` = 4px. Pills use `999px`. No softer rounding than that. + +### Borders + +Borders are hairline (`1px solid theme.colors.primary.border[10]`). They define edges quietly. On hover they step to `border[20]`. Never use a chunky border as decoration. + +## Components + +### Card (PartnerCard, RatesPanel) + +White surface, hairline border, 4px radius, 24px padding, soft shadow on hover only: + +```css +background-color: ${theme.colors.primary.background[100]}; +border: 1px solid ${theme.colors.primary.border[10]}; +border-radius: ${theme.radius(2)}; +padding: ${theme.spacing(6)}; + +&:hover { + border-color: ${theme.colors.primary.border[20]}; + box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18); + transform: translateY(-2px); +} +``` + +### Chip / Pill + +Rounded `999px`, 1px border, subtle background fill (`primary.text[5]` for filter pills, transparent for chip rows), `text[80]` color, mono or sans font. + +### Button / LinkButton + +Lives in `@/design-system/components`. Two color modes: `primary` (deep ink fill, white text) and `secondary` (transparent fill, ink text + 1px border). `variant="contained"` is what partner pages use. + +### Avatar + +`PartnerAvatar` is a deterministic generated mark from name + slug. Used as fallback when `profilePictureUrl` is missing. The real photo overlays it at 120px circle on the detail page, 56px on the list card. + +## Motion + +- Hover transitions: 250ms, ease-out (cubic-bezier curve in `PartnerCard`: `0.25s ease`). +- Card entrance: 700ms cubic-bezier `0.22, 1, 0.36, 1` (ease-out-quart), 90ms stagger per index. +- All motion respects `@media (prefers-reduced-motion: reduce)` — animations stop, hover translate disabled. +- **No bounce, no elastic, no parallax.** Editorial restraint. + +## Iconography + +`@tabler/icons-react`, 14–16px on body-level chips, 18–24px on buttons. Always `aria-hidden="true"` when decorative. Stroke width `2` (default). + +## Accessibility Defaults + +- Focus ring: `outline: 2px solid theme.colors.primary.text[100]; outline-offset: 4px` (already used on the card link). +- Touch target ≥ 40×40px on mobile. +- `aria-label` on icon-only buttons, `aria-labelledby` on sectioned regions. +- All `` includes `rel="noopener noreferrer"`. +- Color is never the sole carrier of meaning. The money pills carry both an icon and a text label. + +## Anti-patterns (project-specific) + +In addition to the impeccable shared absolute bans: + +- **Do not use the brand accent colors (blue/pink/yellow/green) on partner pages** unless we have a stronger reason than "to add color". +- **No skeuomorphic shadows on cards.** The hover shadow is `0 12px 32px -16px rgba(0,0,0,0.18)` — that's the ceiling. +- **No gradients on anything.** Including text, borders, and backgrounds. +- **No floating "Trusted by" logo bars** on partner pages. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000000..277a20baf74 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,80 @@ +# Twenty Website — Product & Brand Context + +> Strategic context for design work on the Twenty marketing site (`packages/twenty-website`). Loaded by every `impeccable` invocation. + +## Register + +**Brand.** The marketing site is a public-facing surface where the design itself is part of the credibility argument. Prospects evaluate Twenty partly by how the site feels. The product app (`packages/twenty-front`) is a separate product-register surface, governed elsewhere. + +## Users & Purpose + +The primary audience varies by route, but the working assumption for partner-related pages is: + +- **Who:** A budget-holding decision maker (founder, RevOps lead, or COO) shopping for a CRM implementation partner. Already on Twenty's site, evaluating a shortlist of partners. +- **Context:** Doing a side-by-side comparison across 2–5 candidates over a single browsing session. Will spend 30–90 seconds on each profile before deciding whether to book a call. +- **Decision being made:** "Is this partner credible, the right size, the right specialty, and within budget? Do I trust them enough to commit 30 minutes to a discovery call?" + +What the partner pages must do, in priority order: +1. Communicate credibility (real firm, real person, real work). +2. Surface fit signals fast (skills, region, languages, deployment expertise, budget range). +3. Give the visitor a confident "next step" affordance (book a call or vet via LinkedIn) without pressure. + +## Desired Outcome + +The redesign should make `/partners/profile/[slug]` feel like a *thoughtfully curated profile of a top-tier partner*, not a generic templated card. A visitor should leave thinking "this firm is serious" even if they don't book a call this session. + +Specifically: +- **Confidence over information density.** A short, well-typeset profile beats a packed-but-busy one. +- **Editorial restraint.** White space, deliberate type hierarchy, and a few well-chosen details say more than dozens of small components. +- **Quiet conviction.** No hype copy, no growth-hack patterns, no "Trusted by" logo strips. The partner's own work and intro speak for themselves. + +## Brand Personality + +**Editorial · Founder-led · Considered.** + +The site reads like a thoughtful indie publication, not a SaaS landing page. Serif headlines, plenty of whitespace, deliberate typographic rhythm. Quietly opinionated — Twenty has a point of view about CRM (open-source, customizable, well-designed) and the site reflects that without shouting. + +Tonal anchors: +- Stripe's documentation for clarity, Linear's marketing for restraint, an editorial print magazine for typography choices. + +## Anti-references + +**Reject these patterns. They make the work read as generic AI / generic SaaS:** + +- **Generic SaaS landing.** Big-number heroes, identical icon-grid cards, gradient text, navy + lime accent color schemes, "supercharge your workflow" language. +- **Corporate enterprise tone.** Stock photos of diverse handshakes. "Trusted by Fortune 500" logo strips as the primary credibility move. Trust-badge bars. +- **Bento templates.** Repetitive same-size cards. Vercel-style scroll-pin animations on every section. +- **Side-stripe borders, gradient text, glassmorphism, hero-metric templates, identical card grids** — see impeccable's shared absolute bans. + +## Strategic Design Principles + +1. **Typography carries the design.** The brand has a serif/sans/mono trio. Hierarchy is set by scale + weight contrast, not by color or borders. +2. **Restrained palette.** Tinted neutrals (black/white via CSS variables, with alpha-tone variants for text and borders) carry 90%+ of the surface. Accent color used sparingly when it appears at all. +3. **Whitespace is a feature.** Tight cards feel cheap. Pages should breathe. +4. **Asymmetry over grid.** A 12-col bento is the wrong shape for a profile page. Use asymmetric two-column layouts where one column does heavy lifting. +5. **One opinionated detail per page.** Each surface should have one moment of editorial conviction (a typographic flourish, a precise micro-interaction, a deliberate space) rather than five generic flourishes. + +## Accessibility + +**WCAG AA + keyboard + screen reader baseline:** + +- All interactive elements reachable by keyboard, focus visible (`outline: 2px solid`, not just color shift). +- Semantic landmarks: `
`, `
`, `

`, 'i')); + expect(html).toMatch( + new RegExp(`]*>\\s*]*>${FIXTURE.name}\\s*`, 'i'), + ); + expect(html).toContain(`href="/en/partners/profile/${FIXTURE.slug}"`); }); it('renders the geo eyebrow with the first served region', () => { @@ -50,7 +61,7 @@ describe('PartnerCard', () => { const expectedChipCount = FIXTURE.region.length + FIXTURE.languagesSpoken.length + - FIXTURE.deploymentExpertise.length; + FIXTURE.partnerScope.length; const liMatches = html.match(/]*>/g) ?? []; expect(liMatches.length).toBe(expectedChipCount); }); @@ -74,9 +85,10 @@ describe('PartnerCard', () => { , ); - expect(html).not.toContain('href='); + expect(html).not.toContain(`href="${unsafeLink}"`); }); }); 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 index 7bfd65cf794..b1cd99fe337 100644 --- 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 @@ -14,7 +14,15 @@ const make = (overrides: Partial): MarketplacePartner => ({ calendarLink: 'https://calendly.com/p', region: [], languagesSpoken: [], - deploymentExpertise: [], + partnerScope: [], + hourlyRateUsd: null, + projectBudgetMinUsd: null, + projectBudgetTypicalUsd: null, + linkedinUrl: '', + profilePictureUrl: '', + city: '', + country: '', + skills: [], ...overrides, }); @@ -22,19 +30,19 @@ const felix = make({ slug: 'felix', region: ['EUROPE', 'US', 'APAC'], languagesSpoken: ['ENGLISH', 'FRENCH'], - deploymentExpertise: ['CLOUD', 'SELF_HOST'], + partnerScope: ['HOSTING', 'DEVELOPMENT'], }); const rashad = make({ slug: 'rashad', region: ['US', 'EUROPE'], languagesSpoken: ['ENGLISH', 'FRENCH'], - deploymentExpertise: ['CLOUD'], + partnerScope: ['DEVELOPMENT'], }); const acme = make({ slug: 'acme', region: ['MENA'], languagesSpoken: ['ENGLISH'], - deploymentExpertise: ['SELF_HOST'], + partnerScope: ['SUPPORT'], }); const all = [felix, rashad, acme] as const; @@ -48,7 +56,7 @@ describe('filterPartners', () => { const c: FilterCriteria = { regions: new Set(['MENA']), languages: new Set(), - deployments: new Set(), + categories: new Set(), }; expect(filterPartners(all, c)).toEqual([acme]); }); @@ -57,25 +65,43 @@ describe('filterPartners', () => { const c: FilterCriteria = { regions: new Set(['APAC', 'MENA']), languages: new Set(), - deployments: new Set(), + categories: new Set(), }; expect(filterPartners(all, c)).toEqual([felix, acme]); }); - it('combines facets with AND', () => { + it('combines region + language facets with AND', () => { const c: FilterCriteria = { regions: new Set(['EUROPE']), languages: new Set(['FRENCH']), - deployments: new Set(), + categories: new Set(), }; expect(filterPartners(all, c)).toEqual([felix, rashad]); }); + it('filters by a single category', () => { + const c: FilterCriteria = { + regions: new Set(), + languages: new Set(), + categories: new Set(['SUPPORT']), + }; + expect(filterPartners(all, c)).toEqual([acme]); + }); + + it('filters by multiple categories (OR within facet)', () => { + const c: FilterCriteria = { + regions: new Set(), + languages: new Set(), + categories: new Set(['HOSTING', 'SUPPORT']), + }; + expect(filterPartners(all, c)).toEqual([felix, acme]); + }); + it('returns empty when no partner matches', () => { const c: FilterCriteria = { regions: new Set(), languages: new Set(['GERMAN']), - deployments: new Set(), + categories: new Set(), }; expect(filterPartners(all, c)).toEqual([]); }); @@ -84,7 +110,7 @@ describe('filterPartners', () => { const c: FilterCriteria = { regions: new Set(['EUROPE']), languages: new Set(['FRENCH']), - deployments: new Set(['SELF_HOST']), + categories: new Set(['HOSTING']), }; expect(filterPartners(all, c)).toEqual([felix]); }); @@ -103,7 +129,7 @@ describe('hasAnyFilter', () => { hasAnyFilter({ ...EMPTY_CRITERIA, languages: new Set(['ENGLISH']) }), ).toBe(true); expect( - hasAnyFilter({ ...EMPTY_CRITERIA, deployments: new Set(['CLOUD']) }), + hasAnyFilter({ ...EMPTY_CRITERIA, categories: new Set(['HOSTING']) }), ).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 index 9dd17cff658..27beb3696c3 100644 --- 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 @@ -1,5 +1,5 @@ import { - DEPLOYMENT_EXPERTISES, + PARTNER_SCOPES, SERVED_GEOS, SPOKEN_LANGUAGES, } from '@/lib/partners-api'; @@ -15,26 +15,26 @@ describe('parseCriteriaFromParams', () => { const c = parseCriteriaFromParams(new URLSearchParams()); expect(c.regions.size).toBe(0); expect(c.languages.size).toBe(0); - expect(c.deployments.size).toBe(0); + expect(c.categories.size).toBe(0); }); it('parses a CSV of valid values', () => { const c = parseCriteriaFromParams( new URLSearchParams( - 'regions=EUROPE,US&languages=FRENCH&deployments=CLOUD', + 'regions=EUROPE,US&languages=FRENCH&categories=HOSTING', ), ); expect(c.regions).toEqual(new Set(['EUROPE', 'US'])); expect(c.languages).toEqual(new Set(['FRENCH'])); - expect(c.deployments).toEqual(new Set(['CLOUD'])); + expect(c.categories).toEqual(new Set(['HOSTING'])); }); it('silently drops unknown values', () => { const c = parseCriteriaFromParams( - new URLSearchParams('regions=EUROPE,MARS,US&languages=KLINGON'), + new URLSearchParams('regions=EUROPE,MARS,US&categories=KLINGON'), ); expect(c.regions).toEqual(new Set(['EUROPE', 'US'])); - expect(c.languages.size).toBe(0); + expect(c.categories.size).toBe(0); }); it('handles whitespace inside CSV values', () => { @@ -51,7 +51,7 @@ describe('buildQueryString', () => { buildQueryString({ regions: new Set(), languages: new Set(), - deployments: new Set(), + categories: new Set(), }), ).toBe(''); }); @@ -60,20 +60,20 @@ describe('buildQueryString', () => { 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'>, + categories: new Set(['HOSTING']) as ReadonlySet<'HOSTING'>, }; 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); + expect(parsed.categories).toEqual(original.categories); }); it('omits facets whose sets are empty', () => { const qs = buildQueryString({ regions: new Set(['EUROPE']), languages: new Set(), - deployments: new Set(), + categories: new Set(), }); expect(qs).toBe('regions=EUROPE'); }); @@ -96,9 +96,9 @@ describe('toggleInSet', () => { }); describe('enum constants are non-empty', () => { - it('covers all known regions, languages, deployments', () => { + it('covers all known regions, languages, categories', () => { expect(SERVED_GEOS.length).toBe(6); - expect(SPOKEN_LANGUAGES.length).toBe(5); - expect(DEPLOYMENT_EXPERTISES.length).toBe(2); + expect(SPOKEN_LANGUAGES.length).toBe(35); + expect(PARTNER_SCOPES.length).toBe(5); }); }); 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 index 070dfff7ef1..e1682390eeb 100644 --- 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 @@ -42,7 +42,7 @@ describe('useFilterState', () => { 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); + expect(state.criteria.categories.size).toBe(0); }); it('parses regions=EUROPE,US into Set {EUROPE, US}', () => { diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/ActiveFilterPills.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/ActiveFilterPills.tsx new file mode 100644 index 00000000000..dfa90fcc3a7 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/ActiveFilterPills.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { styled } from '@linaria/react'; +import { IconX } from '@tabler/icons-react'; + +import { theme } from '@/theme'; + +export type ActivePill = { + key: string; + text: string; + onRemove: () => void; +}; + +export type ActiveFilterPillsProps = { + pills: readonly ActivePill[]; +}; + +const PillsRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${theme.spacing(1.5)}; +`; + +const Pill = styled.span` + align-items: center; + background: ${theme.colors.primary.text[5]}; + border: 1px solid ${theme.colors.primary.border[10]}; + border-radius: ${theme.radius(4)}; + color: ${theme.colors.primary.text[80]}; + display: inline-flex; + font-family: ${theme.font.family.sans}; + font-size: ${theme.font.size(3)}; + gap: ${theme.spacing(1)}; + line-height: ${theme.lineHeight(4)}; + padding: ${theme.spacing(0.5)} ${theme.spacing(1.5)}; +`; + +const RemoveButton = styled.button` + align-items: center; + background: transparent; + border: none; + color: ${theme.colors.primary.text[40]}; + cursor: pointer; + display: inline-flex; + flex-shrink: 0; + margin: 0; + padding: 0; + transition: color 100ms ease; + + &:hover { + color: ${theme.colors.primary.text[100]}; + } + + &:focus-visible { + outline: 2px solid ${theme.colors.primary.border[40]}; + outline-offset: 2px; + border-radius: ${theme.radius(0.5)}; + } +`; + +export function ActiveFilterPills({ pills }: ActiveFilterPillsProps) { + const { i18n } = useLingui(); + + if (pills.length === 0) { + return null; + } + + return ( + + {pills.map((pill) => ( + + {pill.text} + + + + + ))} + + ); +} 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 index 73bc4ae569d..3deb8e99504 100644 --- a/packages/twenty-website/src/app/[locale]/partners/list/components/FilterBar.tsx +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/FilterBar.tsx @@ -6,10 +6,10 @@ import { useLingui } from '@lingui/react'; import { styled } from '@linaria/react'; import { - DEPLOYMENT_EXPERTISES, + PARTNER_SCOPES, SERVED_GEOS, SPOKEN_LANGUAGES, - type DeploymentExpertise, + type PartnerScope, type ServedGeo, type SpokenLanguage, } from '@/lib/partners-api'; @@ -17,12 +17,13 @@ import { theme } from '@/theme'; import type { FilterCriteria } from '../filter-partners'; import { - DEPLOYMENT_EXPERTISE_LABELS, + PARTNER_SCOPE_LABELS, SERVED_GEO_LABELS, SPOKEN_LANGUAGE_LABELS, } from './chip-labels'; import { ClearFiltersButton } from './ClearFiltersButton'; -import { FilterChipRow } from './FilterChipRow'; +import { ActiveFilterPills, type ActivePill } from './ActiveFilterPills'; +import { FilterDropdown } from './FilterDropdown'; type FilterBarProps = { criteria: FilterCriteria; @@ -31,7 +32,7 @@ type FilterBarProps = { hasAnyFilter: boolean; onToggleRegion: (geo: ServedGeo) => void; onToggleLanguage: (lang: SpokenLanguage) => void; - onToggleDeployment: (dep: DeploymentExpertise) => void; + onToggleCategory: (scope: PartnerScope) => void; onClearAll: () => void; }; @@ -41,6 +42,12 @@ const BarSection = styled.section` gap: ${theme.spacing(4)}; `; +const DropdownRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: ${theme.spacing(2)}; +`; + const Footer = styled.div` align-items: center; display: flex; @@ -65,34 +72,55 @@ export function FilterBar({ hasAnyFilter, onToggleRegion, onToggleLanguage, - onToggleDeployment, + onToggleCategory, onClearAll, }: FilterBarProps) { const { i18n } = useLingui(); + const pills: ActivePill[] = [ + ...[...criteria.regions].map((geo) => ({ + key: `region:${geo}`, + text: i18n._(SERVED_GEO_LABELS[geo]), + onRemove: () => onToggleRegion(geo), + })), + ...[...criteria.languages].map((lang) => ({ + key: `language:${lang}`, + text: i18n._(SPOKEN_LANGUAGE_LABELS[lang]), + onRemove: () => onToggleLanguage(lang), + })), + ...[...criteria.categories].map((scope) => ({ + key: `category:${scope}`, + text: i18n._(PARTNER_SCOPE_LABELS[scope]), + onRemove: () => onToggleCategory(scope), + })), + ]; + return ( - - - + + + + + + {hasAnyFilter && }