feat(website): surface partner Categories (partnerScope) in marketplace, drop deploymentExpertise facet (#21127)

## 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.
This commit is contained in:
Rashad Karanouh
2026-06-02 14:32:54 +04:00
committed by GitHub
parent 94d2e386e8
commit 7e034f711f
41 changed files with 2429 additions and 216 deletions
+159
View File
@@ -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 1427 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 912 (3648px)
- h2 / section heads: size 78 (2832px)
- h3 / card heads: size 56 (2024px)
- Body / prose: size 45 (1620px)
- Eyebrow / meta: size 3 (12px) with `letter-spacing: 0.060.08em` and `text-transform: uppercase`
Body line length: cap at 6575ch (the existing `PartnerProfileIntro` uses `max-width: 62ch` — keep that order of magnitude).
### Hierarchy contract
- A serif `<h1>` 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(1014)` — generous, editorial breathing room.
- Inter-element gap inside a section: `theme.spacing(35)`.
- 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`, 1416px on body-level chips, 1824px 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 `<a target="_blank">` 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.
+80
View File
@@ -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 25 candidates over a single browsing session. Will spend 3090 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: `<header>`, `<main>`, `<nav>`, `<section aria-labelledby=…>`, headings in order.
- All images with informational content have alt text. Decorative icons have `aria-hidden="true"`.
- Body text ≥ 4.5:1 contrast; large text (≥18pt or 14pt bold) ≥ 3:1.
- Respect `prefers-reduced-motion`. Animations stop, don't slow.
- Forms have explicit labels. Errors are announced.
## Tech & Constraints
- Next.js 16 app router (Server Components by default, `'use client'` for interactivity).
- Linaria styled-components (`@linaria/react`) for zero-runtime CSS-in-JS.
- Lingui (`@lingui/react`) for i18n; never hardcode user-visible strings.
- Theme tokens in `packages/twenty-website/src/theme/`. Colors are CSS variables resolved to OKLCH-tinted neutrals.
- `@tabler/icons-react` for iconography (no Heroicons, no custom SVGs unless purposeful).
- `@radix-ui/react-*` for primitives (Popover etc) where headless behavior is needed.
## Out of Scope for This File
- Detailed visual tokens (colors, type scale, motion specs) live in `DESIGN.md`.
- Per-page IA decisions live in shape briefs (`docs/superpowers/specs/`).
+1
View File
@@ -22,6 +22,7 @@
"@lingui/core": "^5.1.2",
"@lingui/react": "^5.1.2",
"@lottiefiles/dotlottie-react": "^0.18.10",
"@radix-ui/react-popover": "^1.1.15",
"@tabler/icons-react": "^3.41.1",
"@wyw-in-js/babel-preset": "^0.8.1",
"framer-motion": "^11.18.0",
@@ -34,14 +34,18 @@ const FilterBarInner = styled(Container)`
type MarketplaceClientProps = {
partners: readonly MarketplacePartner[];
locale: string;
};
export function MarketplaceClient({ partners }: MarketplaceClientProps) {
export function MarketplaceClient({
partners,
locale,
}: MarketplaceClientProps) {
const {
criteria,
toggleRegion,
toggleLanguage,
toggleDeployment,
toggleCategory,
clearAll,
hasAnyFilter,
} = useFilterState();
@@ -62,7 +66,7 @@ export function MarketplaceClient({ partners }: MarketplaceClientProps) {
hasAnyFilter={hasAnyFilter}
onToggleRegion={toggleRegion}
onToggleLanguage={toggleLanguage}
onToggleDeployment={toggleDeployment}
onToggleCategory={toggleCategory}
onClearAll={clearAll}
/>
</FilterBarInner>
@@ -70,7 +74,7 @@ export function MarketplaceClient({ partners }: MarketplaceClientProps) {
{filteredPartners.length === 0 ? (
<EmptyState onClearFilters={clearAll} />
) : (
<MarketplaceGrid partners={filteredPartners} />
<MarketplaceGrid partners={filteredPartners} locale={locale} />
)}
</>
);
@@ -17,22 +17,33 @@ const FIXTURE: MarketplacePartner = {
name: 'Test Partner',
introduction: 'A reliable partner for testing purposes.',
calendarLink: 'https://calendly.com/test-partner',
deploymentExpertise: ['CLOUD', 'SELF_HOST'],
partnerScope: ['HOSTING', 'DEVELOPMENT'],
region: ['EUROPE', 'US'],
languagesSpoken: ['ENGLISH', 'FRENCH'],
hourlyRateUsd: null,
projectBudgetMinUsd: null,
projectBudgetTypicalUsd: null,
linkedinUrl: '',
profilePictureUrl: '',
city: '',
country: '',
skills: [],
};
const renderCard = () =>
renderToStaticMarkup(
<I18nProvider i18n={i18n}>
<PartnerCard partner={FIXTURE} index={0} />
<PartnerCard partner={FIXTURE} index={0} locale="en" />
</I18nProvider>,
);
describe('PartnerCard', () => {
it('renders the partner name as the article heading', () => {
it('renders the partner name inside the detail-page link in the heading', () => {
const html = renderCard();
expect(html).toMatch(new RegExp(`<h3[^>]*>${FIXTURE.name}</h3>`, 'i'));
expect(html).toMatch(
new RegExp(`<h3[^>]*>\\s*<a[^>]*>${FIXTURE.name}</a>\\s*</h3>`, '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(/<li[^>]*>/g) ?? [];
expect(liMatches.length).toBe(expectedChipCount);
});
@@ -74,9 +85,10 @@ describe('PartnerCard', () => {
<PartnerCard
partner={{ ...FIXTURE, calendarLink: unsafeLink }}
index={0}
locale="en"
/>
</I18nProvider>,
);
expect(html).not.toContain('href=');
expect(html).not.toContain(`href="${unsafeLink}"`);
});
});
@@ -14,7 +14,15 @@ const make = (overrides: Partial<MarketplacePartner>): 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);
});
});
@@ -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);
});
});
@@ -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}', () => {
@@ -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 (
<PillsRow aria-label={i18n._(msg`Active filters`)}>
{pills.map((pill) => (
<Pill key={pill.key}>
{pill.text}
<RemoveButton
aria-label={i18n._(msg`Remove ${pill.text} filter`)}
onClick={pill.onRemove}
type="button"
>
<IconX size={12} strokeWidth={2} />
</RemoveButton>
</Pill>
))}
</PillsRow>
);
}
@@ -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 (
<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}
/>
<DropdownRow>
<FilterDropdown
label={msg`Regions`}
options={SERVED_GEOS}
optionLabels={SERVED_GEO_LABELS}
selected={criteria.regions}
onToggle={onToggleRegion}
/>
<FilterDropdown
label={msg`Languages`}
options={SPOKEN_LANGUAGES}
optionLabels={SPOKEN_LANGUAGE_LABELS}
selected={criteria.languages}
onToggle={onToggleLanguage}
/>
<FilterDropdown
label={msg`Categories`}
options={PARTNER_SCOPES}
optionLabels={PARTNER_SCOPE_LABELS}
selected={criteria.categories}
onToggle={onToggleCategory}
/>
</DropdownRow>
{hasAnyFilter && <ActiveFilterPills pills={pills} />}
<Footer>
<ResultCount aria-live="polite">
{hasAnyFilter ? (
@@ -1,98 +0,0 @@
'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,160 @@
'use client';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import * as Popover from '@radix-ui/react-popover';
import { IconChevronDown } from '@tabler/icons-react';
import { theme } from '@/theme';
type FilterDropdownProps<T extends string> = {
label: MessageDescriptor;
options: readonly T[];
optionLabels: Record<T, MessageDescriptor>;
selected: ReadonlySet<T>;
onToggle: (value: T) => void;
};
const TriggerButton = styled.button`
align-items: center;
background: transparent;
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(1.5)};
color: ${theme.colors.primary.text[80]};
cursor: pointer;
display: inline-flex;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3.5)};
gap: ${theme.spacing(1.5)};
line-height: ${theme.lineHeight(4)};
padding: ${theme.spacing(1.5)} ${theme.spacing(3)};
transition:
background 120ms ease,
border-color 120ms ease;
white-space: nowrap;
&[data-active='true'] {
border-color: ${theme.colors.primary.border[40]};
color: ${theme.colors.primary.text[100]};
}
&:hover {
background: ${theme.colors.primary.text[5]};
}
&:focus-visible {
outline: 2px solid ${theme.colors.primary.border[40]};
outline-offset: 2px;
}
`;
const ChevronIcon = styled(IconChevronDown)`
color: ${theme.colors.primary.text[40]};
flex-shrink: 0;
transition: transform 180ms ease;
[data-state='open'] & {
transform: rotate(180deg);
}
`;
const contentStyles = css`
background: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18);
min-width: 200px;
overflow: hidden;
padding: ${theme.spacing(2)};
z-index: 50;
`;
const OptionList = styled.div`
display: flex;
flex-direction: column;
gap: 0;
max-height: 320px;
overflow-y: auto;
`;
const OptionRow = styled.label`
align-items: center;
border-radius: ${theme.radius(1)};
color: ${theme.colors.primary.text[80]};
cursor: pointer;
display: flex;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3.5)};
gap: ${theme.spacing(2)};
line-height: ${theme.lineHeight(4)};
padding: ${theme.spacing(1.5)} ${theme.spacing(2)};
transition: background 100ms ease;
user-select: none;
&:hover {
background: ${theme.colors.primary.text[5]};
}
`;
const Checkbox = styled.input`
accent-color: ${theme.colors.primary.text[100]};
border: 1px solid ${theme.colors.primary.border[20]};
border-radius: ${theme.radius(0.5)};
cursor: pointer;
flex-shrink: 0;
height: 14px;
margin: 0;
width: 14px;
`;
export function FilterDropdown<T extends string>({
label,
options,
optionLabels,
selected,
onToggle,
}: FilterDropdownProps<T>) {
const { i18n } = useLingui();
const labelText = i18n._(label);
const hasSelection = selected.size > 0;
return (
<Popover.Root>
<Popover.Trigger asChild>
<TriggerButton
aria-label={
hasSelection
? i18n._(
msg`${labelText} filter, ${selected.size} selected, click to open`,
)
: i18n._(msg`${labelText} filter, click to open`)
}
data-active={hasSelection ? 'true' : 'false'}
type="button"
>
{hasSelection ? `${labelText} · ${selected.size}` : labelText}
<ChevronIcon size={14} strokeWidth={2} />
</TriggerButton>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content align="start" className={contentStyles} sideOffset={6}>
<OptionList role="group" aria-label={labelText}>
{options.map((option) => (
<OptionRow key={option}>
<Checkbox
checked={selected.has(option)}
onChange={() => onToggle(option)}
type="checkbox"
/>
{i18n._(optionLabels[option])}
</OptionRow>
))}
</OptionList>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
@@ -41,15 +41,21 @@ const CardGrid = styled.div`
type MarketplaceGridProps = {
partners: readonly MarketplacePartner[];
locale: string;
};
export function MarketplaceGrid({ partners }: MarketplaceGridProps) {
export function MarketplaceGrid({ partners, locale }: MarketplaceGridProps) {
return (
<Section>
<StyledContainer>
<CardGrid>
{partners.map((partner, index) => (
<PartnerCard key={partner.slug} partner={partner} index={index} />
<PartnerCard
key={partner.slug}
partner={partner}
index={index}
locale={locale}
/>
))}
</CardGrid>
</StyledContainer>
@@ -1,17 +1,20 @@
'use client';
import { IconBrandLinkedin } from '@tabler/icons-react';
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 NextLink from 'next/link';
import type { CSSProperties } from 'react';
import type { MarketplacePartner } from '@/lib/partners-api';
import { PartnerAvatar } from './PartnerAvatar';
import { PartnerChipRow } from './PartnerChipRow';
import { PartnerMoneyRow } from './PartnerMoneyRow';
import {
DEPLOYMENT_EXPERTISE_LABELS,
PARTNER_SCOPE_LABELS,
SERVED_GEO_LABELS,
SPOKEN_LANGUAGE_LABELS,
} from './chip-labels';
@@ -40,7 +43,9 @@ const CardArticle = styled.article`
display: flex;
flex-direction: column;
gap: ${theme.spacing(5)};
isolation: isolate;
padding: ${theme.spacing(6)};
position: relative;
transition:
border-color 0.25s ease,
box-shadow 0.25s ease,
@@ -76,6 +81,12 @@ const HeaderText = styled.div`
min-width: 0;
`;
const NameRow = styled.div`
align-items: center;
display: flex;
gap: ${theme.spacing(2)};
`;
const PartnerName = styled.h3`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.serif};
@@ -86,6 +97,42 @@ const PartnerName = styled.h3`
margin: 0;
`;
// The card's whole-surface link uses the "stretched link" pattern: an inline
// <a> on the name with an absolutely-positioned ::after that covers the
// entire <article>. Other anchors inside the card (LinkedIn icon, Book-a-call)
// sit above this overlay via z-index, so each remains an independent click
// target without illegal nested <a> elements.
const NameLink = styled(NextLink)`
color: inherit;
text-decoration: none;
&::after {
border-radius: ${theme.radius(2)};
content: '';
inset: 0;
position: absolute;
z-index: 0;
}
&:focus-visible::after {
outline: 2px solid ${theme.colors.primary.text[100]};
outline-offset: 4px;
}
`;
const LinkedinIconLink = styled.a`
align-items: center;
color: ${theme.colors.primary.text[60]};
display: inline-flex;
position: relative;
transition: color 0.2s ease;
z-index: 1;
&:hover {
color: ${theme.colors.primary.text[100]};
}
`;
const CountryEyebrow = styled.span`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
@@ -125,6 +172,8 @@ const ChipRows = styled.div`
const CtaWrapper = styled.div`
display: flex;
margin-top: auto;
position: relative;
z-index: 1;
`;
const isSafeHttpUrl = (raw: string) => {
@@ -138,9 +187,10 @@ const isSafeHttpUrl = (raw: string) => {
type PartnerCardProps = {
partner: MarketplacePartner;
index: number;
locale: string;
};
export function PartnerCard({ partner, index }: PartnerCardProps) {
export function PartnerCard({ partner, index, locale }: PartnerCardProps) {
const { i18n } = useLingui();
const headingId = `partner-card-heading-${partner.slug}`;
const style: PartnerCardStyle = { '--partner-card-index': index };
@@ -150,12 +200,31 @@ export function PartnerCard({ partner, index }: PartnerCardProps) {
? i18n._(SERVED_GEO_LABELS[firstGeo]).toUpperCase()
: '';
const linkedinSafe = isSafeHttpUrl(partner.linkedinUrl);
const calendarSafe = isSafeHttpUrl(partner.calendarLink);
return (
<CardArticle aria-labelledby={headingId} style={style}>
<CardHeader>
<PartnerAvatar name={partner.name} slug={partner.slug} />
<HeaderText>
<PartnerName id={headingId}>{partner.name}</PartnerName>
<NameRow>
<PartnerName id={headingId}>
<NameLink href={`/${locale}/partners/profile/${partner.slug}`}>
{partner.name}
</NameLink>
</PartnerName>
{linkedinSafe && (
<LinkedinIconLink
href={partner.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={i18n._(msg`View ${partner.name} on LinkedIn`)}
>
<IconBrandLinkedin size={16} aria-hidden="true" />
</LinkedinIconLink>
)}
</NameRow>
<CountryEyebrow>{countryLine}</CountryEyebrow>
</HeaderText>
</CardHeader>
@@ -176,13 +245,18 @@ export function PartnerCard({ partner, index }: PartnerCardProps) {
valueLabels={SPOKEN_LANGUAGE_LABELS}
/>
<PartnerChipRow
label={msg`Deploys`}
values={partner.deploymentExpertise}
valueLabels={DEPLOYMENT_EXPERTISE_LABELS}
label={msg`Categories`}
values={partner.partnerScope}
valueLabels={PARTNER_SCOPE_LABELS}
/>
</ChipRows>
{isSafeHttpUrl(partner.calendarLink) && (
<PartnerMoneyRow
hourlyRateUsd={partner.hourlyRateUsd}
projectBudgetMinUsd={partner.projectBudgetMinUsd}
/>
{calendarSafe && (
<CtaWrapper>
<LinkButton
color="secondary"
@@ -56,6 +56,16 @@ type PartnerChipRowProps<TValue extends string> = {
valueLabels: Record<TValue, MessageDescriptor>;
};
// Falls back when the CRM stores a value the website doesn't yet know about
// (e.g. a new language). Turns "TAMIL" → "Tamil", "SELF_HOST" → "Self host".
const titleCaseFallback = (raw: string): string =>
raw
.toLowerCase()
.split(/[_\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
export function PartnerChipRow<TValue extends string>({
label,
values,
@@ -68,11 +78,17 @@ export function PartnerChipRow<TValue extends string>({
<RowLabel>{i18n._(label)}</RowLabel>
<ChipListWrapper>
<ChipList>
{values.map((value) => (
<li key={value} className={chipBaseStyles}>
{i18n._(valueLabels[value])}
</li>
))}
{values.map((value) => {
const descriptor = valueLabels[value];
const text = descriptor
? i18n._(descriptor)
: titleCaseFallback(value);
return (
<li key={value} className={chipBaseStyles}>
{text}
</li>
);
})}
</ChipList>
</ChipListWrapper>
</Row>
@@ -0,0 +1,64 @@
'use client';
import { IconBriefcase, IconCurrencyDollar } from '@tabler/icons-react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { formatUsdCompact } from '@/lib/format/format-usd';
import { theme } from '@/theme';
const Row = styled.div`
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(2)};
`;
const Pill = styled.span`
align-items: center;
background-color: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: 999px;
color: ${theme.colors.primary.text[80]};
display: inline-flex;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
gap: ${theme.spacing(1.5)};
line-height: 1;
padding: ${theme.spacing(1.5)} ${theme.spacing(3)};
`;
type PartnerMoneyRowProps = {
hourlyRateUsd: number | null;
projectBudgetMinUsd: number | null;
};
export function PartnerMoneyRow({
hourlyRateUsd,
projectBudgetMinUsd,
}: PartnerMoneyRowProps) {
const { i18n } = useLingui();
const hourly = formatUsdCompact(hourlyRateUsd);
const minBudget = formatUsdCompact(projectBudgetMinUsd);
if (!hourly && !minBudget) return null;
return (
<Row aria-label={i18n._(msg`Pricing`)}>
{hourly && (
<Pill>
<IconCurrencyDollar size={14} aria-hidden="true" />
{i18n._(msg`${hourly}/hr`)}
</Pill>
)}
{minBudget && (
<Pill>
<IconBriefcase size={14} aria-hidden="true" />
{i18n._(msg`from ${minBudget}`)}
</Pill>
)}
</Row>
);
}
@@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
import type { MessageDescriptor } from '@lingui/core';
import type {
DeploymentExpertise,
PartnerScope,
ServedGeo,
SpokenLanguage,
} from '@/lib/partners-api';
@@ -23,12 +23,44 @@ export const SPOKEN_LANGUAGE_LABELS: Record<SpokenLanguage, MessageDescriptor> =
GERMAN: msg`German`,
CHINESE: msg`Chinese`,
SPANISH: msg`Spanish`,
ARABIC: msg`Arabic`,
BENGALI: msg`Bengali`,
CATALAN: msg`Catalan`,
CZECH: msg`Czech`,
DANISH: msg`Danish`,
DUTCH: msg`Dutch`,
FARSI: msg`Farsi`,
FINNISH: msg`Finnish`,
GREEK: msg`Greek`,
HINDI: msg`Hindi`,
INDONESIAN: msg`Indonesian`,
ITALIAN: msg`Italian`,
JAPANESE: msg`Japanese`,
KOREAN: msg`Korean`,
MALAY: msg`Malay`,
NORWEGIAN: msg`Norwegian`,
POLISH: msg`Polish`,
PORTUGUESE: msg`Portuguese`,
PUNJABI: msg`Punjabi`,
ROMANIAN: msg`Romanian`,
RUSSIAN: msg`Russian`,
SWAHILI: msg`Swahili`,
SWEDISH: msg`Swedish`,
TAGALOG: msg`Tagalog`,
TAMIL: msg`Tamil`,
THAI: msg`Thai`,
TURKISH: msg`Turkish`,
UKRAINIAN: msg`Ukrainian`,
URDU: msg`Urdu`,
VIETNAMESE: msg`Vietnamese`,
};
export const DEPLOYMENT_EXPERTISE_LABELS: Record<
DeploymentExpertise,
MessageDescriptor
> = {
CLOUD: msg`Cloud`,
SELF_HOST: msg`Self-host`,
// Mirrors the Partner.partnerScope ("Categories") option labels in the
// twenty-partners SDK app.
export const PARTNER_SCOPE_LABELS: Record<PartnerScope, MessageDescriptor> = {
ADVISORY: msg`Advisory & Discovery`,
SOLUTIONING: msg`Solutioning`,
DEVELOPMENT: msg`Custom Development`,
HOSTING: msg`Hosting & Infrastructure`,
SUPPORT: msg`Training & Adoption`,
};
@@ -3,4 +3,7 @@ export { MarketplaceHeader } from './MarketplaceHeader';
export { PartnerCard } from './PartnerCard';
export { EmptyState } from './EmptyState';
export { FilterBar } from './FilterBar';
export { FilterChipRow } from './FilterChipRow';
export { FilterDropdown } from './FilterDropdown';
export { ActiveFilterPills } from './ActiveFilterPills';
export type { ActivePill, ActiveFilterPillsProps } from './ActiveFilterPills';
export { PartnerMoneyRow } from './PartnerMoneyRow';
@@ -1,6 +1,6 @@
import type {
DeploymentExpertise,
MarketplacePartner,
PartnerScope,
ServedGeo,
SpokenLanguage,
} from '@/lib/partners-api';
@@ -8,13 +8,13 @@ import type {
export type FilterCriteria = {
regions: ReadonlySet<ServedGeo>;
languages: ReadonlySet<SpokenLanguage>;
deployments: ReadonlySet<DeploymentExpertise>;
categories: ReadonlySet<PartnerScope>;
};
export const EMPTY_CRITERIA: FilterCriteria = {
regions: new Set(),
languages: new Set(),
deployments: new Set(),
categories: new Set(),
};
const facetMatches = <T>(
@@ -30,10 +30,10 @@ export const filterPartners = (
(p) =>
facetMatches(p.region, criteria.regions) &&
facetMatches(p.languagesSpoken, criteria.languages) &&
facetMatches(p.deploymentExpertise, criteria.deployments),
facetMatches(p.partnerScope, criteria.categories),
);
export const hasAnyFilter = (criteria: FilterCriteria): boolean =>
criteria.regions.size > 0 ||
criteria.languages.size > 0 ||
criteria.deployments.size > 0;
criteria.categories.size > 0;
@@ -1,5 +1,5 @@
import {
DEPLOYMENT_EXPERTISES,
PARTNER_SCOPES,
SERVED_GEOS,
SPOKEN_LANGUAGES,
} from '@/lib/partners-api';
@@ -24,7 +24,7 @@ export const parseCriteriaFromParams = (
): FilterCriteria => ({
regions: parseFacet(params.get('regions'), SERVED_GEOS),
languages: parseFacet(params.get('languages'), SPOKEN_LANGUAGES),
deployments: parseFacet(params.get('deployments'), DEPLOYMENT_EXPERTISES),
categories: parseFacet(params.get('categories'), PARTNER_SCOPES),
});
const encodeFacet = (set: ReadonlySet<string>): string | null =>
@@ -34,10 +34,10 @@ 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);
const c = encodeFacet(criteria.categories);
if (r) params.set('regions', r);
if (l) params.set('languages', l);
if (d) params.set('deployments', d);
if (c) params.set('categories', c);
return params.toString();
};
@@ -20,8 +20,9 @@ type PartnersMarketplacePageProps = {
export default async function PartnersMarketplacePage({
params,
}: PartnersMarketplacePageProps) {
const [, stats, livePartners] = await Promise.all([
const [, { locale }, stats, livePartners] = await Promise.all([
getRouteI18n(params),
params,
fetchCommunityStats(),
getPartners(),
]);
@@ -37,7 +38,7 @@ export default async function PartnersMarketplacePage({
<MarketplaceHeader />
<Suspense fallback={null}>
<MarketplaceClient partners={livePartners} />
<MarketplaceClient partners={livePartners} locale={locale} />
</Suspense>
</>
);
@@ -4,7 +4,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useMemo } from 'react';
import type {
DeploymentExpertise,
PartnerScope,
ServedGeo,
SpokenLanguage,
} from '@/lib/partners-api';
@@ -23,7 +23,7 @@ type UseFilterStateReturn = {
criteria: FilterCriteria;
toggleRegion: (geo: ServedGeo) => void;
toggleLanguage: (lang: SpokenLanguage) => void;
toggleDeployment: (dep: DeploymentExpertise) => void;
toggleCategory: (scope: PartnerScope) => void;
clearAll: () => void;
hasAnyFilter: boolean;
};
@@ -67,11 +67,11 @@ export const useFilterState = (): UseFilterStateReturn => {
[criteria, writeCriteria],
);
const toggleDeployment = useCallback(
(dep: DeploymentExpertise) => {
const toggleCategory = useCallback(
(scope: PartnerScope) => {
writeCriteria({
...criteria,
deployments: toggleInSet(criteria.deployments, dep),
categories: toggleInSet(criteria.categories, scope),
});
},
[criteria, writeCriteria],
@@ -81,7 +81,7 @@ export const useFilterState = (): UseFilterStateReturn => {
writeCriteria({
regions: new Set(),
languages: new Set(),
deployments: new Set(),
categories: new Set(),
});
}, [writeCriteria]);
@@ -89,7 +89,7 @@ export const useFilterState = (): UseFilterStateReturn => {
criteria,
toggleRegion,
toggleLanguage,
toggleDeployment,
toggleCategory,
clearAll,
hasAnyFilter: computeHasAnyFilter(criteria),
};
@@ -0,0 +1,32 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { theme } from '@/theme';
const BackLink = styled.a`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
text-decoration: none;
transition: color 150ms ease;
&:hover {
color: ${theme.colors.primary.text[100]};
}
`;
type BackToMarketplaceLinkProps = {
locale: string;
};
export function BackToMarketplaceLink({ locale }: BackToMarketplaceLinkProps) {
const { i18n } = useLingui();
return (
<BackLink href={`/${locale}/partners/list`}>
{i18n._(msg`← Twenty partners`)}
</BackLink>
);
}
@@ -0,0 +1,125 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import type { MessageDescriptor } from '@lingui/core';
import {
PARTNER_SCOPE_LABELS,
SPOKEN_LANGUAGE_LABELS,
} from '@/app/[locale]/partners/list/components/chip-labels';
import type {
MarketplacePartner,
PartnerScope,
SpokenLanguage,
} from '@/lib/partners-api';
import { theme } from '@/theme';
const FactsDl = styled.dl`
display: flex;
flex-direction: column;
gap: ${theme.spacing(3)};
margin: 0;
`;
const FactRow = styled.div`
align-items: baseline;
display: flex;
gap: ${theme.spacing(3)};
`;
const FactLabel = styled.dt`
color: ${theme.colors.primary.text[60]};
flex-shrink: 0;
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
letter-spacing: 0.08em;
margin: 0;
text-transform: uppercase;
width: 100px;
`;
const FactValue = styled.dd`
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4)};
margin: 0;
`;
// Falls back when the CMS stores a value the website doesn't yet know about.
const titleCaseFallback = (raw: string): string =>
raw
.toLowerCase()
.split(/[_\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
function resolveLabels<TValue extends string>(
values: readonly TValue[],
labelMap: Record<TValue, MessageDescriptor>,
translate: (descriptor: MessageDescriptor) => string,
): string {
return values
.map((v) => {
const descriptor = labelMap[v];
return descriptor ? translate(descriptor) : titleCaseFallback(v);
})
.join(', ');
}
type PartnerFactsListProps = {
city: MarketplacePartner['city'];
country: MarketplacePartner['country'];
languagesSpoken: MarketplacePartner['languagesSpoken'];
partnerScope: MarketplacePartner['partnerScope'];
};
export function PartnerFactsList({
city,
country,
languagesSpoken,
partnerScope,
}: PartnerFactsListProps) {
const { i18n } = useLingui();
const translate = (d: MessageDescriptor) => i18n._(d);
// Country is a CRM SELECT enum (e.g. "UNITED_STATES"); title-case it.
const locationText = [city, country ? titleCaseFallback(country) : '']
.filter(Boolean)
.join(', ');
const languageText = resolveLabels(
languagesSpoken as readonly SpokenLanguage[],
SPOKEN_LANGUAGE_LABELS,
translate,
);
const categoryText = resolveLabels(
partnerScope as readonly PartnerScope[],
PARTNER_SCOPE_LABELS,
translate,
);
return (
<FactsDl>
{locationText && (
<FactRow>
<FactLabel>{i18n._(msg`Based in`)}</FactLabel>
<FactValue>{locationText}</FactValue>
</FactRow>
)}
{languageText && (
<FactRow>
<FactLabel>{i18n._(msg`Languages`)}</FactLabel>
<FactValue>{languageText}</FactValue>
</FactRow>
)}
{categoryText && (
<FactRow>
<FactLabel>{i18n._(msg`Categories`)}</FactLabel>
<FactValue>{categoryText}</FactValue>
</FactRow>
)}
</FactsDl>
);
}
@@ -0,0 +1,79 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { LinkButton } from '@/design-system/components';
import { theme } from '@/theme';
const CtasWrapper = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(3)};
width: 100%;
`;
const CtasEyebrow = styled.p`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
letter-spacing: 0.08em;
margin: 0;
text-transform: uppercase;
`;
const ButtonRow = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
width: 100%;
`;
const isSafeHttpUrl = (raw: string) => {
try {
return ['https:', 'http:'].includes(new URL(raw).protocol);
} catch {
return false;
}
};
type PartnerProfileCtasProps = {
calendarLink: string;
linkedinUrl: string;
};
export function PartnerProfileCtas({
calendarLink,
linkedinUrl,
}: PartnerProfileCtasProps) {
const { i18n } = useLingui();
const showCalendar = isSafeHttpUrl(calendarLink);
const showLinkedin = isSafeHttpUrl(linkedinUrl);
if (!showCalendar && !showLinkedin) return null;
return (
<CtasWrapper>
<CtasEyebrow>{i18n._(msg`Reach out`)}</CtasEyebrow>
<ButtonRow>
{showCalendar && (
<LinkButton
color="secondary"
href={calendarLink}
label={i18n._(msg`Book a call`)}
variant="contained"
/>
)}
{showLinkedin && (
<LinkButton
color="secondary"
href={linkedinUrl}
label={i18n._(msg`View on LinkedIn`)}
variant="outlined"
/>
)}
</ButtonRow>
</CtasWrapper>
);
}
@@ -0,0 +1,71 @@
'use client';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import type { MarketplacePartner } from '@/lib/partners-api';
import { SERVED_GEO_LABELS } from '@/app/[locale]/partners/list/components/chip-labels';
import { theme } from '@/theme';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
`;
const Eyebrow = styled.p`
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;
margin: 0;
text-transform: uppercase;
`;
const Name = styled.h1`
animation: nameEnter 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.serif};
font-size: ${theme.font.size(12)};
font-weight: ${theme.font.weight.light};
letter-spacing: -0.02em;
line-height: ${theme.lineHeight(11)};
margin: 0;
@keyframes nameEnter {
from {
opacity: 0;
transform: translate3d(0, 12px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
type PartnerProfileHeaderProps = {
partner: MarketplacePartner;
};
export function PartnerProfileHeader({ partner }: PartnerProfileHeaderProps) {
const { i18n } = useLingui();
// Served regions are the partner's market coverage, not their address.
const eyebrow = partner.region
.map((geo) => i18n._(SERVED_GEO_LABELS[geo]))
.filter(Boolean)
.join(' · ');
return (
<Wrapper>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<Name id="partner-name">{partner.name}</Name>
</Wrapper>
);
}
@@ -0,0 +1,26 @@
import { styled } from '@linaria/react';
import { theme } from '@/theme';
const Prose = styled.p`
color: ${theme.colors.primary.text[80]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(5)};
line-height: ${theme.lineHeight(7)};
margin: 0;
max-width: 62ch;
text-wrap: pretty;
white-space: pre-wrap;
`;
type PartnerProfileIntroProps = {
introduction: string;
};
export function PartnerProfileIntro({
introduction,
}: PartnerProfileIntroProps) {
if (!introduction) return null;
return <Prose>{introduction}</Prose>;
}
@@ -0,0 +1,67 @@
import { styled } from '@linaria/react';
import { PartnerAvatar } from '@/app/[locale]/partners/list/components/PartnerAvatar';
import { theme } from '@/theme';
// Squared tile, not a circle: it reads as a studio/brand image and matches the
// rectangular panel + crosshair geometry instead of fighting it.
const PhotoWrapper = styled.div`
aspect-ratio: 1 / 1;
border-radius: ${theme.radius(2)};
overflow: hidden;
width: 100%;
`;
const RealPhoto = styled.img`
display: block;
height: 100%;
object-fit: cover;
width: 100%;
`;
const AvatarSizer = styled.div`
align-items: center;
aspect-ratio: 1 / 1;
background-color: rgba(74, 56, 245, 0.06);
border-radius: ${theme.radius(2)};
display: flex;
justify-content: center;
overflow: hidden;
width: 100%;
`;
const isSafeHttpUrl = (raw: string) => {
try {
return ['https:', 'http:'].includes(new URL(raw).protocol);
} catch {
return false;
}
};
type PartnerProfilePhotoProps = {
name: string;
slug: string;
profilePictureUrl: string;
};
export function PartnerProfilePhoto({
name,
slug,
profilePictureUrl,
}: PartnerProfilePhotoProps) {
const showRealPhoto = isSafeHttpUrl(profilePictureUrl);
if (showRealPhoto) {
return (
<PhotoWrapper>
<RealPhoto src={profilePictureUrl} alt={name} />
</PhotoWrapper>
);
}
return (
<AvatarSizer>
<PartnerAvatar name={name} slug={slug} aria-hidden="true" />
</AvatarSizer>
);
}
@@ -0,0 +1,89 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { formatUsdRate } from '@/lib/format/format-usd';
import { theme } from '@/theme';
// Quiet fact list, not a card. A single hairline separates it from the CTAs
// above; values are modest sans so rates read as a footnote, not the headline.
const Panel = styled.section`
border-top: 1px solid ${theme.colors.primary.border[10]};
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
padding-top: ${theme.spacing(5)};
`;
const PanelEyebrow = styled.p`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
letter-spacing: 0.08em;
margin: 0;
text-transform: uppercase;
`;
const Row = styled.div`
align-items: baseline;
display: flex;
gap: ${theme.spacing(3)};
justify-content: space-between;
`;
const Label = styled.span`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3)};
`;
const Value = styled.span`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4)};
font-weight: ${theme.font.weight.medium};
`;
type PartnerRatesPanelProps = {
hourlyRateUsd: number | null;
projectBudgetMinUsd: number | null;
projectBudgetTypicalUsd: number | null;
};
export function PartnerRatesPanel({
hourlyRateUsd,
projectBudgetMinUsd,
projectBudgetTypicalUsd,
}: PartnerRatesPanelProps) {
const { i18n } = useLingui();
const rows: Array<{ label: string; value: string }> = [];
const hourly = formatUsdRate(hourlyRateUsd);
if (hourly) {
rows.push({ label: i18n._(msg`Hourly`), value: `${hourly}/hr` });
}
const min = formatUsdRate(projectBudgetMinUsd);
if (min) {
rows.push({ label: i18n._(msg`Project minimum`), value: min });
}
const typical = formatUsdRate(projectBudgetTypicalUsd);
if (typical) {
rows.push({ label: i18n._(msg`Typical project`), value: typical });
}
if (rows.length === 0) return null;
return (
<Panel aria-label={i18n._(msg`Rates`)}>
<PanelEyebrow>{i18n._(msg`Rates`)}</PanelEyebrow>
{rows.map((row) => (
<Row key={row.label}>
<Label>{row.label}</Label>
<Value>{row.value}</Value>
</Row>
))}
</Panel>
);
}
@@ -0,0 +1,7 @@
export { BackToMarketplaceLink } from './BackToMarketplaceLink';
export { PartnerFactsList } from './PartnerFactsList';
export { PartnerProfileCtas } from './PartnerProfileCtas';
export { PartnerProfileHeader } from './PartnerProfileHeader';
export { PartnerProfileIntro } from './PartnerProfileIntro';
export { PartnerProfilePhoto } from './PartnerProfilePhoto';
export { PartnerRatesPanel } from './PartnerRatesPanel';
@@ -0,0 +1,289 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { styled } from '@linaria/react';
import { getPartnerBySlug } from '@/lib/partners-api';
import { GuideCrosshair } from '@/design-system/components';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { theme } from '@/theme';
import {
BackToMarketplaceLink,
PartnerFactsList,
PartnerProfileCtas,
PartnerProfileHeader,
PartnerProfileIntro,
PartnerProfilePhoto,
PartnerRatesPanel,
} from './components';
// ─── Styled layout ────────────────────────────────────────────────────────────
// Soft ambient wash built from Twenty's brand accents (blue / pink / green) at
// low alpha over white. Lifts the page off flat white without touching text
// contrast. Pure decoration, fixed behind the content.
const PageRoot = styled.div`
background-color: ${theme.colors.primary.background[100]};
background-image:
radial-gradient(
62% 48% at 88% -4%,
rgba(74, 56, 245, 0.09),
transparent 70%
),
radial-gradient(
48% 38% at 6% 12%,
rgba(237, 135, 252, 0.07),
transparent 72%
),
radial-gradient(
60% 50% at 50% 108%,
rgba(137, 252, 154, 0.08),
transparent 70%
);
background-repeat: no-repeat;
min-height: 100dvh;
`;
const PageInner = styled.div`
margin: 0 auto;
max-width: 1100px;
padding: ${theme.spacing(10)} ${theme.spacing(4)} ${theme.spacing(28)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
}
`;
const ContentGrid = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.spacing(10)};
@media (min-width: ${theme.breakpoints.md}px) {
display: grid;
gap: ${theme.spacing(0)};
grid-template-columns: 7fr 1fr 4fr;
}
`;
// Left main column
const MainColumn = styled.main`
display: flex;
flex-direction: column;
gap: ${theme.spacing(10)};
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(14)};
grid-column: 1;
}
`;
// Empty gutter column (only in grid)
const GutterColumn = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
grid-column: 2;
}
`;
// Right rail column. A soft neutral panel groups photo + CTAs + rates into one
// "decision rail" that lifts gently off the color wash without a hard card.
const RailColumn = styled.aside`
align-self: flex-start;
animation: nameEnter 700ms cubic-bezier(0.22, 1, 0.36, 1) 100ms both;
background-color: ${theme.colors.primary.text[5]};
border-radius: ${theme.radius(2)};
display: flex;
flex-direction: column;
gap: ${theme.spacing(6)};
padding: ${theme.spacing(6)};
position: relative;
@keyframes nameEnter {
from {
opacity: 0;
transform: translate3d(0, 12px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
}
@media (min-width: ${theme.breakpoints.md}px) {
grid-column: 3;
}
`;
// Skills section within main column
const SkillsSection = styled.section`
display: flex;
flex-direction: column;
gap: ${theme.spacing(4)};
`;
const SectionEyebrow = styled.p`
color: ${theme.colors.primary.text[60]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
letter-spacing: 0.08em;
margin: 0;
text-transform: uppercase;
`;
const SkillsRow = styled.ul`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(2)};
list-style: none;
margin: 0;
padding: 0;
`;
const SkillChip = styled.li`
background-color: rgba(74, 56, 245, 0.07);
border-radius: ${theme.radius(2)};
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(4)};
font-weight: ${theme.font.weight.medium};
padding: ${theme.spacing(2)} ${theme.spacing(4)};
`;
const Divider = styled.hr`
background-color: ${theme.colors.primary.border[10]};
border: none;
height: 1px;
margin: 0;
`;
const WhereSection = styled.section`
display: flex;
flex-direction: column;
gap: ${theme.spacing(4)};
`;
// ─── Metadata ─────────────────────────────────────────────────────────────────
type PartnerProfilePageProps = {
params: Promise<{ locale: string; slug: string }>;
};
const truncateDescription = (text: string, max = 160) => {
const cleaned = text.replace(/\s+/g, ' ').trim();
if (cleaned.length <= max) return cleaned;
return `${cleaned.slice(0, max - 1)}`;
};
export async function generateMetadata({
params,
}: PartnerProfilePageProps): Promise<Metadata> {
const { slug } = await params;
const partner = await getPartnerBySlug(slug);
if (!partner) {
return { title: 'Partner not found · Twenty Partners' };
}
return {
title: `${partner.name} · Twenty Partners`,
description: truncateDescription(partner.introduction),
};
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default async function PartnerProfilePage({
params,
}: PartnerProfilePageProps) {
const { locale, slug } = await params;
const [partner, stats] = await Promise.all([
getPartnerBySlug(slug),
fetchCommunityStats(),
]);
if (!partner) {
notFound();
}
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<>
<Menu
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
socialLinks={menuSocialLinks}
/>
<PageRoot>
<PageInner>
<ContentGrid>
{/* ── Left main column ── */}
<MainColumn aria-labelledby="partner-name">
<BackToMarketplaceLink locale={locale} />
<PartnerProfileHeader partner={partner} />
<PartnerProfileIntro introduction={partner.introduction} />
{partner.skills.length > 0 && (
<SkillsSection>
<SectionEyebrow>What they do</SectionEyebrow>
<SkillsRow aria-label="Skills">
{partner.skills.map((skill) => (
<SkillChip key={skill}>{skill}</SkillChip>
))}
</SkillsRow>
</SkillsSection>
)}
<Divider aria-hidden="true" />
<WhereSection>
<SectionEyebrow>Where &amp; how</SectionEyebrow>
<PartnerFactsList
city={partner.city}
country={partner.country}
languagesSpoken={partner.languagesSpoken}
partnerScope={partner.partnerScope}
/>
</WhereSection>
</MainColumn>
{/* ── Gutter (desktop only) ── */}
<GutterColumn />
{/* ── Right rail ── */}
<RailColumn aria-label="Partner facts and contact">
<GuideCrosshair
crossX={`calc(100% - ${theme.spacing(6)})`}
crossY={theme.spacing(6)}
/>
<PartnerProfilePhoto
name={partner.name}
slug={partner.slug}
profilePictureUrl={partner.profilePictureUrl}
/>
<PartnerProfileCtas
calendarLink={partner.calendarLink}
linkedinUrl={partner.linkedinUrl}
/>
<PartnerRatesPanel
hourlyRateUsd={partner.hourlyRateUsd}
projectBudgetMinUsd={partner.projectBudgetMinUsd}
projectBudgetTypicalUsd={partner.projectBudgetTypicalUsd}
/>
</RailColumn>
</ContentGrid>
</PageInner>
</PageRoot>
</>
);
}
@@ -0,0 +1,34 @@
import { formatUsdRate, formatUsdCompact } from '@/lib/format/format-usd';
describe('formatUsdRate', () => {
it.each([
[null, null],
[0, '$0'],
[150, '$150'],
[5000, '$5,000'],
[25000, '$25,000'],
])('formats %p as %p', (input, expected) => {
expect(formatUsdRate(input)).toBe(expected);
});
it('returns null for NaN', () => {
expect(formatUsdRate(Number.NaN)).toBeNull();
});
});
describe('formatUsdCompact', () => {
it.each([
[null, null],
[0, '$0'],
[150, '$150'],
[5000, '$5K'],
[25000, '$25K'],
[1500000, '$1.5M'],
])('formats %p as %p', (input, expected) => {
expect(formatUsdCompact(input)).toBe(expected);
});
it('returns null for NaN', () => {
expect(formatUsdCompact(Number.NaN)).toBeNull();
});
});
@@ -0,0 +1,23 @@
const FULL_FORMATTER = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
});
const COMPACT_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
export const formatUsdRate = (usd: number | null): string | null => {
if (usd === null || Number.isNaN(usd)) return null;
return FULL_FORMATTER.format(usd);
};
export const formatUsdCompact = (usd: number | null): string | null => {
if (usd === null || Number.isNaN(usd)) return null;
return COMPACT_FORMATTER.format(usd);
};
@@ -0,0 +1,75 @@
// Mock next/cache so unstable_cache is a transparent pass-through in tests.
// Without this, Next.js throws "Invariant: incrementalCache missing" outside
// of a Next.js request context.
jest.mock('next/cache', () => ({
unstable_cache: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
const importGetter = async () => {
jest.resetModules();
return (await import('@/lib/partners-api/get-partner-by-slug'))
.getPartnerBySlug;
};
const apiPartner = (overrides: Record<string, unknown> = {}) => ({
id: 'p1',
name: 'Acme',
slug: 'acme',
introduction: 'We do CRM things.',
languagesSpoken: ['ENGLISH'],
partnerScope: ['HOSTING'],
region: ['EUROPE'],
calendarLink: { primaryLinkUrl: 'https://calendly.com/acme' },
hourlyRate: { amountMicros: 150_000_000, currencyCode: 'USD' },
projectBudgetMin: { amountMicros: 5_000_000_000, currencyCode: 'USD' },
projectBudgetTypical: { amountMicros: 25_000_000_000, currencyCode: 'USD' },
linkedin: { primaryLinkUrl: 'https://linkedin.com/in/acme' },
profilePicture: { primaryLinkUrl: 'https://cdn.example.com/acme.jpg' },
skills: ['INTEGRATIONS'],
city: 'Berlin',
country: 'DE',
...overrides,
});
const mockApi = (body: unknown, status = 200) => {
process.env.TWENTY_PARTNERS_API_URL = 'https://example.com';
process.env.TWENTY_PARTNERS_API_KEY = 'k';
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(body), { status }),
);
};
describe('getPartnerBySlug', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('returns a normalized MarketplacePartner on happy path', async () => {
mockApi({ ok: true, partner: apiPartner() });
const getPartnerBySlug = await importGetter();
const partner = await getPartnerBySlug('acme');
expect(partner).not.toBeNull();
expect(partner?.slug).toBe('acme');
expect(partner?.hourlyRateUsd).toBe(150);
expect(partner?.linkedinUrl).toBe('https://linkedin.com/in/acme');
expect(partner?.partnerScope).toEqual(['HOSTING']);
});
it('returns null on NOT_FOUND', async () => {
mockApi({ ok: false, reason: 'NOT_FOUND' });
const getPartnerBySlug = await importGetter();
const partner = await getPartnerBySlug('bogus');
expect(partner).toBeNull();
});
it('returns null when fetch throws', async () => {
process.env.TWENTY_PARTNERS_API_URL = 'https://example.com';
process.env.TWENTY_PARTNERS_API_KEY = 'k';
jest.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network'));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const getPartnerBySlug = await importGetter();
const partner = await getPartnerBySlug('acme');
expect(partner).toBeNull();
});
});
export {};
@@ -0,0 +1,106 @@
// Mock next/cache so unstable_cache is a transparent pass-through in tests.
// Without this, Next.js throws "Invariant: incrementalCache missing" outside
// of a Next.js request context.
jest.mock('next/cache', () => ({
unstable_cache: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
const importGetPartners = async () => {
jest.resetModules();
return (await import('@/lib/partners-api/get-partners')).getPartners;
};
const apiPartner = (overrides: Record<string, unknown> = {}) => ({
id: 'p1',
name: 'Acme',
slug: 'acme',
introduction: 'We do CRM things.',
languagesSpoken: ['ENGLISH'],
partnerScope: ['HOSTING'],
region: ['EUROPE'],
calendarLink: { primaryLinkUrl: 'calendly.com/acme' },
hourlyRate: { amountMicros: 150_000_000, currencyCode: 'USD' },
projectBudgetMin: { amountMicros: 5_000_000_000, currencyCode: 'USD' },
projectBudgetTypical: { amountMicros: 25_000_000_000, currencyCode: 'USD' },
linkedin: { primaryLinkUrl: 'linkedin.com/in/acme' },
profilePicture: { primaryLinkUrl: 'https://cdn.example.com/acme.jpg' },
skills: ['INTEGRATIONS', 'MIGRATION'],
city: 'Berlin',
country: 'DE',
...overrides,
});
const mockApi = (apiPartners: unknown[]) => {
process.env.TWENTY_PARTNERS_API_URL = 'https://example.com';
process.env.TWENTY_PARTNERS_API_KEY = 'k';
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ ok: true, count: apiPartners.length, partners: apiPartners }),
{ status: 200 },
),
);
};
describe('getPartners boundary normalization', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('normalizes CURRENCY micros to whole USD numbers', async () => {
mockApi([apiPartner()]);
const getPartners = await importGetPartners();
const partners = await getPartners();
expect(partners[0].hourlyRateUsd).toBe(150);
expect(partners[0].projectBudgetMinUsd).toBe(5000);
expect(partners[0].projectBudgetTypicalUsd).toBe(25000);
});
it('returns null when a CURRENCY field is missing', async () => {
mockApi([apiPartner({ hourlyRate: null, projectBudgetMin: null })]);
const getPartners = await importGetPartners();
const partners = await getPartners();
expect(partners[0].hourlyRateUsd).toBeNull();
expect(partners[0].projectBudgetMinUsd).toBeNull();
});
it('extracts primaryLinkUrl for LINKS fields and prepends scheme when missing', async () => {
mockApi([apiPartner()]);
const getPartners = await importGetPartners();
const partners = await getPartners();
expect(partners[0].linkedinUrl).toBe('https://linkedin.com/in/acme');
expect(partners[0].profilePictureUrl).toBe('https://cdn.example.com/acme.jpg');
expect(partners[0].calendarLink).toBe('https://calendly.com/acme');
});
it('returns empty string when a LINKS field is missing', async () => {
mockApi([apiPartner({ linkedin: null, profilePicture: null })]);
const getPartners = await importGetPartners();
const partners = await getPartners();
expect(partners[0].linkedinUrl).toBe('');
expect(partners[0].profilePictureUrl).toBe('');
});
it('passes through skills and location fields with empty fallbacks', async () => {
mockApi([apiPartner({ skills: null, city: null, country: null })]);
const getPartners = await importGetPartners();
const partners = await getPartners();
expect(partners[0].skills).toEqual([]);
expect(partners[0].city).toBe('');
expect(partners[0].country).toBe('');
});
it('passes through partnerScope categories', async () => {
mockApi([apiPartner({ partnerScope: ['HOSTING', 'SUPPORT'] })]);
const getPartners = await importGetPartners();
const partners = await getPartners();
expect(partners[0].partnerScope).toEqual(['HOSTING', 'SUPPORT']);
});
it('falls back to an empty array when partnerScope is null', async () => {
mockApi([apiPartner({ partnerScope: null })]);
const getPartners = await importGetPartners();
const partners = await getPartners();
expect(partners[0].partnerScope).toEqual([]);
});
});
export {};
@@ -0,0 +1,85 @@
import { unstable_cache } from 'next/cache';
import { partnersApiFetch } from './client';
import type { MarketplacePartner } from './partner-types';
type CurrencyValue = { amountMicros: number; currencyCode: string } | null;
type LinkValue = { primaryLinkUrl: string | null } | null;
type ApiPartner = {
id: string;
name: string;
slug: string;
introduction: string;
languagesSpoken: MarketplacePartner['languagesSpoken'][number][];
partnerScope: MarketplacePartner['partnerScope'][number][] | null;
region: MarketplacePartner['region'][number][];
calendarLink: LinkValue;
hourlyRate: CurrencyValue;
projectBudgetMin: CurrencyValue;
projectBudgetTypical: CurrencyValue;
linkedin: LinkValue;
profilePicture: LinkValue;
skills: string[] | null;
city: string | null;
country: string | null;
};
type ApiResponse =
| { ok: true; partner: ApiPartner }
| { ok: false; reason: string };
const normalizeUrl = (raw: string | null | undefined): string => {
if (!raw) return '';
return raw.includes('://') ? raw : `https://${raw}`;
};
const linkUrl = (link: LinkValue): string =>
normalizeUrl(link?.primaryLinkUrl ?? '');
const microsToUsd = (currency: CurrencyValue): number | null => {
if (!currency || typeof currency.amountMicros !== 'number') return null;
return Math.round(currency.amountMicros / 1_000_000);
};
const toMarketplacePartner = (p: ApiPartner): MarketplacePartner => ({
slug: p.slug,
name: p.name,
introduction: p.introduction,
languagesSpoken: p.languagesSpoken,
partnerScope: p.partnerScope ?? [],
region: p.region,
calendarLink: linkUrl(p.calendarLink),
hourlyRateUsd: microsToUsd(p.hourlyRate),
projectBudgetMinUsd: microsToUsd(p.projectBudgetMin),
projectBudgetTypicalUsd: microsToUsd(p.projectBudgetTypical),
linkedinUrl: linkUrl(p.linkedin),
profilePictureUrl: linkUrl(p.profilePicture),
skills: p.skills ?? [],
city: p.city ?? '',
country: p.country ?? '',
});
const fetchPartnerBySlugUncached = async (
slug: string,
): Promise<MarketplacePartner | null> => {
try {
const data = (await partnersApiFetch(
`/s/partner-by-slug?slug=${encodeURIComponent(slug)}`,
)) as ApiResponse;
if (!data.ok) return null;
return toMarketplacePartner(data.partner);
} catch (error) {
console.error('[partners-api] getPartnerBySlug failed:', error);
return null;
}
};
export const getPartnerBySlug = (
slug: string,
): Promise<MarketplacePartner | null> =>
unstable_cache(
() => fetchPartnerBySlugUncached(slug),
['partners-api:by-slug', slug],
{ revalidate: 300, tags: ['partners-api'] },
)();
@@ -3,24 +3,42 @@ import { unstable_cache } from 'next/cache';
import { partnersApiFetch } from './client';
import type { MarketplacePartner } from './partner-types';
type CurrencyValue = { amountMicros: number; currencyCode: string } | null;
type LinkValue = { primaryLinkUrl: string | null } | null;
type ApiPartner = {
id: string;
name: string;
slug: string;
introduction: string;
languagesSpoken: MarketplacePartner['languagesSpoken'][number][];
deploymentExpertise: MarketplacePartner['deploymentExpertise'][number][];
partnerScope: MarketplacePartner['partnerScope'][number][] | null;
region: MarketplacePartner['region'][number][];
calendarLink: { primaryLinkUrl: string | null } | null;
calendarLink: LinkValue;
hourlyRate: CurrencyValue;
projectBudgetMin: CurrencyValue;
projectBudgetTypical: CurrencyValue;
linkedin: LinkValue;
profilePicture: LinkValue;
skills: string[] | null;
city: string | null;
country: string | 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;
const normalizeUrl = (raw: string | null | undefined): string => {
if (!raw) return '';
return raw.includes('://') ? raw : `https://${raw}`;
};
const linkUrl = (link: LinkValue): string =>
normalizeUrl(link?.primaryLinkUrl ?? '');
const microsToUsd = (currency: CurrencyValue): number | null => {
if (!currency || typeof currency.amountMicros !== 'number') return null;
return Math.round(currency.amountMicros / 1_000_000);
};
const fetchPartnersUncached = async (): Promise<
readonly MarketplacePartner[]
@@ -37,9 +55,17 @@ const fetchPartnersUncached = async (): Promise<
name: p.name,
introduction: p.introduction,
languagesSpoken: p.languagesSpoken,
deploymentExpertise: p.deploymentExpertise,
partnerScope: p.partnerScope ?? [],
region: p.region,
calendarLink: normalizeUrl(p.calendarLink?.primaryLinkUrl ?? ''),
calendarLink: linkUrl(p.calendarLink),
hourlyRateUsd: microsToUsd(p.hourlyRate),
projectBudgetMinUsd: microsToUsd(p.projectBudgetMin),
projectBudgetTypicalUsd: microsToUsd(p.projectBudgetTypical),
linkedinUrl: linkUrl(p.linkedin),
profilePictureUrl: linkUrl(p.profilePicture),
skills: p.skills ?? [],
city: p.city ?? '',
country: p.country ?? '',
}));
} catch (error) {
console.error('[partners-api] getPartners failed:', error);
@@ -1,11 +1,13 @@
export {
DEPLOYMENT_EXPERTISES,
PARTNER_SCOPES,
SERVED_GEOS,
SPOKEN_LANGUAGES,
} from '@/lib/partners-api/partner-facets';
export type {
DeploymentExpertise,
PartnerScope,
ServedGeo,
SpokenLanguage,
} from '@/lib/partners-api/partner-facets';
export type { MarketplacePartner } from '@/lib/partners-api/partner-types';
export { getPartners } from '@/lib/partners-api/get-partners';
export { getPartnerBySlug } from '@/lib/partners-api/get-partner-by-slug';
@@ -12,14 +12,60 @@ export const SERVED_GEOS = [
] as const;
export type ServedGeo = (typeof SERVED_GEOS)[number];
// Mirrors the MULTI_SELECT options on the Partner.languagesSpoken field in the
// twenty-partners SDK app. Keep these in sync — the CRM is the source of truth
// and any value it can store should appear here. Unknown values still render
// via PartnerChipRow's title-case fallback so a forgotten sync only loses the
// translated label, not the chip itself.
export const SPOKEN_LANGUAGES = [
'ENGLISH',
'FRENCH',
'GERMAN',
'CHINESE',
'SPANISH',
'ARABIC',
'BENGALI',
'CATALAN',
'CZECH',
'DANISH',
'DUTCH',
'FARSI',
'FINNISH',
'GREEK',
'HINDI',
'INDONESIAN',
'ITALIAN',
'JAPANESE',
'KOREAN',
'MALAY',
'NORWEGIAN',
'POLISH',
'PORTUGUESE',
'PUNJABI',
'ROMANIAN',
'RUSSIAN',
'SWAHILI',
'SWEDISH',
'TAGALOG',
'TAMIL',
'THAI',
'TURKISH',
'UKRAINIAN',
'URDU',
'VIETNAMESE',
] 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];
// Mirrors the MULTI_SELECT options on the Partner.partnerScope field (labelled
// "Categories" in the CRM) in the twenty-partners SDK app. These are the macro
// categories a partner operates in. Keep in sync with the app's partner.object
// options — the CRM is the source of truth. Unknown values still render via
// PartnerChipRow's title-case fallback.
export const PARTNER_SCOPES = [
'ADVISORY',
'SOLUTIONING',
'DEVELOPMENT',
'HOSTING',
'SUPPORT',
] as const;
export type PartnerScope = (typeof PARTNER_SCOPES)[number];
@@ -1,5 +1,5 @@
import type {
DeploymentExpertise,
PartnerScope,
ServedGeo,
SpokenLanguage,
} from './partner-facets';
@@ -9,7 +9,15 @@ export type MarketplacePartner = {
name: string;
introduction: string;
calendarLink: string;
deploymentExpertise: readonly DeploymentExpertise[];
partnerScope: readonly PartnerScope[];
region: readonly ServedGeo[];
languagesSpoken: readonly SpokenLanguage[];
hourlyRateUsd: number | null;
projectBudgetMinUsd: number | null;
projectBudgetTypicalUsd: number | null;
linkedinUrl: string;
profilePictureUrl: string;
city: string;
country: string;
skills: readonly string[];
};
+370 -1
View File
@@ -17306,6 +17306,13 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/primitive@npm:1.1.3":
version: 1.1.3
resolution: "@radix-ui/primitive@npm:1.1.3"
checksum: 10c0/88860165ee7066fa2c179f32ffcd3ee6d527d9dcdc0e8be85e9cb0e2c84834be8e3c1a976c74ba44b193f709544e12f54455d892b28e32f0708d89deda6b9f1d
languageName: node
linkType: hard
"@radix-ui/react-arrow@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-arrow@npm:1.1.0"
@@ -17325,6 +17332,25 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-arrow@npm:1.1.7":
version: 1.1.7
resolution: "@radix-ui/react-arrow@npm:1.1.7"
dependencies:
"@radix-ui/react-primitive": "npm:2.1.3"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/c3b46766238b3ee2a394d8806a5141432361bf1425110c9f0dcf480bda4ebd304453a53f294b5399c6ee3ccfcae6fd544921fd01ddc379cf5942acdd7168664b
languageName: node
linkType: hard
"@radix-ui/react-collection@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-collection@npm:1.1.0"
@@ -17360,6 +17386,19 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-compose-refs@npm:1.1.2":
version: 1.1.2
resolution: "@radix-ui/react-compose-refs@npm:1.1.2"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/d36a9c589eb75d634b9b139c80f916aadaf8a68a7c1c4b8c6c6b88755af1a92f2e343457042089f04cc3f23073619d08bb65419ced1402e9d4e299576d970771
languageName: node
linkType: hard
"@radix-ui/react-context@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-context@npm:1.1.0"
@@ -17373,6 +17412,19 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-context@npm:1.1.2":
version: 1.1.2
resolution: "@radix-ui/react-context@npm:1.1.2"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/cece731f8cc25d494c6589cc681e5c01a93867d895c75889973afa1a255f163c286e390baa7bc028858eaabe9f6b57270d0ca6377356f652c5557c1c7a41ccce
languageName: node
linkType: hard
"@radix-ui/react-dialog@npm:^1.0.4":
version: 1.1.1
resolution: "@radix-ui/react-dialog@npm:1.1.1"
@@ -17441,6 +17493,29 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-dismissable-layer@npm:1.1.11":
version: 1.1.11
resolution: "@radix-ui/react-dismissable-layer@npm:1.1.11"
dependencies:
"@radix-ui/primitive": "npm:1.1.3"
"@radix-ui/react-compose-refs": "npm:1.1.2"
"@radix-ui/react-primitive": "npm:2.1.3"
"@radix-ui/react-use-callback-ref": "npm:1.1.1"
"@radix-ui/react-use-escape-keydown": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/c825572a64073c4d3853702029979f6658770ffd6a98eabc4984e1dee1b226b4078a2a4dc7003f96475b438985e9b21a58e75f51db74dd06848dcae1f2d395dc
languageName: node
linkType: hard
"@radix-ui/react-dropdown-menu@npm:^2.0.5":
version: 2.1.1
resolution: "@radix-ui/react-dropdown-menu@npm:2.1.1"
@@ -17479,6 +17554,19 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-focus-guards@npm:1.1.3":
version: 1.1.3
resolution: "@radix-ui/react-focus-guards@npm:1.1.3"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/0bab65eb8d7e4f72f685d63de7fbba2450e3cb15ad6a20a16b42195e9d335c576356f5a47cb58d1ffc115393e46d7b14b12c5d4b10029b0ec090861255866985
languageName: node
linkType: hard
"@radix-ui/react-focus-scope@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-focus-scope@npm:1.1.0"
@@ -17500,6 +17588,27 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-focus-scope@npm:1.1.7":
version: 1.1.7
resolution: "@radix-ui/react-focus-scope@npm:1.1.7"
dependencies:
"@radix-ui/react-compose-refs": "npm:1.1.2"
"@radix-ui/react-primitive": "npm:2.1.3"
"@radix-ui/react-use-callback-ref": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/8a6071331bdeeb79b223463de75caf759b8ad19339cab838e537b8dbb2db236891a1f4df252445c854d375d43d9d315dfcce0a6b01553a2984ec372bb8f1300e
languageName: node
linkType: hard
"@radix-ui/react-id@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-id@npm:1.1.0"
@@ -17515,6 +17624,21 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-id@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-id@npm:1.1.1"
dependencies:
"@radix-ui/react-use-layout-effect": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/7d12e76818763d592c331277ef62b197e2e64945307e650bd058f0090e5ae48bbd07691b23b7e9e977901ef4eadcb3e2d5eaeb17a13859083384be83fc1292c7
languageName: node
linkType: hard
"@radix-ui/react-menu@npm:2.1.1":
version: 2.1.1
resolution: "@radix-ui/react-menu@npm:2.1.1"
@@ -17551,6 +17675,39 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-popover@npm:^1.1.15":
version: 1.1.15
resolution: "@radix-ui/react-popover@npm:1.1.15"
dependencies:
"@radix-ui/primitive": "npm:1.1.3"
"@radix-ui/react-compose-refs": "npm:1.1.2"
"@radix-ui/react-context": "npm:1.1.2"
"@radix-ui/react-dismissable-layer": "npm:1.1.11"
"@radix-ui/react-focus-guards": "npm:1.1.3"
"@radix-ui/react-focus-scope": "npm:1.1.7"
"@radix-ui/react-id": "npm:1.1.1"
"@radix-ui/react-popper": "npm:1.2.8"
"@radix-ui/react-portal": "npm:1.1.9"
"@radix-ui/react-presence": "npm:1.1.5"
"@radix-ui/react-primitive": "npm:2.1.3"
"@radix-ui/react-slot": "npm:1.2.3"
"@radix-ui/react-use-controllable-state": "npm:1.2.2"
aria-hidden: "npm:^1.2.4"
react-remove-scroll: "npm:^2.6.3"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/c1c76b5e5985b128d03b621424fb453f769931d497759a1977734d303007da9f970570cf3ea1f6968ab609ab4a97f384168bff056197bd2d3d422abea0e3614b
languageName: node
linkType: hard
"@radix-ui/react-popper@npm:1.2.0":
version: 1.2.0
resolution: "@radix-ui/react-popper@npm:1.2.0"
@@ -17579,6 +17736,34 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-popper@npm:1.2.8":
version: 1.2.8
resolution: "@radix-ui/react-popper@npm:1.2.8"
dependencies:
"@floating-ui/react-dom": "npm:^2.0.0"
"@radix-ui/react-arrow": "npm:1.1.7"
"@radix-ui/react-compose-refs": "npm:1.1.2"
"@radix-ui/react-context": "npm:1.1.2"
"@radix-ui/react-primitive": "npm:2.1.3"
"@radix-ui/react-use-callback-ref": "npm:1.1.1"
"@radix-ui/react-use-layout-effect": "npm:1.1.1"
"@radix-ui/react-use-rect": "npm:1.1.1"
"@radix-ui/react-use-size": "npm:1.1.1"
"@radix-ui/rect": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/48e3f13eac3b8c13aca8ded37d74db17e1bb294da8d69f142ab6b8719a06c3f90051668bed64520bf9f3abdd77b382ce7ce209d056bb56137cecc949b69b421c
languageName: node
linkType: hard
"@radix-ui/react-portal@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-portal@npm:1.1.1"
@@ -17599,6 +17784,26 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-portal@npm:1.1.9":
version: 1.1.9
resolution: "@radix-ui/react-portal@npm:1.1.9"
dependencies:
"@radix-ui/react-primitive": "npm:2.1.3"
"@radix-ui/react-use-layout-effect": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/45b432497c722720c72c493a29ef6085bc84b50eafe79d48b45c553121b63e94f9cdb77a3a74b9c49126f8feb3feee009fe400d48b7759d3552396356b192cd7
languageName: node
linkType: hard
"@radix-ui/react-presence@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-presence@npm:1.1.0"
@@ -17619,6 +17824,26 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-presence@npm:1.1.5":
version: 1.1.5
resolution: "@radix-ui/react-presence@npm:1.1.5"
dependencies:
"@radix-ui/react-compose-refs": "npm:1.1.2"
"@radix-ui/react-use-layout-effect": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/d0e61d314250eeaef5369983cb790701d667f51734bafd98cf759072755562018052c594e6cdc5389789f4543cb0a4d98f03ff4e8f37338d6b5bf51a1700c1d1
languageName: node
linkType: hard
"@radix-ui/react-primitive@npm:2.0.0":
version: 2.0.0
resolution: "@radix-ui/react-primitive@npm:2.0.0"
@@ -17638,6 +17863,25 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-primitive@npm:2.1.3":
version: 2.1.3
resolution: "@radix-ui/react-primitive@npm:2.1.3"
dependencies:
"@radix-ui/react-slot": "npm:1.2.3"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10c0/fdff9b84913bb4172ef6d3af7442fca5f9bba5f2709cba08950071f819d7057aec3a4a2d9ef44cf9cbfb8014d02573c6884a04cff175895823aaef809ebdb034
languageName: node
linkType: hard
"@radix-ui/react-roving-focus@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-roving-focus@npm:1.1.0"
@@ -17680,6 +17924,21 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-slot@npm:1.2.3":
version: 1.2.3
resolution: "@radix-ui/react-slot@npm:1.2.3"
dependencies:
"@radix-ui/react-compose-refs": "npm:1.1.2"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/5913aa0d760f505905779515e4b1f0f71a422350f077cc8d26d1aafe53c97f177fec0e6d7fbbb50d8b5e498aa9df9f707ca75ae3801540c283b26b0136138eef
languageName: node
linkType: hard
"@radix-ui/react-tooltip@npm:^1.0.6":
version: 1.1.2
resolution: "@radix-ui/react-tooltip@npm:1.1.2"
@@ -17723,6 +17982,19 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-use-callback-ref@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-use-callback-ref@npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/5f6aff8592dea6a7e46589808912aba3fb3b626cf6edd2b14f01638b61dbbe49eeb9f67cd5601f4c15b2fb547b9a7e825f7c4961acd4dd70176c969ae405f8d8
languageName: node
linkType: hard
"@radix-ui/react-use-controllable-state@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-use-controllable-state@npm:1.1.0"
@@ -17738,6 +18010,37 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-use-controllable-state@npm:1.2.2":
version: 1.2.2
resolution: "@radix-ui/react-use-controllable-state@npm:1.2.2"
dependencies:
"@radix-ui/react-use-effect-event": "npm:0.0.2"
"@radix-ui/react-use-layout-effect": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/f55c4b06e895293aed4b44c9ef26fb24432539f5346fcd6519c7745800535b571058685314e83486a45bf61dc83887e24826490d3068acc317fb0a9010516e63
languageName: node
linkType: hard
"@radix-ui/react-use-effect-event@npm:0.0.2":
version: 0.0.2
resolution: "@radix-ui/react-use-effect-event@npm:0.0.2"
dependencies:
"@radix-ui/react-use-layout-effect": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/e84ff72a3e76c5ae9c94941028bb4b6472f17d4104481b9eab773deab3da640ecea035e54da9d6f4df8d84c18ef6913baf92b7511bee06930dc58bd0c0add417
languageName: node
linkType: hard
"@radix-ui/react-use-escape-keydown@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-use-escape-keydown@npm:1.1.0"
@@ -17753,6 +18056,21 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-use-escape-keydown@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-use-escape-keydown@npm:1.1.1"
dependencies:
"@radix-ui/react-use-callback-ref": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/bff53be99e940fef1d3c4df7d560e1d9133182e5a98336255d3063327d1d3dd4ec54a95dc5afe15cca4fb6c184f0a956c70de2815578c318cf995a7f9beabaa1
languageName: node
linkType: hard
"@radix-ui/react-use-layout-effect@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-use-layout-effect@npm:1.1.0"
@@ -17766,6 +18084,19 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-use-layout-effect@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/9f98fdaba008dfc58050de60a77670b885792df473cf82c1cef8daee919a5dd5a77d270209f5f0b0abfaac78cb1627396e3ff56c81b735be550409426fe8b040
languageName: node
linkType: hard
"@radix-ui/react-use-rect@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-use-rect@npm:1.1.0"
@@ -17781,6 +18112,21 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-use-rect@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-use-rect@npm:1.1.1"
dependencies:
"@radix-ui/rect": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/271711404c05c589c8dbdaa748749e7daf44bcc6bffc9ecd910821c3ebca0ee245616cf5b39653ce690f53f875c3836fd3f36f51ab1c628273b6db599eee4864
languageName: node
linkType: hard
"@radix-ui/react-use-size@npm:1.1.0":
version: 1.1.0
resolution: "@radix-ui/react-use-size@npm:1.1.0"
@@ -17796,6 +18142,21 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-use-size@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-use-size@npm:1.1.1"
dependencies:
"@radix-ui/react-use-layout-effect": "npm:1.1.1"
peerDependencies:
"@types/react": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/851d09a816f44282e0e9e2147b1b571410174cc048703a50c4fa54d672de994fd1dfff1da9d480ecfd12c77ae8f48d74f01adaf668f074156b8cd0043c6c21d8
languageName: node
linkType: hard
"@radix-ui/react-visually-hidden@npm:1.1.0, @radix-ui/react-visually-hidden@npm:^1.0.3":
version: 1.1.0
resolution: "@radix-ui/react-visually-hidden@npm:1.1.0"
@@ -17822,6 +18183,13 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/rect@npm:1.1.1":
version: 1.1.1
resolution: "@radix-ui/rect@npm:1.1.1"
checksum: 10c0/0dac4f0f15691199abe6a0e067821ddd9d0349c0c05f39834e4eafc8403caf724106884035ae91bbc826e10367e6a5672e7bec4d4243860fa7649de246b1f60b
languageName: node
linkType: hard
"@react-email/body@npm:0.1.0":
version: 0.1.0
resolution: "@react-email/body@npm:0.1.0"
@@ -50767,7 +51135,7 @@ __metadata:
languageName: node
linkType: hard
"react-remove-scroll@npm:^2.7.1":
"react-remove-scroll@npm:^2.6.3, react-remove-scroll@npm:^2.7.1":
version: 2.7.2
resolution: "react-remove-scroll@npm:2.7.2"
dependencies:
@@ -57074,6 +57442,7 @@ __metadata:
"@lingui/swc-plugin": "npm:^5.11.0"
"@lottiefiles/dotlottie-react": "npm:^0.18.10"
"@opennextjs/cloudflare": "npm:^1.0.0"
"@radix-ui/react-popover": "npm:^1.1.15"
"@swc/core": "npm:^1.15.11"
"@swc/jest": "npm:^0.2.39"
"@tabler/icons-react": "npm:^3.41.1"