Add Enterprise plan and competitor pricing comparison pages (#23588)
Reworks the pricing surface in one PR: a new Enterprise tier on the
pricing page, five competitor pricing comparison pages, and the pricing
page section that links to them.
## Enterprise tier (pricing page)
- Third plan on both the cloud and self-hosting views: "from $50k /year"
with a "Talk to sales" CTA opening the existing contact modal.
- Cloud bullets: single-tenant isolation, IP allow-listing, SCIM
provisioning, dedicated support & SLA. Self-host: SCIM, air-gapped
deployment, LTS releases.
- Comparison table gains an Enterprise column (inherits Organization
values unless a row overrides) plus an Enterprise category of rows.
- New halftone building icon generated with the /halftone studio to
match the plan icon family.
- Pro and Organization pricing unchanged ($9/$19, in sync with Stripe).
## Competitor comparison pages
-
`/compare-pricing/{hubspot,salesforce,attio,pipedrive,microsoft-dynamics}`:
one shared template driven by a data file per vendor.
- Feature-by-feature cost table (competitor price + unlocking tier +
source link per claim, "checked on July 30, 2026" note, "spotted an
inaccuracy" link), a side-by-side bill for a 20-person team using the
PlanCard visual language with a savings badge cloned from the billing
toggle's -25% chip, and a one-line "fair play" note per vendor.
- Prices are 2026 list prices researched from vendors' public pricing
pages, billed annually. Routes registered in the website route registry
(indexed, sitemap, hreflang).
## Pricing page hub
- The salesfarce section intro becomes "Compare the real cost" with the
five comparison links using the footer's hover-marker link style. The
parody widget is unchanged.
Locale catalogs untouched for the i18n bot. Typecheck, lint, and the
website test suite pass.
Worth a second pair of eyes: the Pipedrive plan names/prices (2025
rebrand; their pricing page blocks fetchers) and the Salesforce "+30% of
spend" support/sandbox figures.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,31 @@
|
||||
import { getCommunityStats } from '@/platform/community';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/platform/i18n/get-route-i18n';
|
||||
import { buildRouteMetadata } from '@/platform/seo';
|
||||
import { ComparePage } from '@/sections/compare/ComparePage';
|
||||
import { COMPARISONS } from '@/sections/compare/compare-data';
|
||||
import { Menu } from '@/sections/menu';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('comparePricingHubspot');
|
||||
|
||||
export default async function CompareHubspotPricingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
}) {
|
||||
const [, communityStats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
getCommunityStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu communityStats={communityStats} scheme="muted" />
|
||||
<main>
|
||||
<ComparePage comparison={COMPARISONS.hubspot} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { getCommunityStats } from '@/platform/community';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/platform/i18n/get-route-i18n';
|
||||
import { buildRouteMetadata } from '@/platform/seo';
|
||||
import { ComparePage } from '@/sections/compare/ComparePage';
|
||||
import { COMPARISONS } from '@/sections/compare/compare-data';
|
||||
import { Menu } from '@/sections/menu';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('comparePricingDynamics');
|
||||
|
||||
export default async function CompareMicrosoftDynamicsPricingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
}) {
|
||||
const [, communityStats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
getCommunityStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu communityStats={communityStats} scheme="muted" />
|
||||
<main>
|
||||
<ComparePage comparison={COMPARISONS.dynamics} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getCommunityStats } from '@/platform/community';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/platform/i18n/get-route-i18n';
|
||||
import { buildRouteMetadata } from '@/platform/seo';
|
||||
import { ComparePage } from '@/sections/compare/ComparePage';
|
||||
import { COMPARISONS } from '@/sections/compare/compare-data';
|
||||
import { Menu } from '@/sections/menu';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('comparePricingPipedrive');
|
||||
|
||||
export default async function ComparePipedrivePricingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
}) {
|
||||
const [, communityStats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
getCommunityStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu communityStats={communityStats} scheme="muted" />
|
||||
<main>
|
||||
<ComparePage comparison={COMPARISONS.pipedrive} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getCommunityStats } from '@/platform/community';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/platform/i18n/get-route-i18n';
|
||||
import { buildRouteMetadata } from '@/platform/seo';
|
||||
import { ComparePage } from '@/sections/compare/ComparePage';
|
||||
import { COMPARISONS } from '@/sections/compare/compare-data';
|
||||
import { Menu } from '@/sections/menu';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('comparePricingSalesforce');
|
||||
|
||||
export default async function CompareSalesforcePricingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
}) {
|
||||
const [, communityStats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
getCommunityStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu communityStats={communityStats} scheme="muted" />
|
||||
<main>
|
||||
<ComparePage comparison={COMPARISONS.salesforce} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { getCommunityStats } from '@/platform/community';
|
||||
import {
|
||||
getRouteI18n,
|
||||
type LocaleRouteParams,
|
||||
} from '@/platform/i18n/get-route-i18n';
|
||||
import { buildRouteMetadata } from '@/platform/seo';
|
||||
import { ComparePage } from '@/sections/compare/ComparePage';
|
||||
import { COMPARISONS } from '@/sections/compare/compare-data';
|
||||
import { Menu } from '@/sections/menu';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('comparePricingSap');
|
||||
|
||||
export default async function CompareSapPricingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<LocaleRouteParams>;
|
||||
}) {
|
||||
const [, communityStats] = await Promise.all([
|
||||
getRouteI18n(params),
|
||||
getCommunityStats(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu communityStats={communityStats} scheme="muted" />
|
||||
<main>
|
||||
<ComparePage comparison={COMPARISONS.sap} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,51 @@ export const STATIC_WEBSITE_ROUTES: readonly WebsiteRoute[] = [
|
||||
priority: 0.9,
|
||||
title: msg`Twenty CRM Pricing — Plans from $9 per User per Month`,
|
||||
},
|
||||
{
|
||||
changeFrequency: 'monthly',
|
||||
description: msg`HubSpot gates custom objects behind its $150/seat Enterprise tier. See the full price comparison, with sources, and what Twenty includes from $9 per user.`,
|
||||
id: 'comparePricingHubspot',
|
||||
indexed: true,
|
||||
path: '/compare-pricing/hubspot',
|
||||
priority: 0.7,
|
||||
title: msg`Twenty vs HubSpot Pricing — The Real Cost Compared`,
|
||||
},
|
||||
{
|
||||
changeFrequency: 'monthly',
|
||||
description: msg`Sales Cloud Enterprise lists at $175/user/month before support, sandboxes, and AI add-ons. Compare the real cost with Twenty, from $9 per user.`,
|
||||
id: 'comparePricingSalesforce',
|
||||
indexed: true,
|
||||
path: '/compare-pricing/salesforce',
|
||||
priority: 0.7,
|
||||
title: msg`Twenty vs Salesforce Pricing — The Real Cost Compared`,
|
||||
},
|
||||
{
|
||||
changeFrequency: 'monthly',
|
||||
description: msg`SAP Sales Cloud lists at $134/user/month before a year-one implementation project that often exceeds the licenses. Compare with Twenty, from $9 per user.`,
|
||||
id: 'comparePricingSap',
|
||||
indexed: true,
|
||||
path: '/compare-pricing/sap-sales-cloud',
|
||||
priority: 0.7,
|
||||
title: msg`Twenty vs SAP Sales Cloud Pricing — The Real Cost Compared`,
|
||||
},
|
||||
{
|
||||
changeFrequency: 'monthly',
|
||||
description: msg`Pipedrive caps automations per company and keeps SSO for its top tier. Compare real per-seat costs with Twenty, from $9 per user with no caps.`,
|
||||
id: 'comparePricingPipedrive',
|
||||
indexed: true,
|
||||
path: '/compare-pricing/pipedrive',
|
||||
priority: 0.7,
|
||||
title: msg`Twenty vs Pipedrive Pricing — The Real Cost Compared`,
|
||||
},
|
||||
{
|
||||
changeFrequency: 'monthly',
|
||||
description: msg`Dynamics 365 Sales runs $105/user/month plus $40/GB database storage. See the full comparison with Twenty, from $9 per user, open source.`,
|
||||
id: 'comparePricingDynamics',
|
||||
indexed: true,
|
||||
path: '/compare-pricing/microsoft-dynamics',
|
||||
priority: 0.7,
|
||||
title: msg`Twenty vs Microsoft Dynamics Pricing — The Real Cost Compared`,
|
||||
},
|
||||
{
|
||||
changeFrequency: 'monthly',
|
||||
description: msg`Find a certified Twenty partner to migrate, customise, and operate your open source CRM, or join the ecosystem and grow your practice with us.`,
|
||||
|
||||
@@ -3,6 +3,11 @@ import { type MetadataRoute } from 'next';
|
||||
|
||||
export type WebsiteRouteId =
|
||||
| 'apps'
|
||||
| 'comparePricingDynamics'
|
||||
| 'comparePricingHubspot'
|
||||
| 'comparePricingPipedrive'
|
||||
| 'comparePricingSalesforce'
|
||||
| 'comparePricingSap'
|
||||
| 'customers'
|
||||
| 'enterpriseActivate'
|
||||
| 'halftone'
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { INFORMATIVE_MARKS } from '@/icons';
|
||||
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
|
||||
import {
|
||||
color,
|
||||
FONT_WEIGHT,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
mediaUp,
|
||||
semanticColor,
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
import { ExternalLink } from '@/ui';
|
||||
|
||||
import { type CompetitorComparison } from './compare-data';
|
||||
|
||||
const CheckMark = INFORMATIVE_MARKS.check;
|
||||
|
||||
const REPORT_INACCURACY_URL =
|
||||
'https://github.com/twentyhq/twenty/issues/new/choose';
|
||||
|
||||
// On narrow viewports the three-column grid scrolls horizontally instead of
|
||||
// squeezing the price cells.
|
||||
const TableScroller = styled.div`
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const TableScope = styled.div`
|
||||
color: ${semanticColor.ink};
|
||||
min-width: 640px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const GridRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr 1fr;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const HeadCell = styled.div`
|
||||
border-bottom: 1px solid ${semanticColor.line};
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(5.5)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
line-height: ${spacing(7)};
|
||||
padding: ${spacing(4)};
|
||||
|
||||
&[data-tier] {
|
||||
border-left: 1px solid ${semanticColor.line};
|
||||
}
|
||||
`;
|
||||
|
||||
const FeatureCell = styled.div`
|
||||
border-bottom: 1px solid ${semanticColor.line};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(1)};
|
||||
justify-content: center;
|
||||
min-height: ${spacing(18)};
|
||||
padding: ${spacing(4)};
|
||||
`;
|
||||
|
||||
const FeatureName = styled.span`
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(4)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
`;
|
||||
|
||||
const FeatureDescription = styled.span`
|
||||
color: ${semanticColor.inkMuted};
|
||||
display: none;
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(3.5)};
|
||||
|
||||
${mediaUp('md')} {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const ValueCell = styled.div`
|
||||
border-bottom: 1px solid ${semanticColor.line};
|
||||
border-left: 1px solid ${semanticColor.line};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(1)};
|
||||
justify-content: center;
|
||||
min-height: ${spacing(18)};
|
||||
padding: ${spacing(4)};
|
||||
`;
|
||||
|
||||
const CompetitorPrice = styled.span`
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(4)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
`;
|
||||
|
||||
const CompetitorDetail = styled.span`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(3.5)};
|
||||
`;
|
||||
|
||||
const SourceLink = styled(ExternalLink)`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(3)};
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
width: fit-content;
|
||||
|
||||
&:hover {
|
||||
color: ${semanticColor.ink};
|
||||
}
|
||||
`;
|
||||
|
||||
const IncludedRow = styled.div`
|
||||
align-items: center;
|
||||
column-gap: ${spacing(2.5)};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const IncludedText = styled.span`
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(4)};
|
||||
`;
|
||||
|
||||
const SourceNote = styled.p`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(3.5)};
|
||||
line-height: ${spacing(5.5)};
|
||||
margin-top: ${spacing(6)};
|
||||
max-width: 640px;
|
||||
`;
|
||||
|
||||
const ReportLink = styled(ExternalLink)`
|
||||
color: ${semanticColor.inkMuted};
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: ${semanticColor.ink};
|
||||
}
|
||||
`;
|
||||
|
||||
export function CompareCostTable({
|
||||
comparison,
|
||||
}: {
|
||||
comparison: CompetitorComparison;
|
||||
}) {
|
||||
const i18n = getServerI18n();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableScroller>
|
||||
<TableScope>
|
||||
<GridRow>
|
||||
<HeadCell>{i18n._(msg`Feature`)}</HeadCell>
|
||||
<HeadCell data-tier>
|
||||
{i18n._(comparison.competitorColumnLabel)}
|
||||
</HeadCell>
|
||||
<HeadCell data-tier>{i18n._(msg`On Twenty`)}</HeadCell>
|
||||
</GridRow>
|
||||
{comparison.rows.map((row) => (
|
||||
<GridRow key={i18n._(row.feature)}>
|
||||
<FeatureCell>
|
||||
<FeatureName>{i18n._(row.feature)}</FeatureName>
|
||||
<FeatureDescription>
|
||||
{i18n._(row.description)}
|
||||
</FeatureDescription>
|
||||
</FeatureCell>
|
||||
<ValueCell>
|
||||
<CompetitorPrice>
|
||||
{i18n._(row.competitor.price)}
|
||||
</CompetitorPrice>
|
||||
<CompetitorDetail>
|
||||
{i18n._(row.competitor.detail)}
|
||||
</CompetitorDetail>
|
||||
{row.competitor.sourceUrl !== undefined ? (
|
||||
<SourceLink
|
||||
href={row.competitor.sourceUrl}
|
||||
rel="nofollow noreferrer"
|
||||
>
|
||||
{i18n._(msg`source`)}
|
||||
</SourceLink>
|
||||
) : null}
|
||||
</ValueCell>
|
||||
<ValueCell>
|
||||
<IncludedRow>
|
||||
<CheckMark color={color('blue')} sizePx={16} />
|
||||
<IncludedText>{i18n._(row.twenty.detail)}</IncludedText>
|
||||
</IncludedRow>
|
||||
</ValueCell>
|
||||
</GridRow>
|
||||
))}
|
||||
</TableScope>
|
||||
</TableScroller>
|
||||
<SourceNote>
|
||||
{i18n._(comparison.sourceNote)}{' '}
|
||||
<ReportLink href={REPORT_INACCURACY_URL}>
|
||||
{i18n._(msg`Spotted an inaccuracy? Tell us and we will fix it.`)}
|
||||
</ReportLink>
|
||||
</SourceNote>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
|
||||
import { GRADIENT, mediaUp, spacing } from '@/tokens';
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
EngagementBand,
|
||||
Eyebrow,
|
||||
Heading,
|
||||
HeadingPair,
|
||||
SectionShell,
|
||||
} from '@/ui';
|
||||
|
||||
import { CompareCostTable } from './CompareCostTable';
|
||||
import { CompareReceipt } from './CompareReceipt';
|
||||
import { type CompetitorComparison } from './compare-data';
|
||||
|
||||
const GradientBackdrop = styled.div`
|
||||
background: ${GRADIENT.heroGlow};
|
||||
inset: 0 -20%;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const HeroStack = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const HeadingMeasure = styled.div`
|
||||
white-space: pre-line;
|
||||
|
||||
${mediaUp('md')} {
|
||||
max-width: 720px;
|
||||
}
|
||||
`;
|
||||
|
||||
const IntroMeasure = styled.div`
|
||||
white-space: pre-line;
|
||||
|
||||
${mediaUp('md')} {
|
||||
max-width: 560px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CtaRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${spacing(3)};
|
||||
justify-content: center;
|
||||
margin-top: ${spacing(8)};
|
||||
`;
|
||||
|
||||
const SectionHeading = styled.div`
|
||||
margin-bottom: ${spacing(8)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export function ComparePage({
|
||||
comparison,
|
||||
}: {
|
||||
comparison: CompetitorComparison;
|
||||
}) {
|
||||
const i18n = getServerI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionShell
|
||||
background={<GradientBackdrop />}
|
||||
rhythm="hero"
|
||||
scheme="muted"
|
||||
>
|
||||
<HeroStack>
|
||||
<HeadingPair>
|
||||
<Eyebrow>{i18n._(comparison.eyebrow)}</Eyebrow>
|
||||
<HeadingMeasure>
|
||||
<Heading as="h1" size="lg" weight="light">
|
||||
{i18n._(comparison.heading)}
|
||||
</Heading>
|
||||
</HeadingMeasure>
|
||||
<IntroMeasure>
|
||||
<Body muted size="sm">
|
||||
{i18n._(comparison.intro)}
|
||||
</Body>
|
||||
</IntroMeasure>
|
||||
</HeadingPair>
|
||||
<CtaRow>
|
||||
<Button
|
||||
href="https://app.twenty.com/welcome"
|
||||
label={i18n._(msg`Start for free`)}
|
||||
variant="filled"
|
||||
/>
|
||||
<Button
|
||||
href="/pricing"
|
||||
label={i18n._(msg`See Twenty pricing`)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</CtaRow>
|
||||
</HeroStack>
|
||||
</SectionShell>
|
||||
|
||||
<SectionShell rhythm="section" scheme="dark">
|
||||
<SectionHeading>
|
||||
<Heading as="h2" size="sm" weight="light">
|
||||
{i18n._(comparison.tableTitle)}
|
||||
</Heading>
|
||||
</SectionHeading>
|
||||
<CompareCostTable comparison={comparison} />
|
||||
</SectionShell>
|
||||
|
||||
<SectionShell rhythm="section" scheme="muted">
|
||||
<SectionHeading>
|
||||
<Heading as="h2" size="sm" weight="light">
|
||||
{i18n._(msg`The bill, side by side`)}
|
||||
</Heading>
|
||||
</SectionHeading>
|
||||
<CompareReceipt comparison={comparison} />
|
||||
</SectionShell>
|
||||
|
||||
<EngagementBand
|
||||
actions={
|
||||
<Button
|
||||
href="https://app.twenty.com/welcome"
|
||||
label={i18n._(msg`Get started`)}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
body={i18n._(comparison.migrationLine)}
|
||||
heading={i18n._(msg`Run the comparison on your own data`)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
|
||||
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
|
||||
import {
|
||||
color,
|
||||
FONT_WEIGHT,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
mediaUp,
|
||||
radius,
|
||||
semanticColor,
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
import { Body, Button, Eyebrow, Heading } from '@/ui';
|
||||
|
||||
import {
|
||||
type CompareReceiptLine,
|
||||
type CompetitorComparison,
|
||||
} from './compare-data';
|
||||
|
||||
const TWENTY_CARD_ICON = {
|
||||
alt: 'Twenty Organization plan icon',
|
||||
src: '/images/pricing/plans/organization-icon.webp',
|
||||
widthPx: 80,
|
||||
};
|
||||
|
||||
const ReceiptScope = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(6)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Scenario = styled.p`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(4)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const CardsGrid = styled.div`
|
||||
display: grid;
|
||||
gap: ${spacing(4)};
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
|
||||
${mediaUp('md')} {
|
||||
gap: ${spacing(6)};
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
// Mirrors the pricing page's PlanCard shell so both pages read as one
|
||||
// component family.
|
||||
const CardShell = styled.div`
|
||||
background-color: ${color('white')};
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${radius(2)};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: ${spacing(4)};
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
& > * + * {
|
||||
margin-top: ${spacing(4)};
|
||||
}
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: ${spacing(3)};
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CardHeaderInfo = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
& > * + * {
|
||||
margin-top: ${spacing(4)};
|
||||
}
|
||||
`;
|
||||
|
||||
const titleClassName = css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const PriceLine = styled.div`
|
||||
align-items: baseline;
|
||||
column-gap: ${spacing(1)};
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const priceSuffixClassName = css`
|
||||
color: ${color('black-60')};
|
||||
display: block;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
// Clones the billing toggle's discount badge so the savings chip is the
|
||||
// same component language as the pricing page's "-25%".
|
||||
const SavingsBadge = styled.span`
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
background-color: ${color('blue')};
|
||||
border-radius: ${radius(12)};
|
||||
color: ${color('white')};
|
||||
display: inline-flex;
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(2.5)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
margin-left: ${spacing(1)};
|
||||
padding-inline: ${spacing(1.5)};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const CardRule = styled.div`
|
||||
border-top: 1px dotted ${semanticColor.divider};
|
||||
flex-shrink: 0;
|
||||
height: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CardIcon = styled.div`
|
||||
border-radius: ${radius(2)};
|
||||
flex-shrink: 0;
|
||||
height: 80px;
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: ${TWENTY_CARD_ICON.widthPx}px;
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
object-position: center right;
|
||||
}
|
||||
`;
|
||||
|
||||
const LinesList = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(3)};
|
||||
`;
|
||||
|
||||
const LineRow = styled.div`
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(4)};
|
||||
gap: ${spacing(3)};
|
||||
justify-content: space-between;
|
||||
line-height: ${spacing(5.5)};
|
||||
`;
|
||||
|
||||
const LineLabel = styled.span`
|
||||
color: ${semanticColor.inkMuted};
|
||||
`;
|
||||
|
||||
const LineAmount = styled.span`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const PerUserLine = styled.p`
|
||||
color: ${semanticColor.inkMuted};
|
||||
font-family: ${fontFamily('sans')};
|
||||
font-size: ${fontSize(3.5)};
|
||||
`;
|
||||
|
||||
const CtaRow = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
padding-top: ${spacing(4)};
|
||||
width: 100%;
|
||||
|
||||
> * {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const FairPlayBlock = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(3)};
|
||||
margin-inline: auto;
|
||||
margin-top: ${spacing(6)};
|
||||
max-width: 560px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
function ReceiptLines({ lines }: { lines: CompareReceiptLine[] }) {
|
||||
const i18n = getServerI18n();
|
||||
|
||||
return (
|
||||
<LinesList>
|
||||
{lines.map((line) => (
|
||||
<LineRow key={i18n._(line.label)}>
|
||||
<LineLabel>{i18n._(line.label)}</LineLabel>
|
||||
<LineAmount>{i18n._(line.amount)}</LineAmount>
|
||||
</LineRow>
|
||||
))}
|
||||
</LinesList>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareReceipt({
|
||||
comparison,
|
||||
}: {
|
||||
comparison: CompetitorComparison;
|
||||
}) {
|
||||
const i18n = getServerI18n();
|
||||
const { receipt } = comparison;
|
||||
|
||||
return (
|
||||
<ReceiptScope>
|
||||
<Scenario>{i18n._(receipt.scenario)}</Scenario>
|
||||
<CardsGrid>
|
||||
<CardShell>
|
||||
<CardHeader>
|
||||
<CardHeaderInfo>
|
||||
<Heading
|
||||
as="h3"
|
||||
className={titleClassName}
|
||||
family="sans"
|
||||
size="xs"
|
||||
weight="light"
|
||||
>
|
||||
{comparison.competitor}
|
||||
</Heading>
|
||||
<PriceLine>
|
||||
<Heading as="h4" family="sans" size="sm" weight="regular">
|
||||
{i18n._(receipt.competitorTotalAmount)}
|
||||
</Heading>
|
||||
<Body as="span" className={priceSuffixClassName} size="sm">
|
||||
{i18n._(receipt.competitorTotalSuffix)}
|
||||
</Body>
|
||||
</PriceLine>
|
||||
</CardHeaderInfo>
|
||||
</CardHeader>
|
||||
<CardRule />
|
||||
<ReceiptLines lines={receipt.competitorLines} />
|
||||
<PerUserLine>{i18n._(receipt.competitorPerUser)}</PerUserLine>
|
||||
</CardShell>
|
||||
|
||||
<CardShell>
|
||||
<CardHeader>
|
||||
<CardHeaderInfo>
|
||||
<Heading
|
||||
as="h3"
|
||||
className={titleClassName}
|
||||
family="sans"
|
||||
size="xs"
|
||||
weight="light"
|
||||
>
|
||||
Twenty
|
||||
</Heading>
|
||||
<PriceLine>
|
||||
<Heading as="h4" family="sans" size="sm" weight="regular">
|
||||
{i18n._(receipt.twentyTotalAmount)}
|
||||
</Heading>
|
||||
<Body as="span" className={priceSuffixClassName} size="sm">
|
||||
{i18n._(receipt.twentyTotalSuffix)}
|
||||
</Body>
|
||||
<SavingsBadge>{i18n._(receipt.multiplier)}</SavingsBadge>
|
||||
</PriceLine>
|
||||
</CardHeaderInfo>
|
||||
<CardIcon>
|
||||
<NextImage
|
||||
alt={TWENTY_CARD_ICON.alt}
|
||||
fill
|
||||
sizes={`${TWENTY_CARD_ICON.widthPx}px`}
|
||||
src={TWENTY_CARD_ICON.src}
|
||||
/>
|
||||
</CardIcon>
|
||||
</CardHeader>
|
||||
<CardRule />
|
||||
<ReceiptLines lines={receipt.twentyLines} />
|
||||
<PerUserLine>{i18n._(receipt.twentyPerUser)}</PerUserLine>
|
||||
<CtaRow>
|
||||
<Button
|
||||
href="https://app.twenty.com/welcome"
|
||||
label={i18n._(msg`Start for free`)}
|
||||
variant="filled"
|
||||
/>
|
||||
</CtaRow>
|
||||
</CardShell>
|
||||
</CardsGrid>
|
||||
<FairPlayBlock>
|
||||
<Eyebrow>{i18n._(msg`Fair play`)}</Eyebrow>
|
||||
<Body muted size="sm">
|
||||
{i18n._(comparison.honest)}
|
||||
</Body>
|
||||
</FairPlayBlock>
|
||||
</ReceiptScope>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
// Competitor list prices, checked against each vendor's public pricing page
|
||||
// on 2026-07-30 (annual billing unless stated). Keep every price cell paired
|
||||
// with its source URL; re-verify before changing any number.
|
||||
|
||||
export type CompareCompetitorCell = {
|
||||
detail: MessageDescriptor;
|
||||
price: MessageDescriptor;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
|
||||
export type CompareRow = {
|
||||
competitor: CompareCompetitorCell;
|
||||
description: MessageDescriptor;
|
||||
feature: MessageDescriptor;
|
||||
twenty: { detail: MessageDescriptor };
|
||||
};
|
||||
|
||||
export type CompareReceiptLine = {
|
||||
amount: MessageDescriptor;
|
||||
label: MessageDescriptor;
|
||||
};
|
||||
|
||||
export type CompetitorComparison = {
|
||||
competitor: string;
|
||||
competitorColumnLabel: MessageDescriptor;
|
||||
eyebrow: MessageDescriptor;
|
||||
heading: MessageDescriptor;
|
||||
honest: MessageDescriptor;
|
||||
intro: MessageDescriptor;
|
||||
migrationLine: MessageDescriptor;
|
||||
receipt: {
|
||||
competitorLines: CompareReceiptLine[];
|
||||
competitorPerUser: MessageDescriptor;
|
||||
competitorTotalAmount: MessageDescriptor;
|
||||
competitorTotalSuffix: MessageDescriptor;
|
||||
multiplier: MessageDescriptor;
|
||||
scenario: MessageDescriptor;
|
||||
twentyLines: CompareReceiptLine[];
|
||||
twentyPerUser: MessageDescriptor;
|
||||
twentyTotalAmount: MessageDescriptor;
|
||||
twentyTotalSuffix: MessageDescriptor;
|
||||
};
|
||||
rows: CompareRow[];
|
||||
slug: string;
|
||||
sourceNote: MessageDescriptor;
|
||||
tableTitle: MessageDescriptor;
|
||||
};
|
||||
|
||||
const HUBSPOT_PRICING_URL = 'https://www.hubspot.com/pricing/sales';
|
||||
const SALESFORCE_PRICING_URL = 'https://www.salesforce.com/sales/pricing/';
|
||||
// const ATTIO_PRICING_URL = 'https://attio.com/pricing';
|
||||
const PIPEDRIVE_PRICING_URL = 'https://www.pipedrive.com/en/pricing';
|
||||
const DYNAMICS_PRICING_URL =
|
||||
'https://www.microsoft.com/en-us/dynamics-365/products/sales/pricing';
|
||||
|
||||
const HUBSPOT_COMPARISON: CompetitorComparison = {
|
||||
competitor: 'HubSpot',
|
||||
competitorColumnLabel: msg`On HubSpot`,
|
||||
eyebrow: msg`Twenty vs HubSpot`,
|
||||
heading: msg`What does\n*HubSpot* really cost?`,
|
||||
intro: msg`A feature-by-feature price check, with sources.\nMost of what HubSpot gates behind tiers is simply included in Twenty.`,
|
||||
migrationLine: msg`Import your data and see your own numbers. Most teams migrate from HubSpot in under a day.`,
|
||||
slug: 'hubspot',
|
||||
tableTitle: msg`What costs extra over there`,
|
||||
rows: [
|
||||
{
|
||||
feature: msg`Custom objects`,
|
||||
description: msg`Model deals, assets, or anything else`,
|
||||
competitor: {
|
||||
price: msg`$150/seat/mo`,
|
||||
detail: msg`Sales Hub Enterprise only`,
|
||||
sourceUrl: HUBSPOT_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Included from Pro, $9/user` },
|
||||
},
|
||||
{
|
||||
feature: msg`Full workflow automation`,
|
||||
description: msg`Trigger actions on record changes`,
|
||||
competitor: {
|
||||
price: msg`$100/seat/mo`,
|
||||
detail: msg`Professional tier and up`,
|
||||
sourceUrl: HUBSPOT_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Included from Pro` },
|
||||
},
|
||||
{
|
||||
feature: msg`Teams & permission sets`,
|
||||
description: msg`Control who sees and edits what`,
|
||||
competitor: {
|
||||
price: msg`$100/seat/mo`,
|
||||
detail: msg`Professional tier and up`,
|
||||
sourceUrl: HUBSPOT_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Roles from Pro, row-level on Organization` },
|
||||
},
|
||||
{
|
||||
feature: msg`SAML SSO`,
|
||||
description: msg`Single sign-on for your team`,
|
||||
competitor: {
|
||||
price: msg`$150/seat/mo`,
|
||||
detail: msg`Enterprise only`,
|
||||
sourceUrl: HUBSPOT_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Organization, $19/user` },
|
||||
},
|
||||
{
|
||||
feature: msg`Sandboxes`,
|
||||
description: msg`Test changes before production`,
|
||||
competitor: {
|
||||
price: msg`$150/seat/mo`,
|
||||
detail: msg`Enterprise only`,
|
||||
sourceUrl: HUBSPOT_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Local environment included` },
|
||||
},
|
||||
{
|
||||
feature: msg`Getting started`,
|
||||
description: msg`Required onboarding on paid tiers`,
|
||||
competitor: {
|
||||
price: msg`$1,500 to $3,500`,
|
||||
detail: msg`One-time mandatory onboarding fee`,
|
||||
sourceUrl: HUBSPOT_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`None, start in minutes` },
|
||||
},
|
||||
],
|
||||
receipt: {
|
||||
scenario: msg`A 20-person sales team, billed annually`,
|
||||
competitorLines: [
|
||||
{
|
||||
label: msg`20 × Sales Hub Enterprise ($150)`,
|
||||
amount: msg`$36,000/yr`,
|
||||
},
|
||||
{ label: msg`Mandatory onboarding (one-time)`, amount: msg`$3,500` },
|
||||
],
|
||||
competitorTotalAmount: msg`$39,500`,
|
||||
competitorTotalSuffix: msg`in year one`,
|
||||
competitorPerUser: msg`≈ $165 per user per month in year one`,
|
||||
twentyLines: [
|
||||
{ label: msg`20 × Organization ($19)`, amount: msg`$4,560/yr` },
|
||||
{ label: msg`Onboarding`, amount: msg`$0` },
|
||||
],
|
||||
twentyTotalAmount: msg`$4,560`,
|
||||
twentyTotalSuffix: msg`in year one`,
|
||||
twentyPerUser: msg`$19 per user per month, that's it`,
|
||||
multiplier: msg`≈ 8× less`,
|
||||
},
|
||||
honest: msg`HubSpot brings a full marketing suite and a huge agency ecosystem. If you need marketing automation more than a CRM, it is a fair choice.`,
|
||||
sourceNote: msg`List prices from HubSpot's public pricing page, billed annually, checked on July 30, 2026.`,
|
||||
};
|
||||
|
||||
const SALESFORCE_COMPARISON: CompetitorComparison = {
|
||||
competitor: 'Salesforce',
|
||||
competitorColumnLabel: msg`On Salesforce`,
|
||||
eyebrow: msg`Twenty vs Salesforce`,
|
||||
heading: msg`What does\n*Salesforce* really cost?`,
|
||||
intro: msg`The list price is only the beginning.\nHere is what the invoice looks like once a real team is on it.`,
|
||||
migrationLine: msg`Import your data and see your own numbers. Twenty's importer handles Salesforce exports out of the box.`,
|
||||
slug: 'salesforce',
|
||||
tableTitle: msg`The line items add up`,
|
||||
rows: [
|
||||
{
|
||||
feature: msg`The CRM itself`,
|
||||
description: msg`Sales Cloud, per user`,
|
||||
competitor: {
|
||||
price: msg`$175/user/mo`,
|
||||
detail: msg`Enterprise edition`,
|
||||
sourceUrl: SALESFORCE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`$9 to $19/user, everything included` },
|
||||
},
|
||||
{
|
||||
feature: msg`The AI edition`,
|
||||
description: msg`Agents and Data Cloud bundled`,
|
||||
competitor: {
|
||||
price: msg`$500/user/mo`,
|
||||
detail: msg`Agentforce 1 Sales edition`,
|
||||
sourceUrl: SALESFORCE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`AI agents included, usage credits` },
|
||||
},
|
||||
{
|
||||
feature: msg`Agent conversations`,
|
||||
description: msg`When customers talk to your AI`,
|
||||
competitor: {
|
||||
price: msg`$2/conversation`,
|
||||
detail: msg`Agentforce list price`,
|
||||
sourceUrl: SALESFORCE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Credit-based, no per-chat fee` },
|
||||
},
|
||||
{
|
||||
feature: msg`Premier support`,
|
||||
description: msg`Faster answers when it matters`,
|
||||
competitor: {
|
||||
price: msg`+30% of spend`,
|
||||
detail: msg`Success plan uplift`,
|
||||
sourceUrl: SALESFORCE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Priority support on Organization` },
|
||||
},
|
||||
{
|
||||
feature: msg`Full sandbox`,
|
||||
description: msg`A real staging environment`,
|
||||
competitor: {
|
||||
price: msg`+30% of spend`,
|
||||
detail: msg`Add-on, per environment`,
|
||||
sourceUrl: SALESFORCE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Local environment included` },
|
||||
},
|
||||
{
|
||||
feature: msg`Implementation`,
|
||||
description: msg`Getting to a working CRM`,
|
||||
competitor: {
|
||||
price: msg`$10k to $100k+`,
|
||||
detail: msg`Typical partner project`,
|
||||
},
|
||||
twenty: { detail: msg`Self-serve, partners optional` },
|
||||
},
|
||||
],
|
||||
receipt: {
|
||||
scenario: msg`A 20-person sales team, billed annually`,
|
||||
competitorLines: [
|
||||
{
|
||||
label: msg`20 × Sales Cloud Enterprise ($175)`,
|
||||
amount: msg`$42,000/yr`,
|
||||
},
|
||||
{ label: msg`Premier support (+30%)`, amount: msg`$12,600/yr` },
|
||||
],
|
||||
competitorTotalAmount: msg`$54,600`,
|
||||
competitorTotalSuffix: msg`per year`,
|
||||
competitorPerUser: msg`≈ $228 per user per month before add-ons`,
|
||||
twentyLines: [
|
||||
{ label: msg`20 × Organization ($19)`, amount: msg`$4,560/yr` },
|
||||
{ label: msg`Support`, amount: msg`Included` },
|
||||
],
|
||||
twentyTotalAmount: msg`$4,560`,
|
||||
twentyTotalSuffix: msg`per year`,
|
||||
twentyPerUser: msg`$19 per user per month, that's it`,
|
||||
multiplier: msg`≈ 12× less`,
|
||||
},
|
||||
honest: msg`Salesforce remains the deepest enterprise platform, with an ecosystem for almost everything. At a thousand seats with a dedicated admin team, it is a different conversation.`,
|
||||
sourceNote: msg`List prices from Salesforce's public pricing page, billed annually, checked on July 30, 2026.`,
|
||||
};
|
||||
|
||||
// The Attio comparison is parked for now (may return later). The data is
|
||||
// kept commented so it only needs re-verification to come back.
|
||||
// export const ATTIO_COMPARISON: CompetitorComparison = {
|
||||
// competitor: 'Attio',
|
||||
// competitorColumnLabel: msg`On Attio`,
|
||||
// eyebrow: msg`Twenty vs Attio`,
|
||||
// heading: msg`What does\n*Attio* really cost?`,
|
||||
// intro: msg`Both are modern CRMs. One is open source.\nHere is the price of the difference.`,
|
||||
// migrationLine: msg`Import your data and see your own numbers. Moving a workspace from Attio usually takes an afternoon.`,
|
||||
// slug: 'attio',
|
||||
// tableTitle: msg`The same features, priced differently`,
|
||||
// rows: [
|
||||
// {
|
||||
// feature: msg`The modern CRM`,
|
||||
// description: msg`Objects, pipelines, email sync`,
|
||||
// competitor: {
|
||||
// price: msg`$35/user/mo`,
|
||||
// detail: msg`Plus plan, billed annually`,
|
||||
// sourceUrl: ATTIO_PRICING_URL,
|
||||
// },
|
||||
// twenty: { detail: msg`$9/user on Pro` },
|
||||
// },
|
||||
// {
|
||||
// feature: msg`The full platform`,
|
||||
// description: msg`Advanced reporting and workflows`,
|
||||
// competitor: {
|
||||
// price: msg`$79/user/mo`,
|
||||
// detail: msg`Pro plan, billed annually`,
|
||||
// sourceUrl: ATTIO_PRICING_URL,
|
||||
// },
|
||||
// twenty: { detail: msg`Included from Pro, $9/user` },
|
||||
// },
|
||||
// {
|
||||
// feature: msg`SAML SSO & advanced admin`,
|
||||
// description: msg`Single sign-on, access control`,
|
||||
// competitor: {
|
||||
// price: msg`Custom pricing`,
|
||||
// detail: msg`Enterprise plan only`,
|
||||
// sourceUrl: ATTIO_PRICING_URL,
|
||||
// },
|
||||
// twenty: { detail: msg`Organization, $19/user` },
|
||||
// },
|
||||
// {
|
||||
// feature: msg`Cost per workflow run`,
|
||||
// description: msg`An automation that writes a record`,
|
||||
// competitor: {
|
||||
// price: msg`From ≈ 1.5¢/run`,
|
||||
// detail: msg`One credit per data-writing block, $150 per 10k credits`,
|
||||
// sourceUrl: ATTIO_PRICING_URL,
|
||||
// },
|
||||
// twenty: { detail: msg`Credit allowance included with every plan` },
|
||||
// },
|
||||
// {
|
||||
// feature: msg`Source code`,
|
||||
// description: msg`Audit it, extend it, own it`,
|
||||
// competitor: {
|
||||
// price: msg`Closed`,
|
||||
// detail: msg`Proprietary`,
|
||||
// },
|
||||
// twenty: { detail: msg`Open source` },
|
||||
// },
|
||||
// ],
|
||||
// receipt: {
|
||||
// scenario: msg`A 20-person team, billed annually`,
|
||||
// competitorLines: [
|
||||
// { label: msg`20 × Pro ($79)`, amount: msg`$18,960/yr` },
|
||||
// {
|
||||
// label: msg`SSO & advanced admin`,
|
||||
// amount: msg`Enterprise upgrade`,
|
||||
// },
|
||||
// ],
|
||||
// competitorTotalAmount: msg`$18,960+`,
|
||||
// competitorTotalSuffix: msg`per year`,
|
||||
// competitorPerUser: msg`$79 per user per month, before Enterprise`,
|
||||
// twentyLines: [
|
||||
// { label: msg`20 × Organization ($19)`, amount: msg`$4,560/yr` },
|
||||
// { label: msg`SSO & advanced admin`, amount: msg`Included` },
|
||||
// ],
|
||||
// twentyTotalAmount: msg`$4,560`,
|
||||
// twentyTotalSuffix: msg`per year`,
|
||||
// twentyPerUser: msg`$19 per user per month, SSO included`,
|
||||
// multiplier: msg`≈ 4× less`,
|
||||
// },
|
||||
// honest: msg`Attio is a polished, fast product with strong built-in enrichment. If you never need to extend or own your CRM, it is a credible pick.`,
|
||||
// sourceNote: msg`List prices from Attio's public pricing page, billed annually, checked on July 30, 2026.`,
|
||||
// };
|
||||
|
||||
const PIPEDRIVE_COMPARISON: CompetitorComparison = {
|
||||
competitor: 'Pipedrive',
|
||||
competitorColumnLabel: msg`On Pipedrive`,
|
||||
eyebrow: msg`Twenty vs Pipedrive`,
|
||||
heading: msg`What does\n*Pipedrive* really cost?`,
|
||||
intro: msg`A friendly price on the front page.\nThe caps show up after you sign.`,
|
||||
migrationLine: msg`Import your data and see your own numbers. Pipedrive exports map cleanly onto Twenty objects.`,
|
||||
slug: 'pipedrive',
|
||||
tableTitle: msg`Where the caps and gates are`,
|
||||
rows: [
|
||||
{
|
||||
feature: msg`The CRM`,
|
||||
description: msg`Pipeline, contacts, email sync`,
|
||||
competitor: {
|
||||
price: msg`$49/user/mo`,
|
||||
detail: msg`Premium plan, billed annually`,
|
||||
sourceUrl: PIPEDRIVE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`$9/user on Pro` },
|
||||
},
|
||||
{
|
||||
feature: msg`SAML SSO & security rules`,
|
||||
description: msg`Single sign-on, access policies`,
|
||||
competitor: {
|
||||
price: msg`$79/user/mo`,
|
||||
detail: msg`Ultimate plan only`,
|
||||
sourceUrl: PIPEDRIVE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Organization, $19/user` },
|
||||
},
|
||||
{
|
||||
feature: msg`Active automations`,
|
||||
description: msg`Company-wide, not per user`,
|
||||
competitor: {
|
||||
price: msg`Capped per tier`,
|
||||
detail: msg`Shared limit below Ultimate`,
|
||||
sourceUrl: PIPEDRIVE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Unlimited workflows` },
|
||||
},
|
||||
{
|
||||
feature: msg`Custom fields`,
|
||||
description: msg`Shape records to your business`,
|
||||
competitor: {
|
||||
price: msg`Capped per tier`,
|
||||
detail: msg`Unlimited on top tiers only`,
|
||||
sourceUrl: PIPEDRIVE_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Unlimited from Pro` },
|
||||
},
|
||||
{
|
||||
feature: msg`Custom objects`,
|
||||
description: msg`Beyond deals and contacts`,
|
||||
competitor: {
|
||||
price: msg`Not available`,
|
||||
detail: msg`Deals, contacts and products only`,
|
||||
},
|
||||
twenty: { detail: msg`Included from Pro` },
|
||||
},
|
||||
],
|
||||
receipt: {
|
||||
scenario: msg`A 20-person sales team, billed annually`,
|
||||
competitorLines: [
|
||||
{ label: msg`20 × Premium ($49)`, amount: msg`$11,760/yr` },
|
||||
{
|
||||
label: msg`SSO & security rules: move to Ultimate`,
|
||||
amount: msg`+$7,200/yr`,
|
||||
},
|
||||
],
|
||||
competitorTotalAmount: msg`$18,960`,
|
||||
competitorTotalSuffix: msg`per year with SSO`,
|
||||
competitorPerUser: msg`$79 per user per month with SSO`,
|
||||
twentyLines: [
|
||||
{ label: msg`20 × Organization ($19)`, amount: msg`$4,560/yr` },
|
||||
{ label: msg`SSO & row-level permissions`, amount: msg`Included` },
|
||||
],
|
||||
twentyTotalAmount: msg`$4,560`,
|
||||
twentyTotalSuffix: msg`per year`,
|
||||
twentyPerUser: msg`$19 per user per month, SSO included`,
|
||||
multiplier: msg`≈ 4× less`,
|
||||
},
|
||||
honest: msg`Pipedrive's pipeline UX is famously simple, and its entry tier is genuinely affordable for very small teams.`,
|
||||
sourceNote: msg`List prices from Pipedrive's public pricing page, billed annually, checked on July 30, 2026.`,
|
||||
};
|
||||
|
||||
const DYNAMICS_COMPARISON: CompetitorComparison = {
|
||||
competitor: 'Microsoft Dynamics',
|
||||
competitorColumnLabel: msg`On Dynamics 365`,
|
||||
eyebrow: msg`Twenty vs Microsoft Dynamics`,
|
||||
heading: msg`What does\n*Dynamics 365* really cost?`,
|
||||
intro: msg`Nobody buys Dynamics, they license it.\nHere is what it costs once storage and add-ons land.`,
|
||||
migrationLine: msg`Import your data and see your own numbers. Twenty's importer handles Dynamics exports without a consulting project.`,
|
||||
slug: 'microsoft-dynamics',
|
||||
tableTitle: msg`The licensing maze, priced`,
|
||||
rows: [
|
||||
{
|
||||
feature: msg`The CRM`,
|
||||
description: msg`Dynamics 365 Sales, per user`,
|
||||
competitor: {
|
||||
price: msg`$105/user/mo`,
|
||||
detail: msg`Sales Enterprise edition`,
|
||||
sourceUrl: DYNAMICS_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`$9 to $19/user, everything included` },
|
||||
},
|
||||
{
|
||||
feature: msg`The AI edition`,
|
||||
description: msg`Sales Premium with AI insights`,
|
||||
competitor: {
|
||||
price: msg`$150/user/mo`,
|
||||
detail: msg`Minimum 10 users`,
|
||||
sourceUrl: DYNAMICS_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`AI agents included, usage credits` },
|
||||
},
|
||||
{
|
||||
feature: msg`Database storage`,
|
||||
description: msg`Beyond the pooled entitlement`,
|
||||
competitor: {
|
||||
price: msg`$40/GB/month`,
|
||||
detail: msg`Dataverse database add-on`,
|
||||
sourceUrl: DYNAMICS_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Included` },
|
||||
},
|
||||
{
|
||||
feature: msg`Customization apps`,
|
||||
description: msg`Extend beyond the CRM screens`,
|
||||
competitor: {
|
||||
price: msg`$20/user/mo`,
|
||||
detail: msg`Separate Power Apps licensing`,
|
||||
sourceUrl: DYNAMICS_PRICING_URL,
|
||||
},
|
||||
twenty: { detail: msg`Full customization included` },
|
||||
},
|
||||
{
|
||||
feature: msg`Implementation`,
|
||||
description: msg`Getting to a working CRM`,
|
||||
competitor: {
|
||||
price: msg`$15k to $100k+`,
|
||||
detail: msg`Typical partner project`,
|
||||
},
|
||||
twenty: { detail: msg`Self-serve, partners optional` },
|
||||
},
|
||||
],
|
||||
receipt: {
|
||||
scenario: msg`A 20-person sales team, billed annually`,
|
||||
competitorLines: [
|
||||
{
|
||||
label: msg`20 × Sales Enterprise ($105)`,
|
||||
amount: msg`$25,200/yr`,
|
||||
},
|
||||
{
|
||||
label: msg`Extra Dataverse storage (10 GB)`,
|
||||
amount: msg`$4,800/yr`,
|
||||
},
|
||||
],
|
||||
competitorTotalAmount: msg`$30,000`,
|
||||
competitorTotalSuffix: msg`per year`,
|
||||
competitorPerUser: msg`≈ $125 per user per month`,
|
||||
twentyLines: [
|
||||
{ label: msg`20 × Organization ($19)`, amount: msg`$4,560/yr` },
|
||||
{ label: msg`Storage`, amount: msg`Included` },
|
||||
],
|
||||
twentyTotalAmount: msg`$4,560`,
|
||||
twentyTotalSuffix: msg`per year`,
|
||||
twentyPerUser: msg`$19 per user per month, that's it`,
|
||||
multiplier: msg`≈ 6× less`,
|
||||
},
|
||||
honest: msg`If your company lives in Microsoft 365 and already runs on the Power Platform, Dynamics integrates like nothing else.`,
|
||||
sourceNote: msg`List prices from Microsoft's public Dynamics 365 pricing page, billed annually, checked on July 30, 2026.`,
|
||||
};
|
||||
|
||||
const SAP_STORE_URL = 'https://store.sap.com';
|
||||
|
||||
const SAP_COMPARISON: CompetitorComparison = {
|
||||
competitor: 'SAP Sales Cloud',
|
||||
competitorColumnLabel: msg`On SAP`,
|
||||
eyebrow: msg`Twenty vs SAP Sales Cloud`,
|
||||
heading: msg`What does\n*SAP Sales Cloud* really cost?`,
|
||||
intro: msg`The license is the cheap part.\nHere is what SAP Sales Cloud costs by the time it goes live.`,
|
||||
migrationLine: msg`Import your data and see your own numbers. No six-month implementation project required.`,
|
||||
slug: 'sap-sales-cloud',
|
||||
tableTitle: msg`Enterprise software, enterprise line items`,
|
||||
rows: [
|
||||
{
|
||||
feature: msg`The CRM itself`,
|
||||
description: msg`Sales Cloud V2, per user`,
|
||||
competitor: {
|
||||
price: msg`$134/user/mo`,
|
||||
detail: msg`SAP Store list price`,
|
||||
sourceUrl: SAP_STORE_URL,
|
||||
},
|
||||
twenty: { detail: msg`$9 to $19/user, everything included` },
|
||||
},
|
||||
{
|
||||
feature: msg`Contract terms`,
|
||||
description: msg`How long you are committed`,
|
||||
competitor: {
|
||||
price: msg`Up to 36 months`,
|
||||
detail: msg`Auto-renewing term contracts`,
|
||||
sourceUrl: SAP_STORE_URL,
|
||||
},
|
||||
twenty: { detail: msg`Monthly or yearly, cancel anytime` },
|
||||
},
|
||||
{
|
||||
feature: msg`Implementation`,
|
||||
description: msg`Getting to a working CRM`,
|
||||
competitor: {
|
||||
price: msg`$40k to $150k+`,
|
||||
detail: msg`Typical partner project, year one`,
|
||||
},
|
||||
twenty: { detail: msg`Self-serve, partners optional` },
|
||||
},
|
||||
{
|
||||
feature: msg`Integration`,
|
||||
description: msg`Connecting the rest of your stack`,
|
||||
competitor: {
|
||||
price: msg`Project-based`,
|
||||
detail: msg`Middleware licensed separately`,
|
||||
},
|
||||
twenty: { detail: msg`REST & GraphQL APIs included` },
|
||||
},
|
||||
{
|
||||
feature: msg`Price transparency`,
|
||||
description: msg`Can you see the bill coming?`,
|
||||
competitor: {
|
||||
price: msg`Quote-based`,
|
||||
detail: msg`Enterprise rate cards are rarely published`,
|
||||
},
|
||||
twenty: { detail: msg`Public pricing, this page included` },
|
||||
},
|
||||
],
|
||||
receipt: {
|
||||
scenario: msg`A 20-person sales team, billed annually`,
|
||||
competitorLines: [
|
||||
{
|
||||
label: msg`20 × Sales Cloud V2 ($134)`,
|
||||
amount: msg`$32,160/yr`,
|
||||
},
|
||||
{
|
||||
label: msg`Implementation project (year one, typical)`,
|
||||
amount: msg`$40,000+`,
|
||||
},
|
||||
],
|
||||
competitorTotalAmount: msg`$72,160+`,
|
||||
competitorTotalSuffix: msg`in year one`,
|
||||
competitorPerUser: msg`≈ $300 per user per month in year one`,
|
||||
twentyLines: [
|
||||
{ label: msg`20 × Organization ($19)`, amount: msg`$4,560/yr` },
|
||||
{ label: msg`Implementation`, amount: msg`$0` },
|
||||
],
|
||||
twentyTotalAmount: msg`$4,560`,
|
||||
twentyTotalSuffix: msg`in year one`,
|
||||
twentyPerUser: msg`$19 per user per month, that's it`,
|
||||
multiplier: msg`≈ 15× less`,
|
||||
},
|
||||
honest: msg`If your business already runs on S/4HANA, Sales Cloud speaks its language natively and handles global-enterprise compliance at serious scale.`,
|
||||
sourceNote: msg`License list price from the SAP Store, billed annually, checked on July 30, 2026. Implementation figures are typical partner-project ranges, not SAP list prices.`,
|
||||
};
|
||||
|
||||
export const COMPARISONS = {
|
||||
dynamics: DYNAMICS_COMPARISON,
|
||||
hubspot: HUBSPOT_COMPARISON,
|
||||
pipedrive: PIPEDRIVE_COMPARISON,
|
||||
salesforce: SALESFORCE_COMPARISON,
|
||||
sap: SAP_COMPARISON,
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type PlanTableCellType,
|
||||
type PlanTableFeatureRowDataType,
|
||||
type PlanTableTierColumnType,
|
||||
type PlanTableTierId,
|
||||
} from './plan-table-types';
|
||||
import { resolveVisibleRows } from './plan-table-visible-rows';
|
||||
|
||||
@@ -41,7 +42,7 @@ const TableScope = styled.div`
|
||||
|
||||
const GridRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-columns: 1.2fr 1fr 1fr 1fr;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
@@ -134,6 +135,20 @@ const CtaRow = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
// Enterprise inherits the Organization cell unless a row overrides it: the
|
||||
// tier is a superset, so unmarked rows read as "same as Organization".
|
||||
function resolveCell(
|
||||
row: PlanTableFeatureRowDataType,
|
||||
columnId: PlanTableTierId,
|
||||
): PlanTableCellType {
|
||||
return (
|
||||
row.tiers[columnId] ??
|
||||
(columnId === 'enterprise' ? row.tiers.organization : undefined) ?? {
|
||||
kind: 'dash',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function CellValue({ cell }: { cell: PlanTableCellType }) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
@@ -167,7 +182,7 @@ function FeatureRow({
|
||||
<FeatureLabel>{i18n._(row.featureLabel)}</FeatureLabel>
|
||||
{tierColumns.map((column) => (
|
||||
<TierCell key={column.id}>
|
||||
<CellValue cell={row.tiers[column.id]} />
|
||||
<CellValue cell={resolveCell(row, column.id)} />
|
||||
</TierCell>
|
||||
))}
|
||||
</GridRow>
|
||||
@@ -184,6 +199,7 @@ function CategoryRow({ title }: { title: MessageDescriptor }) {
|
||||
</CategoryBand>
|
||||
<CategoryBand aria-hidden="true" />
|
||||
<CategoryBand aria-hidden="true" />
|
||||
<CategoryBand aria-hidden="true" />
|
||||
</GridRow>
|
||||
);
|
||||
}
|
||||
@@ -234,7 +250,7 @@ export function PlanTableContent() {
|
||||
<HeadCell>{i18n._(PLAN_TABLE_DATA.featureColumnLabel)}</HeadCell>
|
||||
{PLAN_TABLE_DATA.tierColumns.map((column) => (
|
||||
<HeadCell data-tier key={column.id}>
|
||||
{i18n._(column.label)}
|
||||
{i18n._(column.label[hosting])}
|
||||
</HeadCell>
|
||||
))}
|
||||
</GridRow>
|
||||
|
||||
@@ -9,10 +9,12 @@ export const PLAN_TABLE_DATA: PlanTableDataType = {
|
||||
{
|
||||
featureLabel: msg`Price`,
|
||||
selfHostTiers: {
|
||||
enterprise: { kind: 'text', text: msg`From $50k/year` },
|
||||
organization: { kind: 'text', text: msg`$19` },
|
||||
pro: { kind: 'text', text: msg`$0` },
|
||||
},
|
||||
tiers: {
|
||||
enterprise: { kind: 'text', text: msg`From $50k/year` },
|
||||
organization: { kind: 'text', text: msg`$19` },
|
||||
pro: { kind: 'text', text: msg`$9` },
|
||||
},
|
||||
@@ -269,6 +271,15 @@ export const PLAN_TABLE_DATA: PlanTableDataType = {
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: msg`Dedicated support & SLA`,
|
||||
tiers: {
|
||||
enterprise: { kind: 'yes', label: msg`Yes` },
|
||||
organization: { kind: 'dash' },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: msg`Onboarding Packs`,
|
||||
selfHostTiers: {
|
||||
@@ -353,6 +364,7 @@ export const PLAN_TABLE_DATA: PlanTableDataType = {
|
||||
appliesTo: 'cloud',
|
||||
featureLabel: msg`API calls`,
|
||||
tiers: {
|
||||
enterprise: { kind: 'text', text: msg`Custom` },
|
||||
organization: { kind: 'text', text: msg`100 per minute` },
|
||||
pro: { kind: 'text', text: msg`50 per minute` },
|
||||
},
|
||||
@@ -377,13 +389,79 @@ export const PLAN_TABLE_DATA: PlanTableDataType = {
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{ title: msg`Enterprise`, type: 'category' },
|
||||
{
|
||||
appliesTo: 'cloud',
|
||||
featureLabel: msg`Single-tenant isolation`,
|
||||
tiers: {
|
||||
enterprise: { kind: 'yes', label: msg`Yes` },
|
||||
organization: { kind: 'dash' },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
appliesTo: 'cloud',
|
||||
featureLabel: msg`IP allow-listing`,
|
||||
tiers: {
|
||||
enterprise: { kind: 'yes', label: msg`Yes` },
|
||||
organization: { kind: 'dash' },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: msg`SCIM provisioning`,
|
||||
tiers: {
|
||||
enterprise: { kind: 'yes', label: msg`Yes` },
|
||||
organization: { kind: 'dash' },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
appliesTo: 'selfHost',
|
||||
featureLabel: msg`Air-gapped deployment`,
|
||||
tiers: {
|
||||
enterprise: { kind: 'yes', label: msg`Yes` },
|
||||
organization: { kind: 'dash' },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
appliesTo: 'selfHost',
|
||||
featureLabel: msg`LTS releases`,
|
||||
tiers: {
|
||||
enterprise: { kind: 'yes', label: msg`Yes` },
|
||||
organization: { kind: 'dash' },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: msg`Security review & DPA`,
|
||||
tiers: {
|
||||
enterprise: { kind: 'yes', label: msg`Yes` },
|
||||
organization: { kind: 'dash' },
|
||||
pro: { kind: 'dash' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
],
|
||||
seeMoreFeaturesCta: {
|
||||
collapseLabel: msg`Show less`,
|
||||
expandLabel: msg`See more features`,
|
||||
},
|
||||
tierColumns: [
|
||||
{ id: 'pro', label: msg`Pro` },
|
||||
{ id: 'organization', label: msg`Organization` },
|
||||
{ id: 'pro', label: { cloud: msg`Pro`, selfHost: msg`Pro` } },
|
||||
{
|
||||
id: 'organization',
|
||||
label: { cloud: msg`Organization`, selfHost: msg`Organization` },
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
label: { cloud: msg`Enterprise`, selfHost: msg`Enterprise` },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { type PlansHostingMode } from '@/pricing-state';
|
||||
|
||||
export type PlanTableTierId = 'organization' | 'pro';
|
||||
export type PlanTableTierId = 'enterprise' | 'organization' | 'pro';
|
||||
|
||||
export type PlanTableCellType =
|
||||
| { kind: 'dash' }
|
||||
@@ -11,7 +11,7 @@ export type PlanTableCellType =
|
||||
|
||||
export type PlanTableTierColumnType = {
|
||||
id: PlanTableTierId;
|
||||
label: MessageDescriptor;
|
||||
label: Record<PlansHostingMode, MessageDescriptor>;
|
||||
};
|
||||
|
||||
export type PlanTableCategoryRowDataType = {
|
||||
@@ -23,8 +23,8 @@ export type PlanTableCategoryRowDataType = {
|
||||
export type PlanTableFeatureRowDataType = {
|
||||
appliesTo?: PlansHostingMode;
|
||||
featureLabel: MessageDescriptor;
|
||||
selfHostTiers?: Record<PlanTableTierId, PlanTableCellType>;
|
||||
tiers: Record<PlanTableTierId, PlanTableCellType>;
|
||||
selfHostTiers?: Partial<Record<PlanTableTierId, PlanTableCellType>>;
|
||||
tiers: Partial<Record<PlanTableTierId, PlanTableCellType>>;
|
||||
type: 'row';
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ const FIXTURE: PlanTableBodyRowDataType[] = [
|
||||
];
|
||||
|
||||
const hasDashPro = (rows: PlanTableBodyRowDataType[]): boolean =>
|
||||
rows.some((row) => row.type === 'row' && row.tiers.pro.kind === 'dash');
|
||||
rows.some((row) => row.type === 'row' && row.tiers.pro?.kind === 'dash');
|
||||
|
||||
describe('resolveVisibleRows', () => {
|
||||
it('drops self-host-only rows in cloud mode', () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
|
||||
import { TalkToUsButton } from '@/contact-cal';
|
||||
import { useAnimatedNumber } from '@/platform/motion';
|
||||
import { color, mediaUp, radius, semanticColor, spacing } from '@/tokens';
|
||||
import { Body, Button, Heading } from '@/ui';
|
||||
@@ -142,6 +142,7 @@ export function PlanCard({
|
||||
const { i18n } = useLingui();
|
||||
const tier = PLANS_DATA[tierId];
|
||||
const cell = tier.cells[hosting][billing];
|
||||
const cta = tier.cta[hosting];
|
||||
const iconWidth = tier.icon.widthPx ?? 80;
|
||||
const animatedPriceValue = useAnimatedNumber(cell.price.value);
|
||||
const { comparisonBulletTexts, phase, visibleBullets } = useFeatureTransition(
|
||||
@@ -159,11 +160,11 @@ export function PlanCard({
|
||||
size="xs"
|
||||
weight="light"
|
||||
>
|
||||
{i18n._(tier.heading)}
|
||||
{i18n._(tier.heading[hosting])}
|
||||
</Heading>
|
||||
<PriceLine>
|
||||
<Heading as="h4" family="sans" size="sm" weight="regular">
|
||||
{`${cell.price.prefix}${PRICE_NUMBER_FORMATTER.format(animatedPriceValue)}`}
|
||||
{`${cell.price.prefix}${PRICE_NUMBER_FORMATTER.format(animatedPriceValue)}${cell.price.valueSuffix ?? ''}`}
|
||||
</Heading>
|
||||
<Body as="span" className={priceSuffixClassName} size="sm">
|
||||
{i18n._(cell.price.suffix)}
|
||||
@@ -190,11 +191,18 @@ export function PlanCard({
|
||||
/>
|
||||
|
||||
<CtaRow>
|
||||
<Button
|
||||
href="https://app.twenty.com/welcome"
|
||||
label={i18n._(msg`Start for free`)}
|
||||
variant={highlighted ? 'filled' : 'outlined'}
|
||||
/>
|
||||
{cta.href !== undefined ? (
|
||||
<Button
|
||||
href={cta.href}
|
||||
label={i18n._(cta.label)}
|
||||
variant={highlighted ? 'filled' : 'outlined'}
|
||||
/>
|
||||
) : (
|
||||
<TalkToUsButton
|
||||
label={cta.label}
|
||||
variant={highlighted ? 'filled' : 'outlined'}
|
||||
/>
|
||||
)}
|
||||
</CtaRow>
|
||||
</CardShell>
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ const CardsGrid = styled.div`
|
||||
|
||||
${mediaUp('md')} {
|
||||
column-gap: ${spacing(6)};
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
row-gap: 0;
|
||||
}
|
||||
`;
|
||||
@@ -80,6 +80,7 @@ export function PricingBoard() {
|
||||
const maxBullets = Math.max(
|
||||
PLANS_DATA.pro.cells[hosting][billing].featureBullets.length,
|
||||
PLANS_DATA.organization.cells[hosting][billing].featureBullets.length,
|
||||
PLANS_DATA.enterprise.cells[hosting][billing].featureBullets.length,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -106,6 +107,12 @@ export function PricingBoard() {
|
||||
maxBullets={maxBullets}
|
||||
tierId="organization"
|
||||
/>
|
||||
<PlanCard
|
||||
billing={billing}
|
||||
hosting={hosting}
|
||||
maxBullets={maxBullets}
|
||||
tierId="enterprise"
|
||||
/>
|
||||
</CardsGrid>
|
||||
</Board>
|
||||
);
|
||||
|
||||
@@ -4,12 +4,13 @@ import { msg } from '@lingui/core/macro';
|
||||
import { type PlansHostingMode } from '@/pricing-state';
|
||||
|
||||
export type PlansBillingPeriod = 'monthly' | 'yearly';
|
||||
export type PlansTierId = 'organization' | 'pro';
|
||||
export type PlansTierId = 'enterprise' | 'organization' | 'pro';
|
||||
|
||||
export type PlanPrice = {
|
||||
prefix: string;
|
||||
suffix: MessageDescriptor;
|
||||
value: number;
|
||||
valueSuffix?: string;
|
||||
};
|
||||
|
||||
type PlansTierCell = {
|
||||
@@ -17,9 +18,17 @@ type PlansTierCell = {
|
||||
price: PlanPrice;
|
||||
};
|
||||
|
||||
// A CTA without href opens the contact modal (TalkToUsButton) instead of
|
||||
// navigating.
|
||||
type PlansTierCta = {
|
||||
href?: string;
|
||||
label: MessageDescriptor;
|
||||
};
|
||||
|
||||
type PlansTier = {
|
||||
cells: Record<PlansHostingMode, Record<PlansBillingPeriod, PlansTierCell>>;
|
||||
heading: MessageDescriptor;
|
||||
cta: Record<PlansHostingMode, PlansTierCta>;
|
||||
heading: Record<PlansHostingMode, MessageDescriptor>;
|
||||
icon: { alt: string; src: string; widthPx?: number };
|
||||
};
|
||||
|
||||
@@ -62,7 +71,63 @@ const ORGANIZATION_BULLETS_SELF_HOST = [
|
||||
msg`Twenty team support`,
|
||||
];
|
||||
|
||||
const ENTERPRISE_BULLETS_CLOUD = [
|
||||
msg`Everything in Organization`,
|
||||
msg`Single-tenant isolation`,
|
||||
msg`IP allow-listing`,
|
||||
msg`SCIM provisioning`,
|
||||
msg`Dedicated support & SLA`,
|
||||
];
|
||||
|
||||
const ENTERPRISE_BULLETS_SELF_HOST = [
|
||||
msg`Everything in Organization`,
|
||||
msg`SCIM provisioning`,
|
||||
msg`Air-gapped deployment`,
|
||||
msg`LTS releases`,
|
||||
msg`Dedicated support & SLA`,
|
||||
];
|
||||
|
||||
const ENTERPRISE_PRICE = {
|
||||
value: 50,
|
||||
valueSuffix: 'k',
|
||||
prefix: 'from $',
|
||||
suffix: msg`/year`,
|
||||
};
|
||||
|
||||
export const PLANS_DATA: Record<PlansTierId, PlansTier> = {
|
||||
enterprise: {
|
||||
cells: {
|
||||
cloud: {
|
||||
monthly: {
|
||||
featureBullets: ENTERPRISE_BULLETS_CLOUD,
|
||||
price: ENTERPRISE_PRICE,
|
||||
},
|
||||
yearly: {
|
||||
featureBullets: ENTERPRISE_BULLETS_CLOUD,
|
||||
price: ENTERPRISE_PRICE,
|
||||
},
|
||||
},
|
||||
selfHost: {
|
||||
monthly: {
|
||||
featureBullets: ENTERPRISE_BULLETS_SELF_HOST,
|
||||
price: ENTERPRISE_PRICE,
|
||||
},
|
||||
yearly: {
|
||||
featureBullets: ENTERPRISE_BULLETS_SELF_HOST,
|
||||
price: ENTERPRISE_PRICE,
|
||||
},
|
||||
},
|
||||
},
|
||||
cta: {
|
||||
cloud: { label: msg`Talk to sales` },
|
||||
selfHost: { label: msg`Talk to sales` },
|
||||
},
|
||||
heading: { cloud: msg`Enterprise`, selfHost: msg`Enterprise` },
|
||||
icon: {
|
||||
alt: 'Enterprise plan icon',
|
||||
src: '/images/pricing/plans/enterprise-icon.webp',
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
cells: {
|
||||
cloud: {
|
||||
@@ -86,7 +151,17 @@ export const PLANS_DATA: Record<PlansTierId, PlansTier> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
heading: msg`Organization`,
|
||||
cta: {
|
||||
cloud: {
|
||||
href: 'https://app.twenty.com/welcome',
|
||||
label: msg`Start for free`,
|
||||
},
|
||||
selfHost: {
|
||||
href: 'https://app.twenty.com/welcome',
|
||||
label: msg`Start for free`,
|
||||
},
|
||||
},
|
||||
heading: { cloud: msg`Organization`, selfHost: msg`Organization` },
|
||||
icon: {
|
||||
alt: 'Organization plan icon',
|
||||
src: '/images/pricing/plans/organization-icon.webp',
|
||||
@@ -115,7 +190,17 @@ export const PLANS_DATA: Record<PlansTierId, PlansTier> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
heading: msg`Pro`,
|
||||
cta: {
|
||||
cloud: {
|
||||
href: 'https://app.twenty.com/welcome',
|
||||
label: msg`Start for free`,
|
||||
},
|
||||
selfHost: {
|
||||
href: 'https://app.twenty.com/welcome',
|
||||
label: msg`Start for free`,
|
||||
},
|
||||
},
|
||||
heading: { cloud: msg`Pro`, selfHost: msg`Pro` },
|
||||
icon: {
|
||||
alt: 'Pro plan icon',
|
||||
src: '/images/pricing/plans/pro-icon.webp',
|
||||
|
||||
@@ -2,12 +2,36 @@ import { styled } from '@linaria/react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { getServerI18n } from '@/platform/i18n/get-server-i18n';
|
||||
import { mediaUp, spacing } from '@/tokens';
|
||||
import { LocalizedLink } from '@/platform/i18n/LocalizedLink';
|
||||
import {
|
||||
color,
|
||||
DURATION,
|
||||
fontSize,
|
||||
mediaUp,
|
||||
semanticColor,
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
import { Body, Heading, SectionShell } from '@/ui';
|
||||
|
||||
import { SALESFARCE_DATA } from './salesfarce-data';
|
||||
import { SalesfarceFlow } from './SalesfarceFlow';
|
||||
|
||||
const COMPARE_LINKS = [
|
||||
{ label: msg`Twenty vs HubSpot`, href: '/compare-pricing/hubspot' },
|
||||
{ label: msg`Twenty vs Salesforce`, href: '/compare-pricing/salesforce' },
|
||||
{
|
||||
label: msg`Twenty vs SAP Sales Cloud`,
|
||||
href: '/compare-pricing/sap-sales-cloud',
|
||||
},
|
||||
{ label: msg`Twenty vs Pipedrive`, href: '/compare-pricing/pipedrive' },
|
||||
{
|
||||
label: msg`Twenty vs Microsoft Dynamics`,
|
||||
href: '/compare-pricing/microsoft-dynamics',
|
||||
},
|
||||
// Attio comparison parked, may return:
|
||||
// { label: msg`Twenty vs Attio`, href: '/compare-pricing/attio' },
|
||||
];
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -43,6 +67,57 @@ const CopyColumn = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const CompareList = styled.ul`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing(2)};
|
||||
list-style: none;
|
||||
margin-top: ${spacing(6)};
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
// The footer's hover-marker link affordance, so the list reads as the same
|
||||
// navigation language.
|
||||
const HoverMarker = styled.span`
|
||||
background-color: ${semanticColor.ink};
|
||||
border-radius: 1px;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
height: 7px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
width ${DURATION.md} ease-out,
|
||||
opacity ${DURATION.md} ease-out;
|
||||
width: 0;
|
||||
`;
|
||||
|
||||
const CompareLink = styled(LocalizedLink)`
|
||||
align-items: center;
|
||||
color: ${semanticColor.ink};
|
||||
display: flex;
|
||||
font-size: ${fontSize(4)};
|
||||
gap: 0;
|
||||
line-height: 1.35;
|
||||
text-decoration: none;
|
||||
transition: gap ${DURATION.md} ease-out;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid ${color('blue')};
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
${mediaUp('md')} {
|
||||
&:hover {
|
||||
gap: ${spacing(2)};
|
||||
}
|
||||
|
||||
&:hover [data-slot='hover-marker'] {
|
||||
opacity: 1;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function PricingSalesfarce() {
|
||||
const i18n = getServerI18n();
|
||||
|
||||
@@ -51,11 +126,21 @@ export function PricingSalesfarce() {
|
||||
<Grid>
|
||||
<CopyColumn>
|
||||
<Heading as="h2" size="lg" weight="light">
|
||||
{i18n._(msg`Trust the n°1 CRM, *or not!*`)}
|
||||
{i18n._(msg`Compare *the real cost*`)}
|
||||
</Heading>
|
||||
<Body muted size="sm">
|
||||
{i18n._(SALESFARCE_DATA.body)}
|
||||
</Body>
|
||||
<CompareList>
|
||||
{COMPARE_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<CompareLink href={link.href}>
|
||||
<HoverMarker aria-hidden data-slot="hover-marker" />
|
||||
{i18n._(link.label)}
|
||||
</CompareLink>
|
||||
</li>
|
||||
))}
|
||||
</CompareList>
|
||||
</CopyColumn>
|
||||
<SalesfarceFlow pricing={SALESFARCE_DATA.pricing} />
|
||||
</Grid>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type SalesfarceDataType } from './salesfarce-types';
|
||||
const SALESFARCE_POPUP_TITLE = msg`Good choice!`;
|
||||
|
||||
export const SALESFARCE_DATA: SalesfarceDataType = {
|
||||
body: msg`Some call this enterprise pricing. We prefer a CRM where API access, webhooks, and workflows don't show up as surprise add-ons.`,
|
||||
body: msg`Feature-by-feature price checks against the CRMs you're probably comparing us with. Sources included.`,
|
||||
pricing: {
|
||||
addons: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user