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