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} />