From 6b5a956c851455afc22cbe62fac8e4df5cc29996 Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:22:41 +0400 Subject: [PATCH] feat(website): partner marketplace UI fixes & CTA improvements (#21163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 " CTA** → when a partner has no booking (calendar) link, show a "Contact " 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 Screenshot 2026-06-02 at 23 40 55 Screenshot 2026-06-02 at 23 40 47 Screenshot 2026-06-02 at 23 40 18 --- .../PartnerApplication/PartnerHeroCtas.tsx | 9 +- .../PartnerApplication/PartnerSignoffCtas.tsx | 9 +- .../list/__tests__/PartnerAvatar.test.tsx | 40 ++++++++ .../list/__tests__/PartnerCard.test.tsx | 41 ++++---- .../list/components/PartnerAvatar.tsx | 31 ++++++- .../partners/list/components/PartnerCard.tsx | 37 ++++++-- .../list/components/PartnerChipRow.tsx | 6 +- .../__tests__/PartnerProfileCtas.test.tsx | 93 +++++++++++++++++++ .../[slug]/components/PartnerProfileCtas.tsx | 42 ++++++++- .../[locale]/partners/profile/[slug]/page.tsx | 1 + 10 files changed, 268 insertions(+), 41 deletions(-) create mode 100644 packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerAvatar.test.tsx create mode 100644 packages/twenty-website/src/app/[locale]/partners/profile/[slug]/__tests__/PartnerProfileCtas.test.tsx diff --git a/packages/twenty-website/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx b/packages/twenty-website/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx index 93906abdd9..540f9be0dc 100644 --- a/packages/twenty-website/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx +++ b/packages/twenty-website/src/app/[locale]/partners/components/PartnerApplication/PartnerHeroCtas.tsx @@ -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 ( <> - diff --git a/packages/twenty-website/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx b/packages/twenty-website/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx index 29f45d6913..7287c76576 100644 --- a/packages/twenty-website/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx +++ b/packages/twenty-website/src/app/[locale]/partners/components/PartnerApplication/PartnerSignoffCtas.tsx @@ -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 ( <> - diff --git a/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerAvatar.test.tsx b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerAvatar.test.tsx new file mode 100644 index 0000000000..e2adeffb0d --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/list/__tests__/PartnerAvatar.test.tsx @@ -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( + , + ); + expect(html).toContain(' { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain(''])( + 'falls back to initials for an unsafe or empty URL (%s)', + (url) => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain(' { 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,', - 'vbscript:msgbox(1)', - '', - 'not-a-url', - ])('suppresses the CTA when calendarLink is %s', (unsafeLink) => { - const html = renderToStaticMarkup( - - - , - ); - 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( + + + , + ); + expect(html).toContain('View profile'); + expect(html).toContain(`href="/en/partners/profile/${FIXTURE.slug}"`); + }, + ); }); diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx index 733120c339..bb3e1df522 100644 --- a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerAvatar.tsx @@ -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 ; + } + const backgroundColor = pickPaletteColor(slug); const initials = pickInitials(name); diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx index 4541c635a7..aa3065467c 100644 --- a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerCard.tsx @@ -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 ( - + @@ -256,16 +269,20 @@ export function PartnerCard({ partner, index, locale }: PartnerCardProps) { projectBudgetMinUsd={partner.projectBudgetMinUsd} /> - {calendarSafe && ( - - + + - - )} + + ); } diff --git a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx index 1b0ea894c8..27baf92bcc 100644 --- a/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx +++ b/packages/twenty-website/src/app/[locale]/partners/list/components/PartnerChipRow.tsx @@ -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)}; } diff --git a/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/__tests__/PartnerProfileCtas.test.tsx b/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/__tests__/PartnerProfileCtas.test.tsx new file mode 100644 index 0000000000..81532e01c8 --- /dev/null +++ b/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/__tests__/PartnerProfileCtas.test.tsx @@ -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( + + + , + ); + +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'); + }); +}); diff --git a/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerProfileCtas.tsx b/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerProfileCtas.tsx index 047775248a..333d650b9e 100644 --- a/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerProfileCtas.tsx +++ b/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/components/PartnerProfileCtas.tsx @@ -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 " 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 ( @@ -73,6 +99,20 @@ export function PartnerProfileCtas({ variant="outlined" /> )} + {showContactFallback && ( + + + + )} ); diff --git a/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/page.tsx b/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/page.tsx index a782a22865..176c363fcd 100644 --- a/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/page.tsx +++ b/packages/twenty-website/src/app/[locale]/partners/profile/[slug]/page.tsx @@ -272,6 +272,7 @@ export default async function PartnerProfilePage({ profilePictureUrl={partner.profilePictureUrl} />