feat(website): partner marketplace UI fixes & CTA improvements (#21163)
## Summary Partner marketplace UI fixes and CTA improvements on the website (`twenty-website`). ## Changes - **Find a partner buttons** → link to `/partners/list` (hero + signoff sections) instead of opening the contact modal. - **Chip row layout fix** → align partner chip rows to the first chip's baseline. In the narrow 3-column grid, category chips wrap to multiple lines; centering floated the label to the middle and visually lifted the first chip into the row above (e.g. "Custom Development" appeared under Languages). Baseline keeps the label pinned beside the first chip. - **Card CTA** → replace the conditional "Book a call" calendar CTA with a "View profile" link to the partner profile page, shown on every card regardless of whether a calendar link exists. Keeps card heights consistent across the grid. - **Card avatar** → show the partner's real profile picture when a safe `http(s)` URL is present, keeping the initials block as the fallback. - **Profile "Contact <partner>" CTA** → when a partner has no booking (calendar) link, show a "Contact <partner>" button (alongside LinkedIn if present) that opens a `mailto:rashad@twenty.com` with a partner-named subject and a pre-filled body prompt. ## Tests - New: `PartnerAvatar`, `PartnerProfileCtas` unit tests. - Updated: `PartnerCard` tests for the View-profile CTA. - All `Partner*` website tests pass. ## Screenshots <img width="1440" height="816" alt="Screenshot 2026-06-02 at 23 40 55" src="https://github.com/user-attachments/assets/bd88a9be-3297-4d7f-891c-c9d403d2b4d9" /> <img width="1441" height="818" alt="Screenshot 2026-06-02 at 23 40 47" src="https://github.com/user-attachments/assets/b315424f-48d1-4d29-9e97-1fcf4d8c47f2" /> <img width="458" height="505" alt="Screenshot 2026-06-02 at 23 40 18" src="https://github.com/user-attachments/assets/2a4e0ec2-8e0a-4220-84ec-177c788aa580" />
This commit is contained in:
+6
-3
@@ -1,17 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { TalkToUsButton } from '@/sections/ContactCal';
|
||||
import { LocalizedLinkButton } from '@/lib/i18n/LocalizedLink';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { BecomePartnerButton } from './BecomePartnerButton';
|
||||
|
||||
export function PartnerHeroCtas() {
|
||||
const { i18n } = useLingui();
|
||||
return (
|
||||
<>
|
||||
<BecomePartnerButton variant="contained" />
|
||||
<TalkToUsButton
|
||||
<LocalizedLinkButton
|
||||
color="secondary"
|
||||
label={msg`Find a partner`}
|
||||
href="/partners/list"
|
||||
label={i18n._(msg`Find a partner`)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</>
|
||||
|
||||
+6
-3
@@ -1,17 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { TalkToUsButton } from '@/sections/ContactCal';
|
||||
import { LocalizedLinkButton } from '@/lib/i18n/LocalizedLink';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { BecomePartnerButton } from './BecomePartnerButton';
|
||||
|
||||
export function PartnerSignoffCtas() {
|
||||
const { i18n } = useLingui();
|
||||
return (
|
||||
<>
|
||||
<BecomePartnerButton variant="outlined" />
|
||||
<TalkToUsButton
|
||||
<LocalizedLinkButton
|
||||
color="secondary"
|
||||
label={msg`Talk to us`}
|
||||
href="/partners/list"
|
||||
label={i18n._(msg`Find a partner`)}
|
||||
variant="contained"
|
||||
/>
|
||||
</>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { PartnerAvatar } from '../components/PartnerAvatar';
|
||||
|
||||
describe('PartnerAvatar', () => {
|
||||
it('renders the profile picture when a safe URL is provided', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PartnerAvatar
|
||||
name="Jane Doe"
|
||||
slug="jane-doe"
|
||||
profilePictureUrl="https://cdn.example/jane.jpg"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('src="https://cdn.example/jane.jpg"');
|
||||
});
|
||||
|
||||
it('falls back to initials when no profile picture is provided', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PartnerAvatar name="Jane Doe" slug="jane-doe" />,
|
||||
);
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('JD');
|
||||
});
|
||||
|
||||
it.each(['', 'not-a-url', 'javascript:alert(1)', 'data:text/html,<x>'])(
|
||||
'falls back to initials for an unsafe or empty URL (%s)',
|
||||
(url) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PartnerAvatar
|
||||
name="Jane Doe"
|
||||
slug="jane-doe"
|
||||
profilePictureUrl={url}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('JD');
|
||||
},
|
||||
);
|
||||
});
|
||||
+19
-22
@@ -66,29 +66,26 @@ describe('PartnerCard', () => {
|
||||
expect(liMatches.length).toBe(expectedChipCount);
|
||||
});
|
||||
|
||||
it('renders the Calendly CTA pointing at the partner link in a new tab', () => {
|
||||
it('renders a View profile CTA pointing at the partner profile page', () => {
|
||||
const html = renderCard();
|
||||
expect(html).toContain(`href="${FIXTURE.calendarLink}"`);
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('noopener');
|
||||
expect(html).toContain('View profile');
|
||||
expect(html).toContain(`href="/en/partners/profile/${FIXTURE.slug}"`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'javascript:alert(document.cookie)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'',
|
||||
'not-a-url',
|
||||
])('suppresses the CTA when calendarLink is %s', (unsafeLink) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PartnerCard
|
||||
partner={{ ...FIXTURE, calendarLink: unsafeLink }}
|
||||
index={0}
|
||||
locale="en"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).not.toContain(`href="${unsafeLink}"`);
|
||||
});
|
||||
it.each(['https://calendly.com/test-partner', '', 'not-a-url'])(
|
||||
'renders the View profile CTA regardless of calendarLink (%s)',
|
||||
(link) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PartnerCard
|
||||
partner={{ ...FIXTURE, calendarLink: link }}
|
||||
index={0}
|
||||
locale="en"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain('View profile');
|
||||
expect(html).toContain(`href="/en/partners/profile/${FIXTURE.slug}"`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+30
-1
@@ -23,6 +23,23 @@ const AvatarBlock = styled.span`
|
||||
width: 48px;
|
||||
`;
|
||||
|
||||
const AvatarImage = styled.img`
|
||||
border-radius: ${theme.radius(1.5)};
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
width: 48px;
|
||||
`;
|
||||
|
||||
const isSafeHttpUrl = (raw: string) => {
|
||||
try {
|
||||
return ['https:', 'http:'].includes(new URL(raw).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
function pickPaletteColor(slug: string): string {
|
||||
if (slug.length === 0) return AVATAR_PALETTE[0];
|
||||
const index = (slug.charCodeAt(0) + slug.length) % AVATAR_PALETTE.length;
|
||||
@@ -39,9 +56,21 @@ function pickInitials(name: string): string {
|
||||
type PartnerAvatarProps = {
|
||||
name: string;
|
||||
slug: string;
|
||||
profilePictureUrl?: string;
|
||||
};
|
||||
|
||||
export function PartnerAvatar({ name, slug }: PartnerAvatarProps) {
|
||||
export function PartnerAvatar({
|
||||
name,
|
||||
slug,
|
||||
profilePictureUrl,
|
||||
}: PartnerAvatarProps) {
|
||||
// Show the partner's real picture when present; the initials block stays as
|
||||
// the fallback for partners without one. Decorative (alt="") because the
|
||||
// partner name is already rendered as text beside the avatar.
|
||||
if (profilePictureUrl !== undefined && isSafeHttpUrl(profilePictureUrl)) {
|
||||
return <AvatarImage src={profilePictureUrl} alt="" loading="lazy" />;
|
||||
}
|
||||
|
||||
const backgroundColor = pickPaletteColor(slug);
|
||||
const initials = pickInitials(name);
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { IconBrandLinkedin } from '@tabler/icons-react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { LinkButton } from '@/design-system/components';
|
||||
import {
|
||||
BaseButton,
|
||||
buttonBaseStyles,
|
||||
} from '@/design-system/components/Button/BaseButton';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextLink from 'next/link';
|
||||
@@ -176,6 +179,13 @@ const CtaWrapper = styled.div`
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
// Internal, same-tab navigation to the partner profile. Mirrors NameLink's
|
||||
// explicit locale-prefixed href (rather than LocalizedLink's context lookup)
|
||||
// so the card stays self-contained and testable with just the lingui provider.
|
||||
const ProfileCtaLink = styled(NextLink)`
|
||||
${buttonBaseStyles}
|
||||
`;
|
||||
|
||||
const isSafeHttpUrl = (raw: string) => {
|
||||
try {
|
||||
return ['https:', 'http:'].includes(new URL(raw).protocol);
|
||||
@@ -201,12 +211,15 @@ export function PartnerCard({ partner, index, locale }: PartnerCardProps) {
|
||||
: '';
|
||||
|
||||
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} />
|
||||
<PartnerAvatar
|
||||
name={partner.name}
|
||||
slug={partner.slug}
|
||||
profilePictureUrl={partner.profilePictureUrl}
|
||||
/>
|
||||
<HeaderText>
|
||||
<NameRow>
|
||||
<PartnerName id={headingId}>
|
||||
@@ -256,16 +269,20 @@ export function PartnerCard({ partner, index, locale }: PartnerCardProps) {
|
||||
projectBudgetMinUsd={partner.projectBudgetMinUsd}
|
||||
/>
|
||||
|
||||
{calendarSafe && (
|
||||
<CtaWrapper>
|
||||
<LinkButton
|
||||
<CtaWrapper>
|
||||
<ProfileCtaLink
|
||||
data-color="secondary"
|
||||
data-size="regular"
|
||||
data-variant="contained"
|
||||
href={`/${locale}/partners/profile/${partner.slug}`}
|
||||
>
|
||||
<BaseButton
|
||||
color="secondary"
|
||||
href={partner.calendarLink}
|
||||
label={i18n._(msg`Book a call`)}
|
||||
label={i18n._(msg`View profile`)}
|
||||
variant="contained"
|
||||
/>
|
||||
</CtaWrapper>
|
||||
)}
|
||||
</ProfileCtaLink>
|
||||
</CtaWrapper>
|
||||
</CardArticle>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-1
@@ -15,7 +15,11 @@ const Row = styled.dl`
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: center;
|
||||
// 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: ${theme.spacing(4)};
|
||||
}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { PartnerProfileCtas } from '../components/PartnerProfileCtas';
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.load(SOURCE_LOCALE, {});
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
});
|
||||
|
||||
const render = (props: {
|
||||
partnerName: string;
|
||||
calendarLink: string;
|
||||
linkedinUrl: string;
|
||||
}) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PartnerProfileCtas
|
||||
partnerName={props.partnerName}
|
||||
calendarLink={props.calendarLink}
|
||||
linkedinUrl={props.linkedinUrl}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe('PartnerProfileCtas', () => {
|
||||
it('shows Book a call (and no Contact) when a calendar link is present', () => {
|
||||
const html = render({
|
||||
partnerName: 'Test Partner',
|
||||
calendarLink: 'https://calendly.com/test-partner',
|
||||
linkedinUrl: '',
|
||||
});
|
||||
expect(html).toContain('Book a call');
|
||||
expect(html).not.toContain('Contact Test Partner');
|
||||
});
|
||||
|
||||
it('keeps the calendar CTA over Contact even when LinkedIn is present', () => {
|
||||
const html = render({
|
||||
partnerName: 'Test Partner',
|
||||
calendarLink: 'https://calendly.com/test-partner',
|
||||
linkedinUrl: 'https://www.linkedin.com/in/test',
|
||||
});
|
||||
expect(html).toContain('Book a call');
|
||||
expect(html).toContain('View on LinkedIn');
|
||||
expect(html).not.toContain('Contact Test Partner');
|
||||
});
|
||||
|
||||
it('shows Contact alongside LinkedIn when there is no booking link', () => {
|
||||
const html = render({
|
||||
partnerName: 'Test Partner',
|
||||
calendarLink: '',
|
||||
linkedinUrl: 'https://www.linkedin.com/in/test',
|
||||
});
|
||||
expect(html).toContain('View on LinkedIn');
|
||||
expect(html).toContain('Contact Test Partner');
|
||||
expect(html).not.toContain('Book a call');
|
||||
});
|
||||
|
||||
it('falls back to a Contact mailto when there is no booking link', () => {
|
||||
const html = render({
|
||||
partnerName: 'Test Partner',
|
||||
calendarLink: '',
|
||||
linkedinUrl: '',
|
||||
});
|
||||
// Label names the partner, not a generic "partner".
|
||||
expect(html).toContain('Contact Test Partner');
|
||||
expect(html).toContain('mailto:rashad@twenty.com');
|
||||
// Subject references the partner; body carries the pre-filled prompt.
|
||||
// (Apostrophes are HTML-attribute-escaped in the rendered href, so assert
|
||||
// on apostrophe-free fragments of the URL-encoded text.)
|
||||
expect(html).toContain(
|
||||
encodeURIComponent('Interested in meeting Test Partner'),
|
||||
);
|
||||
expect(html).toContain(encodeURIComponent('interested in meeting. '));
|
||||
expect(html).toContain(encodeURIComponent('my project:'));
|
||||
// Two trailing blank lines leave room to paste the project.
|
||||
expect(html).toContain('%0A%0A');
|
||||
});
|
||||
|
||||
it('treats unsafe calendar/linkedin values as missing', () => {
|
||||
const html = render({
|
||||
partnerName: 'Test Partner',
|
||||
calendarLink: 'javascript:alert(1)',
|
||||
linkedinUrl: 'not-a-url',
|
||||
});
|
||||
expect(html).toContain('Contact Test Partner');
|
||||
expect(html).not.toContain('Book a call');
|
||||
expect(html).not.toContain('View on LinkedIn');
|
||||
});
|
||||
});
|
||||
+41
-1
@@ -4,9 +4,17 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import {
|
||||
BaseButton,
|
||||
buttonBaseStyles,
|
||||
} from '@/design-system/components/Button/BaseButton';
|
||||
import { LinkButton } from '@/design-system/components';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
// Where "Contact partner" enquiries are routed when a partner has no direct
|
||||
// channel (calendar / LinkedIn) of their own.
|
||||
const CONTACT_EMAIL = 'rashad@twenty.com';
|
||||
|
||||
const CtasWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -30,6 +38,13 @@ const ButtonRow = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
// mailto: opens the visitor's mail client in place, so (unlike the external
|
||||
// LinkButton) it must not force target="_blank", which would leave an orphan
|
||||
// blank tab. Reuse the shared button styling via buttonBaseStyles + BaseButton.
|
||||
const MailtoButton = styled.a`
|
||||
${buttonBaseStyles}
|
||||
`;
|
||||
|
||||
const isSafeHttpUrl = (raw: string) => {
|
||||
try {
|
||||
return ['https:', 'http:'].includes(new URL(raw).protocol);
|
||||
@@ -39,19 +54,30 @@ const isSafeHttpUrl = (raw: string) => {
|
||||
};
|
||||
|
||||
type PartnerProfileCtasProps = {
|
||||
partnerName: string;
|
||||
calendarLink: string;
|
||||
linkedinUrl: string;
|
||||
};
|
||||
|
||||
export function PartnerProfileCtas({
|
||||
partnerName,
|
||||
calendarLink,
|
||||
linkedinUrl,
|
||||
}: PartnerProfileCtasProps) {
|
||||
const { i18n } = useLingui();
|
||||
const showCalendar = isSafeHttpUrl(calendarLink);
|
||||
const showLinkedin = isSafeHttpUrl(linkedinUrl);
|
||||
// No booking link → offer a direct "Contact <partner>" email instead.
|
||||
// LinkedIn, when present, still shows alongside either option.
|
||||
const showContactFallback = !showCalendar;
|
||||
|
||||
if (!showCalendar && !showLinkedin) return null;
|
||||
// Pre-filled enquiry so the visitor can send with one tap. The two trailing
|
||||
// blank lines leave room to paste their project under the prompt.
|
||||
const subject = i18n._(msg`Interested in meeting ${partnerName}`);
|
||||
const body = `${i18n._(msg`Hey, I'm interested in meeting. Here's my project:`)}\n\n`;
|
||||
const mailtoHref = `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(
|
||||
subject,
|
||||
)}&body=${encodeURIComponent(body)}`;
|
||||
|
||||
return (
|
||||
<CtasWrapper>
|
||||
@@ -73,6 +99,20 @@ export function PartnerProfileCtas({
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{showContactFallback && (
|
||||
<MailtoButton
|
||||
data-color="secondary"
|
||||
data-size="regular"
|
||||
data-variant="contained"
|
||||
href={mailtoHref}
|
||||
>
|
||||
<BaseButton
|
||||
color="secondary"
|
||||
label={i18n._(msg`Contact ${partnerName}`)}
|
||||
variant="contained"
|
||||
/>
|
||||
</MailtoButton>
|
||||
)}
|
||||
</ButtonRow>
|
||||
</CtasWrapper>
|
||||
);
|
||||
|
||||
@@ -272,6 +272,7 @@ export default async function PartnerProfilePage({
|
||||
profilePictureUrl={partner.profilePictureUrl}
|
||||
/>
|
||||
<PartnerProfileCtas
|
||||
partnerName={partner.name}
|
||||
calendarLink={partner.calendarLink}
|
||||
linkedinUrl={partner.linkedinUrl}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user