fix(website): restore condensed marketplace list card (#23161)

## Summary

The condensed marketplace list-card design (sans-serif partner name,
description excerpt, a single scope line, and a "View profile →" text
CTA) was the intended glowup card, shipped by #22471/#22402. During the
#23016 resync, the `PartnerCard.tsx` merge conflict was resolved in
favor of main's rich card, so the condensed design never actually landed
on `main`.

This PR restores the condensed design, ported onto main's current data
layer and helpers — none of #23016's profile/case-study/matching work is
touched.

## What changed

- `PartnerCard.tsx` — rewritten to render the condensed layout
(sans-serif `PartnerName`, `CardIntro` description excerpt, `CardFoot`
with a single `ScopeLine` and a mono `CardCta` "View profile →" text
link, no chip rows, no money row, no LinkedIn icon). Reuses main's data
layer and helpers directly: `richTextExcerpt`,
`resolvePartnerScopeCards`, `PartnerAvatar`, `titleCaseFallback`, and
the shared `CardFrame` shell (same entrance-animation/hover idiom
already used by `MarketplaceMatchCard`). No new helper files were needed
— everything the condensed design requires already exists on `main`.
- `PartnerChipRow.tsx` — deleted (zero remaining importers after the
port; grep confirmed only self-reference).
- `PartnerMoneyRow.tsx` — deleted (zero remaining importers after the
port; grep confirmed only self-reference).

Kept untouched: `fetch-live-marketplace-partners.ts`,
`marketplace-partner.ts`, `get-marketplace-partners.ts`,
`marketplace-partners-source.ts`, all
`PartnerProfile*`/case-study/matching files, `MarketplaceGrid.tsx`
(already has the `repeat(N, minmax(0, 1fr))` grid columns on main — no
change needed), and the shared label helpers
`PARTNER_SCOPE_LABELS`/`SERVED_GEO_LABELS`/`SPOKEN_LANGUAGE_LABELS`
(still used by `FilterBar.tsx`/`PartnerReachFacts.tsx`).

Website-only change — no partners-app version bump, no `.po` catalog
changes.

## Test plan

- [x] `npx nx typecheck twenty-website` — clean
- [x] `npx oxlint -c .oxlintrc.json .` (twenty-website) — 0 warnings, 0
errors
- [x] `npx oxfmt --check .` (twenty-website) — clean
- [x] `npx jest partners-marketplace` — 12 suites / 54 tests passing
- [x] Verified locally against the running dev server
(`/partners/list`): 16 partner cards render with the condensed layout
(sans-serif name, excerpt, single scope line, "View profile →"), no chip
rows/money rows/LinkedIn icons, real partner data renders correctly
(e.g. 01GROWTH, Inc)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23161?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Rashad Karanouh
2026-07-23 06:35:47 +02:00
committed by GitHub
parent e2ea82170c
commit 32041ce2e0
6 changed files with 70 additions and 367 deletions
@@ -1,12 +1,12 @@
'use client';
import { IconBrandLinkedin } from '@tabler/icons-react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { LocalizedLink } from '@/platform/i18n/LocalizedLink';
import {
color,
FONT_WEIGHT,
fontFamily,
fontSize,
@@ -14,30 +14,24 @@ import {
semanticColor,
spacing,
} from '@/tokens';
import { Button, ExternalLink } from '@/ui';
import { isSafeHttpUrl } from './is-safe-http-url';
import { CardFrame, type PartnerCardIndexStyle } from './MarketplaceCardFrame';
import { type MarketplacePartner } from './marketplace-partner';
import { PartnerAvatar } from './PartnerAvatar';
import { PartnerChipRow } from './PartnerChipRow';
import { PartnerMoneyRow } from './PartnerMoneyRow';
import { PARTNER_SCOPE_LABELS } from './partner-scope-labels';
import { resolvePartnerScopeCards } from './resolve-partner-scope-cards';
import { richTextExcerpt } from './rich-text-excerpt';
import { SERVED_GEO_LABELS } from './served-geo-labels';
import { SPOKEN_LANGUAGE_LABELS } from './spoken-language-labels';
import { titleCaseFallback } from './title-case-fallback';
const CardArticle = styled(CardFrame)`
gap: ${spacing(5)};
padding: ${spacing(6)};
gap: ${spacing(3.5)};
padding: ${spacing(5.5)} ${spacing(5.5)} ${spacing(4.5)};
will-change: transform;
`;
const CardHeader = styled.div`
const CardTop = styled.div`
align-items: center;
display: flex;
gap: ${spacing(4)};
gap: ${spacing(3.25)};
`;
const HeaderText = styled.div`
@@ -46,30 +40,22 @@ const HeaderText = styled.div`
min-width: 0;
& > * + * {
margin-top: ${spacing(1)};
margin-top: ${spacing(0.75)};
}
`;
const NameRow = styled.div`
align-items: center;
display: flex;
gap: ${spacing(2)};
`;
const PartnerName = styled.h3`
color: ${semanticColor.ink};
font-family: ${fontFamily('serif')};
font-size: ${fontSize(6)};
font-weight: ${FONT_WEIGHT.light};
letter-spacing: -0.02em;
line-height: ${fontSize(7)};
font-family: ${fontFamily('sans')};
font-size: ${fontSize(4.25)};
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
`;
// 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, View
// profile) sit above this overlay via z-index, so each stays an independent
// click target without illegal nested <a> elements.
// entire <article>.
const NameLink = styled(LocalizedLink)`
color: inherit;
text-decoration: none;
@@ -88,69 +74,62 @@ const NameLink = styled(LocalizedLink)`
}
`;
const LinkedinIconLink = styled(ExternalLink)`
align-items: center;
color: ${semanticColor.inkMuted};
display: inline-flex;
position: relative;
transition: color 0.2s ease;
z-index: 1;
&:hover {
color: ${semanticColor.ink};
}
`;
const LocationEyebrow = styled.span`
color: ${semanticColor.inkMuted};
font-family: ${fontFamily('mono')};
font-size: ${fontSize(3)};
font-weight: ${FONT_WEIGHT.medium};
letter-spacing: 0.08em;
line-height: ${fontSize(4)};
font-size: ${fontSize(2.625)};
letter-spacing: 0.1em;
line-height: 1.2;
text-transform: uppercase;
`;
const Introduction = styled.p`
const CardIntro = styled.p`
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
color: ${semanticColor.inkMuted};
display: -webkit-box;
font-family: ${fontFamily('sans')};
font-size: ${fontSize(4)};
line-height: ${fontSize(5.5)};
font-size: ${fontSize(3.625)};
line-height: 1.5;
overflow: hidden;
overflow-wrap: anywhere;
`;
const CardFoot = styled.div`
align-items: center;
border-top: 1px solid ${semanticColor.line};
display: flex;
gap: ${spacing(3.5)};
justify-content: space-between;
margin-top: ${spacing(0.5)};
padding-top: ${spacing(3.5)};
`;
const ScopeLine = styled.p`
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: ${semanticColor.inkMuted};
display: -webkit-box;
flex: 1;
font-family: ${fontFamily('sans')};
font-size: ${fontSize(3.5)};
font-weight: ${FONT_WEIGHT.medium};
line-height: 1.4;
min-width: 0;
overflow: hidden;
`;
const Divider = styled.hr`
background-color: ${semanticColor.line};
border: 0;
height: 1px;
width: 100%;
`;
const ChipRows = styled.div`
display: flex;
flex-direction: column;
& > * + * {
margin-top: ${spacing(3)};
}
`;
// The price row pins to the card bottom (margin-top: auto), so the button —
// its plain trailing sibling — sits right under it with the same gap on every
// card, regardless of how many lines the chip rows took. The flexible space
// lands above the price (between the chips and the price), not between the
// price and the button.
const MoneyRowPin = styled.div`
margin-top: auto;
`;
const CtaWrapper = styled.div`
display: flex;
position: relative;
z-index: 1;
const CardCta = styled.span`
align-items: center;
color: ${color('blue')};
display: inline-flex;
flex: 0 0 auto;
font-family: ${fontFamily('mono')};
font-size: ${fontSize(2.75)};
gap: ${spacing(1.25)};
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
`;
type PartnerCardProps = {
@@ -162,7 +141,7 @@ export function PartnerCard({ partner, index }: PartnerCardProps) {
const { i18n } = useLingui();
const headingId = `partner-card-heading-${partner.slug}`;
const style: PartnerCardIndexStyle = { '--partner-card-index': index };
// Unprefixed; LocalizedLink (the name link) and the Button add the locale.
// Unprefixed; LocalizedLink (the name link) adds the locale.
const profileHref = `/partners/profile/${partner.slug}`;
const locationLine = [
@@ -172,77 +151,37 @@ export function PartnerCard({ partner, index }: PartnerCardProps) {
.filter(Boolean)
.join(', ');
const linkedinHref =
partner.links.linkedin !== null && isSafeHttpUrl(partner.links.linkedin)
? partner.links.linkedin
: null;
const scopeCards = resolvePartnerScopeCards(partner.partnerScope);
const scopeLine = scopeCards.map((scope) => i18n._(scope.label)).join(' · ');
const descriptionExcerpt = richTextExcerpt(partner.description);
return (
<CardArticle aria-labelledby={headingId} style={style}>
<CardHeader>
<CardTop>
<PartnerAvatar
name={partner.name}
slug={partner.slug}
profilePictureUrl={partner.profilePictureUrl}
/>
<HeaderText>
<NameRow>
<PartnerName id={headingId}>
<NameLink href={profileHref}>{partner.name}</NameLink>
</PartnerName>
{linkedinHref !== null && (
<LinkedinIconLink
href={linkedinHref}
aria-label={i18n._(msg`View ${partner.name} on LinkedIn`)}
>
<IconBrandLinkedin size={16} aria-hidden="true" />
</LinkedinIconLink>
)}
</NameRow>
<PartnerName id={headingId}>
<NameLink href={profileHref}>{partner.name}</NameLink>
</PartnerName>
{locationLine.length > 0 && (
<LocationEyebrow>{locationLine}</LocationEyebrow>
)}
</HeaderText>
</CardHeader>
</CardTop>
<Introduction>{descriptionExcerpt}</Introduction>
<CardIntro>{descriptionExcerpt}</CardIntro>
<Divider aria-hidden="true" />
<ChipRows>
<PartnerChipRow
label={msg`Regions`}
values={partner.region}
valueLabels={SERVED_GEO_LABELS}
/>
<PartnerChipRow
label={msg`Languages`}
values={partner.languagesSpoken}
valueLabels={SPOKEN_LANGUAGE_LABELS}
/>
<PartnerChipRow
label={msg`Categories`}
values={partner.partnerScope}
valueLabels={PARTNER_SCOPE_LABELS}
/>
</ChipRows>
<MoneyRowPin>
<PartnerMoneyRow
hourlyRateUsd={partner.hourlyRateUsd}
projectBudgetMinUsd={partner.projectBudgetMinUsd}
/>
</MoneyRowPin>
<CtaWrapper>
<Button
href={profileHref}
label={i18n._(msg`View profile`)}
variant="filled"
/>
</CtaWrapper>
<CardFoot>
{scopeLine.length > 0 && (
<ScopeLine title={scopeLine}>{scopeLine}</ScopeLine>
)}
<CardCta>{i18n._(msg`View profile`)} </CardCta>
</CardFoot>
</CardArticle>
);
}
@@ -1,92 +0,0 @@
'use client';
import { type MessageDescriptor } from '@lingui/core';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import {
fontFamily,
FONT_WEIGHT,
fontSize,
mediaUp,
semanticColor,
spacing,
} from '@/tokens';
import { partnerChipClassName } from './partner-chip-style';
import { titleCaseFallback } from './title-case-fallback';
const Row = styled.dl`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${spacing(2)};
${mediaUp('md')} {
// Align the label to the first chip's baseline rather than the row's
// center: when chips wrap to multiple lines in a narrow card, centering
// floats the label to the middle and visually lifts the first chip into
// the row above. Baseline keeps the label pinned beside the first chip.
align-items: baseline;
flex-direction: row;
gap: ${spacing(4)};
}
`;
const RowLabel = styled.dt`
color: ${semanticColor.inkMuted};
font-family: ${fontFamily('mono')};
font-size: ${fontSize(3)};
font-weight: ${FONT_WEIGHT.medium};
letter-spacing: 0.08em;
line-height: ${fontSize(4)};
text-transform: uppercase;
${mediaUp('md')} {
flex-shrink: 0;
width: 80px;
}
`;
const ChipList = styled.ul`
display: flex;
flex-wrap: wrap;
gap: ${spacing(2)};
list-style: none;
padding: 0;
`;
type PartnerChipRowProps<TValue extends string> = {
label: MessageDescriptor;
values: readonly TValue[];
valueLabels: Record<TValue, MessageDescriptor>;
};
export function PartnerChipRow<TValue extends string>({
label,
values,
valueLabels,
}: PartnerChipRowProps<TValue>) {
const { i18n } = useLingui();
return (
<Row>
<RowLabel>{i18n._(label)}</RowLabel>
<dd>
<ChipList>
{values.map((value) => {
const descriptor = valueLabels[value];
const text = descriptor
? i18n._(descriptor)
: titleCaseFallback(value);
return (
<li key={value} className={partnerChipClassName}>
{text}
</li>
);
})}
</ChipList>
</dd>
</Row>
);
}
@@ -1,72 +0,0 @@
'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 {
color,
fontFamily,
FONT_WEIGHT,
fontSize,
semanticColor,
spacing,
} from '@/tokens';
import { formatUsdCompact } from './format-usd-compact';
const Row = styled.div`
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${spacing(2)};
`;
const Pill = styled.span`
align-items: center;
background-color: ${color('white')};
border: 1px solid ${semanticColor.line};
border-radius: 999px;
color: ${color('black-80')};
display: inline-flex;
font-family: ${fontFamily('sans')};
font-size: ${fontSize(3)};
font-weight: ${FONT_WEIGHT.medium};
gap: ${spacing(1.5)};
line-height: 1;
padding: ${spacing(1.5)} ${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>
);
}
@@ -1,31 +0,0 @@
import { formatUsdCompact } from './format-usd-compact';
describe('formatUsdCompact', () => {
it('returns null when the amount is null', () => {
expect(formatUsdCompact(null)).toBeNull();
});
it('returns null when the amount is NaN', () => {
expect(formatUsdCompact(Number.NaN)).toBeNull();
});
it('renders sub-thousand amounts in full', () => {
expect(formatUsdCompact(140)).toBe('$140');
});
it('renders zero', () => {
expect(formatUsdCompact(0)).toBe('$0');
});
it('compacts thousands', () => {
expect(formatUsdCompact(8000)).toBe('$8K');
});
it('compacts thousands to one decimal', () => {
expect(formatUsdCompact(8500)).toBe('$8.5K');
});
it('compacts larger budgets', () => {
expect(formatUsdCompact(25000)).toBe('$25K');
});
});
@@ -1,15 +0,0 @@
const COMPACT_FORMATTER = new Intl.NumberFormat('en-US', {
notation: 'compact',
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
// Compact USD for the marketplace pricing pills ($140/hr, from $8K). A null
// amount (the partner left the rate unset) returns null so the caller can omit
// the pill entirely rather than render "$0".
export const formatUsdCompact = (usd: number | null): string | null => {
if (usd === null || Number.isNaN(usd)) return null;
return COMPACT_FORMATTER.format(usd);
};
@@ -1,26 +0,0 @@
import { css } from '@linaria/core';
import {
color,
fontFamily,
FONT_WEIGHT,
fontSize,
radius,
semanticColor,
spacing,
} from '@/tokens';
// The static label chip on partner cards: a bordered, uppercase mono tag for
// regions, languages, and categories.
export const partnerChipClassName = css`
border: 1px solid ${semanticColor.line};
border-radius: ${radius(4)};
color: ${color('black-80')};
font-family: ${fontFamily('mono')};
font-size: ${fontSize(3)};
font-weight: ${FONT_WEIGHT.medium};
letter-spacing: 0.04em;
line-height: ${fontSize(4)};
padding: ${spacing(1)} ${spacing(2.5)};
text-transform: uppercase;
`;