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:
@@ -0,0 +1,159 @@
|
||||
# Twenty Website — DESIGN.md
|
||||
|
||||
> Visual system for the Twenty marketing site. Distilled from `packages/twenty-website/src/theme/`. Loaded by every `impeccable` invocation alongside PRODUCT.md.
|
||||
|
||||
## Theme
|
||||
|
||||
**Light by default.** A founder browsing a partner profile in daylight on a 14–27 inch monitor is the default scene. The site does ship a `data-scheme="dark"` override (see `css-variables.ts`), but no current public page opts into it. Treat dark as a deferred surface.
|
||||
|
||||
## Color
|
||||
|
||||
Palette is OKLCH-equivalent neutrals at the surface level. The brand accents (blue, pink, yellow, green) are present in the token system but used sparingly — none of them appear on the partner pages.
|
||||
|
||||
### Strategy: Restrained
|
||||
|
||||
Tinted neutrals + one accent ≤10%. The accent for partner pages is the deep ink black (`var(--color-black-100)`) used in CTAs and hover states. Anything beyond a hairline border, an icon glyph, or a primary CTA should question whether it needs color at all.
|
||||
|
||||
### Tokens (from `src/theme/colors.ts` + `css-variables.ts`)
|
||||
|
||||
Neutrals (the workhorses):
|
||||
|
||||
| Token | Hex (computed) | Role |
|
||||
| --- | --- | --- |
|
||||
| `colors.primary.background[100]` | `#ffffff` | Page + card surface |
|
||||
| `colors.primary.text[100]` | `#1c1c1c` | Headlines, primary text |
|
||||
| `colors.primary.text[80]` | `#1c1c1ccc` | Body text |
|
||||
| `colors.primary.text[60]` | `#1c1c1c99` | Eyebrows, meta, captions |
|
||||
| `colors.primary.text[40]` | `#1c1c1c66` | Disabled / placeholder |
|
||||
| `colors.primary.text[20]` | `#1c1c1c33` | Subtle separators |
|
||||
| `colors.primary.text[10]` | `#1c1c1c1a` | Hairline borders |
|
||||
| `colors.primary.text[5]` | `#1c1c1c0d` | Subtle fills (rates panel, skill chips) |
|
||||
| `colors.primary.border[10]` | `#1c1c1c1a` | Default border |
|
||||
| `colors.primary.border[20]` | `#1c1c1c33` | Hover border |
|
||||
|
||||
Reverse palette (for dark CTAs):
|
||||
|
||||
| Token | Role |
|
||||
| --- | --- |
|
||||
| `colors.secondary.background[100]` | Filled CTA background (deep ink) |
|
||||
| `colors.secondary.text[100]` | Filled CTA text (white) |
|
||||
|
||||
Brand accents (currently absent from partner pages; available if needed):
|
||||
|
||||
- `colors.accent.blue` — `#4a38f5` / `#8174f8`
|
||||
- `colors.accent.pink` — `#ed87fc` / `#f3abfd`
|
||||
- `colors.accent.yellow` — `#feffb7` / `#feffd9`
|
||||
- `colors.accent.green` — `#89fc9a` / `#b0fdbe`
|
||||
- `colors.highlight` — same hue as blue accent
|
||||
|
||||
**Do not introduce gradients, glass blurs, or saturated fills on partner pages.** Color is conviction here, not decoration.
|
||||
|
||||
## Typography
|
||||
|
||||
Three families, each load-balanced via CSS variables:
|
||||
|
||||
| Family | Var | Use |
|
||||
| --- | --- | --- |
|
||||
| `theme.font.family.serif` | `--font-serif` | Headlines, partner names, headline values |
|
||||
| `theme.font.family.sans` | `--font-sans` | Body, prose, interactive labels |
|
||||
| `theme.font.family.mono` | `--font-mono` | Eyebrows, meta, currency labels, tabular numerics |
|
||||
| `theme.font.family.retro` | `--font-retro` | Reserved (not used on partner pages) |
|
||||
|
||||
### Weight + Size Contrast
|
||||
|
||||
Weights: `light: 300`, `regular: 400`, `medium: 500`. No bold. Hierarchy is driven by scale and family contrast, never by weight alone.
|
||||
|
||||
Scale (`theme.font.size(n)` → `calc(var(--font-base) * n)`, where `--font-base: 0.25rem` ≈ 4px):
|
||||
|
||||
- Display / h1: size 9–12 (36–48px)
|
||||
- h2 / section heads: size 7–8 (28–32px)
|
||||
- h3 / card heads: size 5–6 (20–24px)
|
||||
- Body / prose: size 4–5 (16–20px)
|
||||
- Eyebrow / meta: size 3 (12px) with `letter-spacing: 0.06–0.08em` and `text-transform: uppercase`
|
||||
|
||||
Body line length: cap at 65–75ch (the existing `PartnerProfileIntro` uses `max-width: 62ch` — keep that order of magnitude).
|
||||
|
||||
### Hierarchy contract
|
||||
|
||||
- A serif `<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(10–14)` — generous, editorial breathing room.
|
||||
- Inter-element gap inside a section: `theme.spacing(3–5)`.
|
||||
- Card padding: `theme.spacing(6)`.
|
||||
- Page horizontal padding: `theme.spacing(4)` mobile, `theme.spacing(10)` ≥ md breakpoint.
|
||||
|
||||
### Radius
|
||||
|
||||
`theme.radius(n)` returns `n * 2px`. The default card radius is `theme.radius(2)` = 4px. Pills use `999px`. No softer rounding than that.
|
||||
|
||||
### Borders
|
||||
|
||||
Borders are hairline (`1px solid theme.colors.primary.border[10]`). They define edges quietly. On hover they step to `border[20]`. Never use a chunky border as decoration.
|
||||
|
||||
## Components
|
||||
|
||||
### Card (PartnerCard, RatesPanel)
|
||||
|
||||
White surface, hairline border, 4px radius, 24px padding, soft shadow on hover only:
|
||||
|
||||
```css
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
border: 1px solid ${theme.colors.primary.border[10]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
padding: ${theme.spacing(6)};
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.colors.primary.border[20]};
|
||||
box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Chip / Pill
|
||||
|
||||
Rounded `999px`, 1px border, subtle background fill (`primary.text[5]` for filter pills, transparent for chip rows), `text[80]` color, mono or sans font.
|
||||
|
||||
### Button / LinkButton
|
||||
|
||||
Lives in `@/design-system/components`. Two color modes: `primary` (deep ink fill, white text) and `secondary` (transparent fill, ink text + 1px border). `variant="contained"` is what partner pages use.
|
||||
|
||||
### Avatar
|
||||
|
||||
`PartnerAvatar` is a deterministic generated mark from name + slug. Used as fallback when `profilePictureUrl` is missing. The real photo overlays it at 120px circle on the detail page, 56px on the list card.
|
||||
|
||||
## Motion
|
||||
|
||||
- Hover transitions: 250ms, ease-out (cubic-bezier curve in `PartnerCard`: `0.25s ease`).
|
||||
- Card entrance: 700ms cubic-bezier `0.22, 1, 0.36, 1` (ease-out-quart), 90ms stagger per index.
|
||||
- All motion respects `@media (prefers-reduced-motion: reduce)` — animations stop, hover translate disabled.
|
||||
- **No bounce, no elastic, no parallax.** Editorial restraint.
|
||||
|
||||
## Iconography
|
||||
|
||||
`@tabler/icons-react`, 14–16px on body-level chips, 18–24px on buttons. Always `aria-hidden="true"` when decorative. Stroke width `2` (default).
|
||||
|
||||
## Accessibility Defaults
|
||||
|
||||
- Focus ring: `outline: 2px solid theme.colors.primary.text[100]; outline-offset: 4px` (already used on the card link).
|
||||
- Touch target ≥ 40×40px on mobile.
|
||||
- `aria-label` on icon-only buttons, `aria-labelledby` on sectioned regions.
|
||||
- All `<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
@@ -0,0 +1,80 @@
|
||||
# Twenty Website — Product & Brand Context
|
||||
|
||||
> Strategic context for design work on the Twenty marketing site (`packages/twenty-website`). Loaded by every `impeccable` invocation.
|
||||
|
||||
## Register
|
||||
|
||||
**Brand.** The marketing site is a public-facing surface where the design itself is part of the credibility argument. Prospects evaluate Twenty partly by how the site feels. The product app (`packages/twenty-front`) is a separate product-register surface, governed elsewhere.
|
||||
|
||||
## Users & Purpose
|
||||
|
||||
The primary audience varies by route, but the working assumption for partner-related pages is:
|
||||
|
||||
- **Who:** A budget-holding decision maker (founder, RevOps lead, or COO) shopping for a CRM implementation partner. Already on Twenty's site, evaluating a shortlist of partners.
|
||||
- **Context:** Doing a side-by-side comparison across 2–5 candidates over a single browsing session. Will spend 30–90 seconds on each profile before deciding whether to book a call.
|
||||
- **Decision being made:** "Is this partner credible, the right size, the right specialty, and within budget? Do I trust them enough to commit 30 minutes to a discovery call?"
|
||||
|
||||
What the partner pages must do, in priority order:
|
||||
1. Communicate credibility (real firm, real person, real work).
|
||||
2. Surface fit signals fast (skills, region, languages, deployment expertise, budget range).
|
||||
3. Give the visitor a confident "next step" affordance (book a call or vet via LinkedIn) without pressure.
|
||||
|
||||
## Desired Outcome
|
||||
|
||||
The redesign should make `/partners/profile/[slug]` feel like a *thoughtfully curated profile of a top-tier partner*, not a generic templated card. A visitor should leave thinking "this firm is serious" even if they don't book a call this session.
|
||||
|
||||
Specifically:
|
||||
- **Confidence over information density.** A short, well-typeset profile beats a packed-but-busy one.
|
||||
- **Editorial restraint.** White space, deliberate type hierarchy, and a few well-chosen details say more than dozens of small components.
|
||||
- **Quiet conviction.** No hype copy, no growth-hack patterns, no "Trusted by" logo strips. The partner's own work and intro speak for themselves.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
**Editorial · Founder-led · Considered.**
|
||||
|
||||
The site reads like a thoughtful indie publication, not a SaaS landing page. Serif headlines, plenty of whitespace, deliberate typographic rhythm. Quietly opinionated — Twenty has a point of view about CRM (open-source, customizable, well-designed) and the site reflects that without shouting.
|
||||
|
||||
Tonal anchors:
|
||||
- Stripe's documentation for clarity, Linear's marketing for restraint, an editorial print magazine for typography choices.
|
||||
|
||||
## Anti-references
|
||||
|
||||
**Reject these patterns. They make the work read as generic AI / generic SaaS:**
|
||||
|
||||
- **Generic SaaS landing.** Big-number heroes, identical icon-grid cards, gradient text, navy + lime accent color schemes, "supercharge your workflow" language.
|
||||
- **Corporate enterprise tone.** Stock photos of diverse handshakes. "Trusted by Fortune 500" logo strips as the primary credibility move. Trust-badge bars.
|
||||
- **Bento templates.** Repetitive same-size cards. Vercel-style scroll-pin animations on every section.
|
||||
- **Side-stripe borders, gradient text, glassmorphism, hero-metric templates, identical card grids** — see impeccable's shared absolute bans.
|
||||
|
||||
## Strategic Design Principles
|
||||
|
||||
1. **Typography carries the design.** The brand has a serif/sans/mono trio. Hierarchy is set by scale + weight contrast, not by color or borders.
|
||||
2. **Restrained palette.** Tinted neutrals (black/white via CSS variables, with alpha-tone variants for text and borders) carry 90%+ of the surface. Accent color used sparingly when it appears at all.
|
||||
3. **Whitespace is a feature.** Tight cards feel cheap. Pages should breathe.
|
||||
4. **Asymmetry over grid.** A 12-col bento is the wrong shape for a profile page. Use asymmetric two-column layouts where one column does heavy lifting.
|
||||
5. **One opinionated detail per page.** Each surface should have one moment of editorial conviction (a typographic flourish, a precise micro-interaction, a deliberate space) rather than five generic flourishes.
|
||||
|
||||
## Accessibility
|
||||
|
||||
**WCAG AA + keyboard + screen reader baseline:**
|
||||
|
||||
- All interactive elements reachable by keyboard, focus visible (`outline: 2px solid`, not just color shift).
|
||||
- Semantic landmarks: `<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/`).
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+18
-6
@@ -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}"`);
|
||||
});
|
||||
});
|
||||
|
||||
+37
-11
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-13
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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}', () => {
|
||||
|
||||
+86
@@ -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>
|
||||
);
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
|
||||
+21
-5
@@ -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),
|
||||
};
|
||||
|
||||
+32
@@ -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>
|
||||
);
|
||||
}
|
||||
+125
@@ -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>
|
||||
);
|
||||
}
|
||||
+79
@@ -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>
|
||||
);
|
||||
}
|
||||
+71
@@ -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>
|
||||
);
|
||||
}
|
||||
+26
@@ -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>;
|
||||
}
|
||||
+67
@@ -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>
|
||||
);
|
||||
}
|
||||
+89
@@ -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 & 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[];
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user