[Website] i18n module, page-local sections, translatable copy (#21082)

**i18n** — collapsed the ~22 scattered i18n files into a single module
and turned on Spanish alongside French.

**Sections** — dropped the old compound pattern (`Section.Root`,
`Section.Heading`, …). Reusable layout shells moved to `src/templates/`,
atomic bits stay in `design-system/`, and each page now owns its copy in
local `_components` blocks instead of pulling it out of shared sections.
Data files hold arrays only, no prose.

**Copy → `<Trans>`** — A lot of headings were split across several
`<HeadingPart>`s just for font styling, which meant each piece was a
separate translation string. A translator got "Build your Enterprise
CRM" and "at AI Speed" as two unrelated strings and had no way to
reorder them for their language. Those are now single `<Trans>` units
with placeholders. Same idea for the old `\n` + `white-space: pre-line`
line-break trick: replaced with a small `ResponsiveLineBreak` element so
the break is doesn't quietly rot, and did a dead-code pass.

The de-fragmentation changes the message IDs, so around 60 strings will
fall back to English in fr/es until Crowdin re-syncs.
This commit is contained in:
Abdullah.
2026-05-31 17:39:35 +05:00
committed by GitHub
parent fc90b4ba8b
commit b027e4bdb1
222 changed files with 2610 additions and 3057 deletions
+1
View File
@@ -40,6 +40,7 @@ const jestConfig = {
],
},
moduleNameMapper: {
'^server-only$': '<rootDir>/jest/server-only-stub.ts',
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, {
prefix: '<rootDir>/',
}),
@@ -0,0 +1,5 @@
// jest stub for the `server-only` package. `server-only` is a Next.js build
// guard (errors if a server module is pulled into a client bundle); it has no
// behaviour at runtime and no resolution under the jest node environment, so we
// map it to this empty module. The real guard still runs during `next build`.
export {};
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from '@lingui/conf';
import { formatter } from '@lingui/format-po';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { WEBSITE_LOCALE_LIST } from './src/lib/i18n/utils/website-locale-list';
import { WEBSITE_LOCALE_LIST } from './src/lib/i18n/locales';
export default defineConfig({
sourceLocale: SOURCE_LOCALE,
+11 -3
View File
@@ -3,9 +3,17 @@ import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
import { APP_LOCALES } from 'twenty-shared/translations';
// Locale URL segments that are actually served (others are normalised away).
// Mirrors LOCALE_BY_URL_SEGMENT keys in src/lib/i18n/utils/website-locale-segments.ts.
const DEPLOYED_LOCALE_URL_SEGMENTS = ['en', 'fr'] as const;
import {
WEBSITE_LOCALE_LIST,
localeToUrlSegment,
} from './src/lib/i18n/locales';
// Locale URL segments that are actually served (others are normalised away),
// derived from the single WEBSITE_LOCALE_LIST source of truth so adding a
// locale never requires editing this config. These are exactly the keys of
// LOCALE_BY_URL_SEGMENT in src/lib/i18n/locales.ts.
const DEPLOYED_LOCALE_URL_SEGMENTS =
WEBSITE_LOCALE_LIST.map(localeToUrlSegment);
// Raw locale codes (e.g. fr-FR, de-DE) that should redirect to the un-prefixed
// path. Excludes pseudo-* locales and the deployed URL segments themselves.
@@ -52,6 +52,36 @@ const RULES = [
'element visibility, cleanup, and render failures are handled consistently.',
].join('\n '),
},
{
id: 'design-system-no-upward-import',
description:
'design-system is the lowest UI layer — it must not import from @/sections, @/templates, or @/app.',
pattern: /from\s+['"]@\/(sections|templates|app)(\/|['"])/,
appliesTo: (rel) =>
rel.startsWith('src/design-system/') && /\.(ts|tsx)$/.test(rel),
exempt: (rel) => rel.includes('__tests__') || rel.endsWith('.d.ts'),
help: [
'design-system holds atomic, domain-agnostic primitives. It may depend on',
'@/theme, @/icons and @/lib, but never on the higher layers (sections,',
'templates, app). If a primitive needs section/page data, lift that data up',
'to the consumer and pass it in as a prop.',
].join('\n '),
},
{
id: 'templates-no-upward-import',
description:
'templates (section layout shells) must not import from @/sections or @/app — only @/design-system and @/theme.',
pattern: /from\s+['"]@\/(sections|app)(\/|['"])/,
appliesTo: (rel) =>
rel.startsWith('src/templates/') && /\.(ts|tsx)$/.test(rel),
exempt: (rel) => rel.includes('__tests__') || rel.endsWith('.d.ts'),
help: [
'templates are reusable section layout shells composed from design-system',
'primitives. They must not reach into sections (visuals) or app (pages).',
'Page-local blocks compose a template with its section visual — inject the',
'visual as children/a prop rather than importing it into the template.',
].join('\n '),
},
];
const SKIP_DIRS = new Set([
@@ -7,8 +7,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SECTIONS_DIR = path.join(ROOT, 'src', 'sections');
const SECTIONS_USING_NAMED_SLOTS = new Map([]);
const LEAF_SECTIONS = new Set([
'CaseStudy',
'CaseStudyCatalog',
@@ -16,6 +14,11 @@ const LEAF_SECTIONS = new Set([
'LegalDocument',
'PartnerApplication',
'Stepper',
// Visual-only modules: their layout shells moved to src/templates/, so they
// no longer own a section <Root> — they just expose visual components.
'Hero',
'ThreeCards',
'Testimonials',
]);
async function listSections() {
@@ -59,9 +62,14 @@ async function findBarrel(sectionDir) {
async function findRoot(sectionDir) {
const sectionName = path.basename(sectionDir);
const candidates = [
// Legacy compound sections own the <section> in components/Root.tsx.
path.join(sectionDir, 'components', 'Root.tsx'),
// Single-file sections own it in <Section>.tsx (e.g. TrustedBy/TrustedBy.tsx).
path.join(sectionDir, `${sectionName}.tsx`),
path.join(sectionDir, `${sectionName}.ts`),
// Flat-primitive sections own it in a <Section>Section shell
// (e.g. Hero/components/HeroSection.tsx) consumed by page-local blocks.
path.join(sectionDir, 'components', `${sectionName}Section.tsx`),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate;
@@ -69,22 +77,6 @@ async function findRoot(sectionDir) {
return null;
}
function parseSlotIdentifiers(barrelContents) {
const exportMatch = barrelContents.match(
/export\s+const\s+\w+\s*=\s*\{([^}]+)\}/m,
);
if (!exportMatch) return null;
const body = exportMatch[1];
return body
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => {
const colon = entry.indexOf(':');
return colon === -1 ? entry : entry.slice(0, colon).trim();
});
}
const TOARRAY_REGEX = /Children\.toArray\s*\(/;
function stripComments(source) {
@@ -100,7 +92,7 @@ async function checkSection(name) {
const barrel = await findBarrel(sectionDir);
if (barrel === null) {
violations.push(
`${name}: missing components/index.{ts,tsx} barrel — every section must expose a single compound export.`,
`${name}: missing a barrel (index.{ts,tsx}) — every section must expose its public API through one barrel (flat named exports; no compound objects).`,
);
return violations;
}
@@ -124,57 +116,9 @@ async function checkSection(name) {
}
}
const slotsToCheck = SECTIONS_USING_NAMED_SLOTS.get(name);
if (slotsToCheck !== undefined) {
const barrelContents = await readFileOrNull(barrel);
const exportedSlotNames = barrelContents
? parseSlotIdentifiers(barrelContents)
: null;
for (const slot of slotsToCheck) {
if (exportedSlotNames !== null && !exportedSlotNames.includes(slot)) {
violations.push(
`${name}: slot "${slot}" is declared in SECTIONS_USING_NAMED_SLOTS but is not exported from ${path.relative(
ROOT,
barrel,
)}. Either export it or remove the entry from check-section-shape.mjs.`,
);
continue;
}
const expected = `${name}.${slot}`;
const slotFile = await locateSlotFile(sectionDir, slot);
if (slotFile === null) {
violations.push(
`${name}: slot "${slot}" is declared in SECTIONS_USING_NAMED_SLOTS but no source file matches the conventional path (components/${slot}.tsx or components/${slot}/${slot}.tsx).`,
);
continue;
}
const contents = await readFileOrNull(slotFile);
if (contents === null) continue;
if (!contents.includes(`displayName = '${expected}'`)) {
violations.push(
`${name}: slot "${slot}" source (${path.relative(
ROOT,
slotFile,
)}) does not set ${slot}.displayName = '${expected}'. Root looks slots up by displayName; without it the slot silently fails to render.`,
);
}
}
}
return violations;
}
async function locateSlotFile(sectionDir, slot) {
const candidates = [
path.join(sectionDir, 'components', `${slot}.tsx`),
path.join(sectionDir, 'components', slot, `${slot}.tsx`),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate;
}
return null;
}
async function main() {
const sections = await listSections();
const allViolations = [];
@@ -0,0 +1,31 @@
import { Trans } from '@lingui/react/macro';
import { FEATURE_CARDS } from '@/app/[locale]/(home)/three-cards-feature.data';
import { Eyebrow, Heading, HeadingPart } from '@/design-system/components';
import { FeatureCards } from '@/sections/ThreeCards';
import { ThreeCardsIntro, ThreeCardsSection } from '@/templates/ThreeCards';
export function HomeFeatureThreeCards() {
return (
<ThreeCardsSection scheme="light">
<ThreeCardsIntro align="center" maxWidthMd={900}>
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>Skip the clunky UX that always comes with custom.</Trans>
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">
Make your GTM team happy
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">with</HeadingPart>
<HeadingPart fontFamily="sans">a CRM they'll love</HeadingPart>
</Trans>
</Heading>
</ThreeCardsIntro>
<FeatureCards featureCards={FEATURE_CARDS} />
</ThreeCardsSection>
);
}
@@ -0,0 +1,82 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
import { HeadingPart, LinkButton } from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { WebGlMount } from '@/lib/visual-runtime';
import { AppPreview } from '@/sections/AppPreview';
import { TalkToUsButton } from '@/sections/ContactCal';
import { HeroBody, HeroCta, HeroHeading, HeroSection } from '@/templates/Hero';
import { HomeBackgroundHalftone } from '@/sections/Hero/visuals/components/HomeBackgroundHalftone';
import { theme } from '@/theme';
const HeroHeadingGroup = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${theme.spacing(3)};
width: 100%;
> *:last-child {
margin-top: 0;
}
`;
const HeroIntroGroup = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${theme.spacing(8)};
width: 100%;
`;
export function HomeHero() {
const i18n = getServerI18n();
return (
<HeroSection
scheme="muted"
background={
<WebGlMount priority>
<HomeBackgroundHalftone />
</WebGlMount>
}
>
<HeroIntroGroup data-halftone-exclude>
<HeroHeadingGroup>
<HeroHeading>
<Trans>
<HeadingPart fontFamily="serif">
Build your Enterprise CRM
</HeadingPart>
<HeadingPart fontFamily="sans">at AI Speed</HeadingPart>
</Trans>
</HeroHeading>
<HeroBody maxWidthMd={591} size="sm">
<Trans>
Twenty gives technical teams the building blocks for a custom CRM
that meets complex business needs and quickly adapts as the
business evolves.
</Trans>
</HeroBody>
</HeroHeadingGroup>
<HeroCta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="secondary"
label={msg`Talk to us`}
variant="outlined"
/>
</HeroCta>
</HeroIntroGroup>
<AppPreview visual={APP_PREVIEW_DATA.visual} />
</HeroSection>
);
}
@@ -0,0 +1,83 @@
import { Trans } from '@lingui/react/macro';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { ILLUSTRATION_CARDS } from '@/app/[locale]/(home)/three-cards-illustration.data';
import {
Body,
Eyebrow,
Heading,
HeadingPart,
} from '@/design-system/components';
import { IllustrationCards } from '@/sections/ThreeCards';
import { ThreeCardsIntro, ThreeCardsSection } from '@/templates/ThreeCards';
import { theme } from '@/theme';
const IntroContent = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(2)};
width: 100%;
`;
const IntroHeader = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(6)};
width: 100%;
`;
const headingClassName = css`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: ${theme.layout.editorial};
}
[data-family='sans'] {
letter-spacing: -0.02em;
}
`;
const bodyClassName = css`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 571px;
}
`;
export function HomeIllustrationThreeCards() {
return (
<ThreeCardsSection scheme="light">
<ThreeCardsIntro align="left">
<IntroContent>
<IntroHeader>
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>Stop settling for trade-offs.</Trans>
</HeadingPart>
</Eyebrow>
<Heading className={headingClassName} size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">
Assemble, iterate and adapt a robust CRM,
</HeadingPart>
<HeadingPart fontFamily="sans">
that's quick to flex
</HeadingPart>
</Trans>
</Heading>
</IntroHeader>
<Body className={bodyClassName} size="sm">
<Trans>
Compose your CRM and internal apps with a single extensibility
toolkit.
</Trans>
</Body>
</IntroContent>
</ThreeCardsIntro>
<IllustrationCards illustrationCards={ILLUSTRATION_CARDS} />
</ThreeCardsSection>
);
}
@@ -0,0 +1,57 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Eyebrow, HeadingPart } from '@/design-system/components';
import {
ProblemContent,
ProblemHeading,
ProblemPoints,
ProblemSection,
ProblemVisual,
type ProblemPointType,
} from '@/sections/Problem';
export function HomeProblem() {
const points: ProblemPointType[] = [
{
heading: (
<HeadingPart fontFamily="sans">
<Trans>The Giant Monolith</Trans>
</HeadingPart>
),
body: msg`Proprietary languages, slow deployment cycles, and "black box" logic.`,
},
{
heading: (
<HeadingPart fontFamily="sans">
<Trans>The In-house Burden</Trans>
</HeadingPart>
),
body: msg`It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.`,
},
];
return (
<ProblemSection>
<ProblemVisual />
<ProblemContent>
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>The Problem.</Trans>
</HeadingPart>
</Eyebrow>
<ProblemHeading>
<Trans>
<HeadingPart fontFamily="serif">
A custom CRM gives your org an edge,
</HeadingPart>
<HeadingPart fontFamily="sans">but building one</HeadingPart>
<HeadingPart fontFamily="serif">comes with</HeadingPart>
<HeadingPart fontFamily="sans">tradeoffs</HeadingPart>
</Trans>
</ProblemHeading>
<ProblemPoints points={points} />
</ProblemContent>
</ProblemSection>
);
}
@@ -0,0 +1,52 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Heading, HeadingPart } from '@/design-system/components';
import {
HomeStepperScrollSection,
type HomeStepperStepType,
} from '@/sections/HomeStepper';
export function HomeStepperSection() {
const steps: HomeStepperStepType[] = [
{
heading: (
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">
Begin with production-grade
</HeadingPart>
<HeadingPart fontFamily="sans">building blocks</HeadingPart>
</Trans>
</Heading>
),
body: msg`Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.`,
},
{
heading: (
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">Continue iteration</HeadingPart>
<HeadingPart fontFamily="sans">without friction</HeadingPart>
</Trans>
</Heading>
),
body: msg`Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.`,
},
{
heading: (
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">
Stay in control with our
</HeadingPart>
<HeadingPart fontFamily="sans">open-source software</HeadingPart>
</Trans>
</Heading>
),
body: msg`Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.`,
},
];
return <HomeStepperScrollSection steps={steps} />;
}
@@ -0,0 +1,24 @@
import { msg } from '@lingui/core/macro';
import { HOME_TESTIMONIALS } from '@/app/[locale]/(home)/testimonials.data';
import { getServerI18n } from '@/lib/i18n/server';
import {
TestimonialsCarousel,
TestimonialsHourglassVisual,
} from '@/sections/Testimonials';
import { TestimonialsSection } from '@/templates/Testimonials';
export function HomeTestimonials() {
const i18n = getServerI18n();
return (
<TestimonialsSection scheme="muted">
<TestimonialsCarousel
eyebrow={i18n._(msg`They are the real sales`)}
testimonials={HOME_TESTIMONIALS}
>
<TestimonialsHourglassVisual />
</TestimonialsCarousel>
</TestimonialsSection>
);
}
@@ -1,96 +1,23 @@
import { msg } from '@lingui/core/macro';
import { HELPED_CARDS } from '@/app/[locale]/(home)/helped.data';
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
import { Problem, type ProblemPointType } from '@/sections/Problem';
import { HOME_TESTIMONIALS } from '@/app/[locale]/(home)/testimonials.data';
import { FEATURE_CARDS } from '@/app/[locale]/(home)/three-cards-feature.data';
import { ILLUSTRATION_CARDS } from '@/app/[locale]/(home)/three-cards-illustration.data';
import { TalkToUsButton } from '@/sections/ContactCal';
import { HomeProblem } from '@/app/[locale]/(home)/_components/HomeProblem';
import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import {
Body,
Eyebrow,
Heading,
HeadingPart,
LinkButton,
} from '@/design-system/components';
import { TrustedBy } from '@/sections/TrustedBy';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Helped } from '@/sections/Helped';
import { Hero } from '@/sections/Hero';
import { HomeStepper, type HomeStepperStepType } from '@/sections/HomeStepper';
import { HomeHero } from '@/app/[locale]/(home)/_components/HomeHero';
import { HomeStepperSection } from '@/app/[locale]/(home)/_components/HomeStepperSection';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { Testimonials } from '@/sections/Testimonials';
import { ThreeCards } from '@/sections/ThreeCards';
import { HomeTestimonials } from '@/app/[locale]/(home)/_components/HomeTestimonials';
import { HomeFeatureThreeCards } from '@/app/[locale]/(home)/_components/HomeFeatureThreeCards';
import { HomeIllustrationThreeCards } from '@/app/[locale]/(home)/_components/HomeIllustrationThreeCards';
import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
export const generateMetadata = buildRouteMetadata('home');
const HOME_TOP_BACKGROUND_COLOR = '#F4F4F4';
const HeroHeadingGroup = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${theme.spacing(3)};
width: 100%;
> *:last-child {
margin-top: 0;
}
`;
const HeroIntroGroup = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${theme.spacing(8)};
width: 100%;
`;
const ThreeCardsIllustrationIntroContent = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(2)};
width: 100%;
`;
const ThreeCardsIllustrationIntroHeader = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(6)};
width: 100%;
`;
const threeCardsIllustrationHeadingClassName = css`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: ${theme.layout.editorial};
}
[data-family='sans'] {
letter-spacing: -0.02em;
}
`;
const threeCardsIllustrationBodyClassName = css`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 571px;
}
`;
type HomePageProps = {
params: Promise<LocaleRouteParams>;
};
@@ -101,66 +28,6 @@ export default async function HomePage({ params }: HomePageProps) {
fetchCommunityStats(),
]);
const PROBLEM_POINTS: ProblemPointType[] = [
{
heading: (
<HeadingPart fontFamily="sans">
{i18n._(msg`The Giant Monolith`)}
</HeadingPart>
),
body: msg`Proprietary languages, slow deployment cycles, and "black box" logic.`,
},
{
heading: (
<HeadingPart fontFamily="sans">
{i18n._(msg`The In-house Burden`)}
</HeadingPart>
),
body: msg`It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.`,
},
];
const HOME_STEPPER_STEPS: HomeStepperStepType[] = [
{
heading: (
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Begin with production-grade`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`building blocks`)}
</HeadingPart>
</Heading>
),
body: msg`Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.`,
},
{
heading: (
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Continue iteration`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`without friction`)}
</HeadingPart>
</Heading>
),
body: msg`Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.`,
},
{
heading: (
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Stay in control with our`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`open-source software`)}
</HeadingPart>
</Heading>
),
body: msg`Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.`,
},
];
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
@@ -192,186 +59,28 @@ export default async function HomePage({ params }: HomePageProps) {
href="/illustrations/home/helped/money.glb"
as="fetch"
/>
<Menu.Root
<Menu
backgroundColor={HOME_TOP_BACKGROUND_COLOR}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.Root scheme="muted" showHomeBackground>
<HeroIntroGroup data-halftone-exclude>
<HeroHeadingGroup>
<Hero.Heading page={Pages.Home}>
<HeadingPart fontFamily="serif">
{i18n._(msg`Build your Enterprise CRM`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`at\u00A0AI\u00A0Speed`)}
</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.Home} size="sm">
{i18n._(
msg`Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.`,
)}
</Hero.Body>
</HeroHeadingGroup>
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="secondary"
label={msg`Talk to us`}
variant="outlined"
/>
</Hero.Cta>
</HeroIntroGroup>
<Hero.AppPreview visual={APP_PREVIEW_DATA.visual} />
</Hero.Root>
<TrustedBy.Root
separator={i18n._(msg`trusted by`)}
logos={TRUSTED_BY_LOGOS}
clientCount={i18n._(msg`+10k others`)}
/>
<Problem.Root>
<Problem.Visual />
<Problem.Content>
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`The Problem.`)}
</HeadingPart>
</Eyebrow>
<Problem.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`A custom CRM gives your org an edge,`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`but building one`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="serif">
{i18n._(msg`comes with`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`tradeoffs`)}
</HeadingPart>
</Problem.Heading>
<Problem.Points points={PROBLEM_POINTS} />
</Problem.Content>
</Problem.Root>
<HomeHero />
<ThreeCards.Root scheme="light">
<ThreeCards.Intro page={Pages.Home} align="left">
<ThreeCardsIllustrationIntroContent>
<ThreeCardsIllustrationIntroHeader>
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Stop settling for trade-offs.`)}
</HeadingPart>
</Eyebrow>
<Heading
className={threeCardsIllustrationHeadingClassName}
size="lg"
weight="light"
>
<HeadingPart fontFamily="serif">
{i18n._(msg`Assemble, iterate and adapt a robust CRM,`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`that's quick to flex`)}
</HeadingPart>
</Heading>
</ThreeCardsIllustrationIntroHeader>
<Body className={threeCardsIllustrationBodyClassName} size="sm">
{i18n._(
msg`Compose your CRM and internal apps with a single extensibility toolkit.`,
)}
</Body>
</ThreeCardsIllustrationIntroContent>
</ThreeCards.Intro>
<ThreeCards.IllustrationCards illustrationCards={ILLUSTRATION_CARDS} />
</ThreeCards.Root>
<TrustedBy />
<HomeStepper.ScrollSection steps={HOME_STEPPER_STEPS} />
<HomeProblem />
<ThreeCards.Root scheme="light">
<ThreeCards.Intro page={Pages.Home} align="center">
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Skip the clunky UX that always comes with custom.`)}
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Make your GTM team happy`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`with`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`a CRM they'll love`)}
</HeadingPart>
</Heading>
</ThreeCards.Intro>
<ThreeCards.FeatureCards featureCards={FEATURE_CARDS} />
</ThreeCards.Root>
<HomeIllustrationThreeCards />
<Helped.Root scheme="muted">
<Helped.Scene cards={HELPED_CARDS} />
</Helped.Root>
<HomeStepperSection />
<Testimonials.Root scheme="muted">
<Testimonials.Carousel
eyebrow={i18n._(msg`They are the real sales`)}
testimonials={HOME_TESTIMONIALS}
>
<Testimonials.HourglassVisual />
</Testimonials.Carousel>
</Testimonials.Root>
<HomeFeatureThreeCards />
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary">
<HeadingPart fontFamily="sans">
{i18n._(msg`Any Questions?`)}
</HeadingPart>
</Eyebrow>
<Faq.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`Stop fighting custom.`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`Start building, with Twenty`)}
</HeadingPart>
</Faq.Heading>
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="primary"
label={msg`Talk to us`}
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_QUESTIONS} />
</Faq.Root>
<Helped scheme="muted" cards={HELPED_CARDS} />
<HomeTestimonials />
<Faq />
</>
);
}
@@ -4,12 +4,11 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { getPublishedArticle, getPublishedArticles } from '@/lib/articles';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { type LocaleRouteParams } from '@/lib/i18n/utils/get-route-i18n';
import { localeToUrlSegment } from '@/lib/i18n/utils/website-locale-segments';
import { resolveLocaleParam } from '@/lib/i18n';
import { type LocaleRouteParams } from '@/lib/i18n/server';
import { localeToUrlSegment, resolveLocaleParam } from '@/lib/i18n';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { buildArticleJsonLd, buildPageMetadata, JsonLd } from '@/lib/seo';
import { Articles } from '@/sections/Articles';
import { ArticlesArticle } from '@/sections/Articles';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { theme } from '@/theme';
@@ -68,18 +67,11 @@ export default async function ArticlePage({ params }: ArticlePageProps) {
return (
<>
<JsonLd data={buildArticleJsonLd(post)} />
<Menu.Root
<Menu
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Articles.Article post={post} />
/>
<ArticlesArticle post={post} />
</>
);
}
@@ -0,0 +1,20 @@
import { HeadingPart } from '@/design-system/components';
import { HeroBody, HeroHeading, HeroSection } from '@/templates/Hero';
// Articles is English-only (SOURCE_LOCALE-gated), so the copy is literal.
export function ArticlesHero() {
return (
<HeroSection scheme="muted">
<HeroHeading>
<HeadingPart fontFamily="serif">Ideas on</HeadingPart>
<br />
<HeadingPart fontFamily="serif">open-source</HeadingPart>
<HeadingPart fontFamily="sans">CRM</HeadingPart>
</HeroHeading>
<HeroBody maxWidthMd={550}>
Ideas from the team building Twenty on open source CRM, GTM systems, and
building software that lasts.
</HeroBody>
</HeroSection>
);
}
@@ -1,27 +1,21 @@
import { msg } from '@lingui/core/macro';
import { notFound } from 'next/navigation';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { HeadingPart } from '@/design-system/components';
import { getPublishedArticles } from '@/lib/articles';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { resolveLocaleParam } from '@/lib/i18n';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import {
buildArticleListJsonLd,
buildBreadcrumbListJsonLd,
buildRouteMetadata,
JsonLd,
} from '@/lib/seo';
import { Articles } from '@/sections/Articles';
import { Hero } from '@/sections/Hero';
import { ArticlesIndex } from '@/sections/Articles';
import { ArticlesHero } from '@/app/[locale]/articles/_components/ArticlesHero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { TrustedBy } from '@/sections/TrustedBy';
import { css } from '@linaria/core';
export const generateMetadata = buildRouteMetadata('articles');
@@ -66,7 +60,10 @@ export default async function ArticlesPage({ params }: ArticlesPageProps) {
notFound();
}
const [i18n, stats] = await Promise.all([
// getRouteI18n sets the request-scoped i18n context that TrustedBy/Menu/
// Articles read; this page's own copy is English-only (SOURCE_LOCALE-gated
// above), so the returned instance is intentionally unused here.
const [, stats] = await Promise.all([
getRouteI18n(params),
fetchCommunityStats(),
]);
@@ -87,41 +84,20 @@ export default async function ArticlesPage({ params }: ArticlesPageProps) {
{posts.length > 0 ? (
<JsonLd data={buildArticleListJsonLd(posts)} />
) : null}
<Menu.Root
<Menu
backgroundColor={ARTICLES_TOP_BACKGROUND_COLOR}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
/>
<div className={pageRevealClassName}>
<Hero.Root scheme="muted">
<Hero.Heading page={Pages.Articles}>
<HeadingPart fontFamily="serif">Ideas on</HeadingPart>
<br />
<HeadingPart fontFamily="serif">open-source</HeadingPart>{' '}
<HeadingPart fontFamily="sans">CRM</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.Articles}>
Ideas from the team building Twenty on open source CRM, GTM systems,
and building software that lasts.
</Hero.Body>
</Hero.Root>
<TrustedBy.Root
<ArticlesHero />
<TrustedBy
cardBackgroundColor={ARTICLES_TOP_BACKGROUND_COLOR}
compactBottom
separator={i18n._(msg`trusted by`)}
logos={TRUSTED_BY_LOGOS}
clientCount={i18n._(msg`+10k others`)}
/>
</div>
<Articles.Index posts={posts} />
<ArticlesIndex posts={posts} />
</>
);
}
@@ -1,10 +1,7 @@
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
import { CustomersCaseStudyPageLayout } from '@/app/[locale]/customers/_components/CustomersCaseStudyPageLayout';
import { Heading, HeadingPart } from '@/design-system/components';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { buildLocalizedMetadata } from '@/lib/seo';
import { msg } from '@lingui/core/macro';
@@ -39,10 +36,10 @@ export default async function NineDotsCaseStudyPage({
<Heading as="h1" size="xl" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A real estate agency on WhatsApp`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="serif">{i18n._(msg`built a`)}</HeadingPart>
<br />
<HeadingPart fontFamily="sans">{i18n._(msg`CRM`)}</HeadingPart>{' '}
<HeadingPart fontFamily="sans">{i18n._(msg`CRM`)}</HeadingPart>
<HeadingPart fontFamily="serif">{i18n._(msg`around it`)}</HeadingPart>
</Heading>
),
@@ -71,7 +68,7 @@ export default async function NineDotsCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`When the channel is`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`the business`)}
</HeadingPart>
@@ -92,7 +89,7 @@ export default async function NineDotsCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`The CRM as a`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`control hub`)}
</HeadingPart>
@@ -112,7 +109,7 @@ export default async function NineDotsCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="sans">
{i18n._(msg`150 hours`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="serif">
{i18n._(msg`saved every month`)}
</HeadingPart>
@@ -135,5 +132,10 @@ export default async function NineDotsCaseStudyPage({
},
};
return <CaseStudyPageLayout caseStudy={caseStudy} path="/customers/9dots" />;
return (
<CustomersCaseStudyPageLayout
caseStudy={caseStudy}
path="/customers/9dots"
/>
);
}
@@ -1,21 +1,28 @@
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy';
import {
CaseStudyBody,
CaseStudyHero,
CaseStudyHighlights,
CaseStudySectionNav,
CaseStudyTextBlock,
CaseStudyVisualBlock,
} from '@/sections/CaseStudy';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { theme } from '@/theme';
import { CustomersCaseStudySignoff } from './CustomersCaseStudySignoff';
type CaseStudyPageLayoutProps = {
type CustomersCaseStudyPageLayoutProps = {
caseStudy: CaseStudyData;
path: string;
};
export async function CaseStudyPageLayout({
export async function CustomersCaseStudyPageLayout({
caseStudy,
path,
}: CaseStudyPageLayoutProps) {
}: CustomersCaseStudyPageLayoutProps) {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
const palette = getCaseStudyPalette(path);
@@ -26,7 +33,7 @@ export async function CaseStudyPageLayout({
const sectionId = `case-study-section-${storySectionIndex}`;
storySectionIndex += 1;
return (
<CaseStudy.TextBlock
<CaseStudyTextBlock
key={index}
block={block}
isLast={index === caseStudy.sections.length - 1}
@@ -35,7 +42,7 @@ export async function CaseStudyPageLayout({
);
}
return (
<CaseStudy.VisualBlock
<CaseStudyVisualBlock
key={index}
block={block}
isLast={index === caseStudy.sections.length - 1}
@@ -45,32 +52,26 @@ export async function CaseStudyPageLayout({
return (
<>
<Menu.Root
<Menu
backgroundColor={theme.colors.secondary.background[100]}
scheme="secondary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="secondary" />
<Menu.Nav scheme="secondary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="secondary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="secondary" />
</Menu.Root>
/>
<CaseStudy.Hero
<CaseStudyHero
dashColor={palette.dashColor}
hero={caseStudy.hero}
hoverDashColor={palette.hoverDashColor}
/>
<CaseStudy.Highlights
<CaseStudyHighlights
industry={caseStudy.hero.industry}
kpis={caseStudy.hero.kpis}
/>
<CaseStudy.Body>{sectionBlocks}</CaseStudy.Body>
<CaseStudyBody>{sectionBlocks}</CaseStudyBody>
<CaseStudy.SectionNav items={caseStudy.tableOfContents} />
<CaseStudySectionNav items={caseStudy.tableOfContents} />
<CustomersCaseStudySignoff />
</>
);
@@ -1,37 +1,50 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
HeadingPart,
LinkButton,
ResponsiveLineBreak,
} from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { TalkToUsButton } from '@/sections/ContactCal';
import { HeadingPart, LinkButton } from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { Pages } from '@/lib/pages';
import { Signoff } from '@/sections/Signoff';
import { Signoff } from '@/templates/Signoff';
export function CustomersCaseStudySignoff() {
const i18n = getServerI18n();
return (
<Signoff.Root scheme="muted" page={Pages.Partners}>
<Signoff.Heading page={Pages.Partners}>
<Signoff
scheme="muted"
centered
heading={
<HeadingPart fontFamily="serif">
{i18n._(msg`Ready to grow\nwith Twenty?`)}
<Trans>
Ready to grow
<ResponsiveLineBreak />
with Twenty?
</Trans>
</HeadingPart>
</Signoff.Heading>
<Signoff.Body page={Pages.Partners}>
{i18n._(
msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`,
)}
</Signoff.Body>
<Signoff.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="secondary"
label={msg`Talk to us`}
variant="outlined"
/>
</Signoff.Cta>
</Signoff.Root>
}
body={
<Trans>
Join the teams that chose to own their CRM.
<ResponsiveLineBreak />
Start building with Twenty today.
</Trans>
}
>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="secondary"
label={msg`Talk to us`}
variant="outlined"
/>
</Signoff>
);
}
@@ -0,0 +1,25 @@
import { Trans } from '@lingui/react/macro';
import { HeadingPart } from '@/design-system/components';
import { HeroBody, HeroHeading, HeroSection } from '@/templates/Hero';
export function CustomersCatalogHero() {
return (
<HeroSection scheme="muted">
<HeroHeading>
<Trans>
<HeadingPart fontFamily="serif">See how teams</HeadingPart>
<br />
<HeadingPart fontFamily="serif">build</HeadingPart>
<HeadingPart fontFamily="sans">on Twenty</HeadingPart>
</Trans>
</HeroHeading>
<HeroBody maxWidthMd={550}>
<Trans>
Real stories from real teams about how they shaped Twenty to fit their
workflow and accelerated their growth.
</Trans>
</HeroBody>
</HeroSection>
);
}
@@ -0,0 +1,48 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
HeadingPart,
LinkButton,
ResponsiveLineBreak,
} from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { TalkToUsButton } from '@/sections/ContactCal';
import { Signoff } from '@/templates/Signoff';
export function CustomersCatalogSignoff() {
const i18n = getServerI18n();
return (
<Signoff
scheme="light"
centered
heading={
<Trans>
<HeadingPart fontFamily="serif">Ready to build</HeadingPart>
<br />
<HeadingPart fontFamily="sans">your own story?</HeadingPart>
</Trans>
}
body={
<Trans>
Join the teams that chose to own their CRM.
<ResponsiveLineBreak />
Start building with Twenty today.
</Trans>
}
>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="secondary"
label={msg`Talk to us`}
variant="outlined"
/>
</Signoff>
);
}
@@ -1,12 +1,9 @@
import { msg } from '@lingui/core/macro';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
import { CustomersCaseStudyPageLayout } from '@/app/[locale]/customers/_components/CustomersCaseStudyPageLayout';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.actEducation;
@@ -62,7 +59,7 @@ export default async function ActEducationCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`When the vendor`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`pulled the plug`)}
</HeadingPart>
@@ -82,7 +79,7 @@ export default async function ActEducationCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`No more renting someone else's`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`structure`)}
</HeadingPart>
@@ -101,7 +98,7 @@ export default async function ActEducationCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Control without`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`the overhead`)}
</HeadingPart>
@@ -119,7 +116,7 @@ export default async function ActEducationCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Costs down more than`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">{i18n._(msg`90%`)}</HeadingPart>
</Heading>
),
@@ -142,7 +139,7 @@ export default async function ActEducationCaseStudyPage({
};
return (
<CaseStudyPageLayout
<CustomersCaseStudyPageLayout
caseStudy={caseStudy}
path="/customers/act-education"
/>
@@ -1,12 +1,9 @@
import { msg } from '@lingui/core/macro';
import { Heading, HeadingPart } from '@/design-system/components';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { buildLocalizedMetadata } from '@/lib/seo';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
import { CustomersCaseStudyPageLayout } from '@/app/[locale]/customers/_components/CustomersCaseStudyPageLayout';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.alternativePartners;
@@ -65,7 +62,7 @@ export default async function AlternativePartnersCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`AI in the`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`migration workflow`)}
</HeadingPart>
@@ -84,7 +81,7 @@ export default async function AlternativePartnersCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Self-hosted`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`means control`)}
</HeadingPart>
@@ -106,7 +103,7 @@ export default async function AlternativePartnersCaseStudyPage({
};
return (
<CaseStudyPageLayout
<CustomersCaseStudyPageLayout
caseStudy={caseStudy}
path="/customers/alternative-partners"
/>
@@ -1,12 +1,9 @@
import { msg } from '@lingui/core/macro';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
import { CustomersCaseStudyPageLayout } from '@/app/[locale]/customers/_components/CustomersCaseStudyPageLayout';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.elevateConsulting;
@@ -43,7 +40,7 @@ export default async function ElevateConsultingCaseStudyPage({
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`API backbone`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="serif">
{i18n._(msg`of a go-to-market stack`)}
</HeadingPart>
@@ -73,7 +70,7 @@ export default async function ElevateConsultingCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`From documents to`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`open APIs`)}
</HeadingPart>
@@ -92,8 +89,8 @@ export default async function ElevateConsultingCaseStudyPage({
eyebrow: msg`Integration`,
heading: (
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">{i18n._(msg`One`)}</HeadingPart>{' '}
<HeadingPart fontFamily="sans">{i18n._(msg`API`)}</HeadingPart>{' '}
<HeadingPart fontFamily="serif">{i18n._(msg`One`)}</HeadingPart>
<HeadingPart fontFamily="sans">{i18n._(msg`API`)}</HeadingPart>
<HeadingPart fontFamily="serif">
{i18n._(msg`to rule them all`)}
</HeadingPart>
@@ -114,7 +111,7 @@ export default async function ElevateConsultingCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Workflows that`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`actually get used`)}
</HeadingPart>
@@ -131,7 +128,7 @@ export default async function ElevateConsultingCaseStudyPage({
eyebrow: msg`What is next`,
heading: (
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">{i18n._(msg`Beyond`)}</HeadingPart>{' '}
<HeadingPart fontFamily="serif">{i18n._(msg`Beyond`)}</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`internal rollout`)}
</HeadingPart>
@@ -156,7 +153,7 @@ export default async function ElevateConsultingCaseStudyPage({
};
return (
<CaseStudyPageLayout
<CustomersCaseStudyPageLayout
caseStudy={caseStudy}
path="/customers/elevate-consulting"
/>
@@ -1,12 +1,9 @@
import { msg } from '@lingui/core/macro';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
import { CustomersCaseStudyPageLayout } from '@/app/[locale]/customers/_components/CustomersCaseStudyPageLayout';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.netzero;
@@ -41,7 +38,7 @@ export default async function NetZeroCaseStudyPage({
{i18n._(msg`A CRM that`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">{i18n._(msg`grows`)}</HeadingPart>{' '}
<HeadingPart fontFamily="sans">{i18n._(msg`grows`)}</HeadingPart>
<HeadingPart fontFamily="serif">{i18n._(msg`with you`)}</HeadingPart>
</Heading>
),
@@ -64,7 +61,7 @@ export default async function NetZeroCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`The right`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`foundation`)}
</HeadingPart>
@@ -83,7 +80,7 @@ export default async function NetZeroCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A business that does not fit a`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">{i18n._(msg`template`)}</HeadingPart>
</Heading>
),
@@ -101,7 +98,7 @@ export default async function NetZeroCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`From simple to`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">{i18n._(msg`advanced`)}</HeadingPart>
</Heading>
),
@@ -118,7 +115,7 @@ export default async function NetZeroCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`The bet is`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`paying off`)}
</HeadingPart>
@@ -143,6 +140,9 @@ export default async function NetZeroCaseStudyPage({
};
return (
<CaseStudyPageLayout caseStudy={caseStudy} path="/customers/netzero" />
<CustomersCaseStudyPageLayout
caseStudy={caseStudy}
path="/customers/netzero"
/>
);
}
@@ -1,20 +1,13 @@
import { msg } from '@lingui/core/macro';
import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { TalkToUsButton } from '@/sections/ContactCal';
import { Faq } from '@/sections/Faq';
import { TrustedBy } from '@/sections/TrustedBy';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { Eyebrow, HeadingPart, LinkButton } from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog';
import { Hero } from '@/sections/Hero';
import { CaseStudyCatalogGrid } from '@/sections/CaseStudyCatalog';
import { CustomersCatalogHero } from '@/app/[locale]/customers/_components/CustomersCatalogHero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { Signoff } from '@/sections/Signoff';
import { CustomersCatalogSignoff } from '@/app/[locale]/customers/_components/CustomersCatalogSignoff';
import { buildRouteMetadata } from '@/lib/seo';
import { css } from '@linaria/core';
@@ -55,7 +48,9 @@ type CaseStudiesCatalogPageProps = {
export default async function CaseStudiesCatalogPage({
params,
}: CaseStudiesCatalogPageProps) {
const [i18n, stats] = await Promise.all([
// getRouteI18n sets the request-scoped i18n context the page's components
// read; the page renders no copy of its own, so the instance is unused here.
const [, stats] = await Promise.all([
getRouteI18n(params),
fetchCommunityStats(),
]);
@@ -63,111 +58,24 @@ export default async function CaseStudiesCatalogPage({
return (
<>
<Menu.Root
<Menu
backgroundColor={CUSTOMERS_TOP_BACKGROUND_COLOR}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
/>
<div className={pageRevealClassName}>
<Hero.Root scheme="muted">
<Hero.Heading page={Pages.CaseStudies}>
<HeadingPart fontFamily="serif">
{i18n._(msg`See how teams`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`build`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`on Twenty`)}
</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.CaseStudies}>
{i18n._(
msg`Real stories from real teams about how they shaped Twenty to fit their workflow and accelerated their growth.`,
)}
</Hero.Body>
</Hero.Root>
<TrustedBy.Root
<CustomersCatalogHero />
<TrustedBy
cardBackgroundColor={CUSTOMERS_TOP_BACKGROUND_COLOR}
compactBottom
separator={i18n._(msg`trusted by`)}
logos={TRUSTED_BY_LOGOS}
clientCount={i18n._(msg`+10k others`)}
/>
</div>
<CaseStudyCatalog.Grid compactTop entries={CASE_STUDY_CATALOG_ENTRIES} />
<CaseStudyCatalogGrid compactTop entries={CASE_STUDY_CATALOG_ENTRIES} />
<Signoff.Root scheme="light" page={Pages.Partners}>
<Signoff.Heading page={Pages.Partners}>
<HeadingPart fontFamily="serif">
{i18n._(msg`Ready to build`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`your own story?`)}
</HeadingPart>
</Signoff.Heading>
<Signoff.Body page={Pages.Partners}>
{i18n._(
msg`Join the teams that chose to own their CRM.\nStart building with Twenty today.`,
)}
</Signoff.Body>
<Signoff.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="secondary"
label={msg`Talk to us`}
variant="outlined"
/>
</Signoff.Cta>
</Signoff.Root>
<CustomersCatalogSignoff />
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary">
<HeadingPart fontFamily="sans">
{i18n._(msg`Any Questions?`)}
</HeadingPart>
</Eyebrow>
<Faq.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`Stop fighting custom.`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`Start building, with Twenty`)}
</HeadingPart>
</Faq.Heading>
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="primary"
label={msg`Talk to us`}
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_QUESTIONS} />
</Faq.Root>
<Faq />
</>
);
}
@@ -1,12 +1,9 @@
import { msg } from '@lingui/core/macro';
import { CASE_STUDY_HERO_IMAGES, type CaseStudyData } from '@/lib/customers';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { buildLocalizedMetadata } from '@/lib/seo';
import { Heading, HeadingPart } from '@/design-system/components';
import { CaseStudyPageLayout } from '@/app/[locale]/customers/_components/CaseStudyPageLayout';
import { CustomersCaseStudyPageLayout } from '@/app/[locale]/customers/_components/CustomersCaseStudyPageLayout';
const HERO_IMAGE = CASE_STUDY_HERO_IMAGES.w3villa;
@@ -62,7 +59,7 @@ export default async function W3villaCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Scale without`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">
{i18n._(msg`breaking operations`)}
</HeadingPart>
@@ -82,7 +79,7 @@ export default async function W3villaCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Focus on the use case, not the`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">{i18n._(msg`plumbing`)}</HeadingPart>
</Heading>
),
@@ -99,7 +96,7 @@ export default async function W3villaCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A platform ready to`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="sans">{i18n._(msg`grow`)}</HeadingPart>
</Heading>
),
@@ -116,7 +113,7 @@ export default async function W3villaCaseStudyPage({
<Heading size="md" weight="light">
<HeadingPart fontFamily="sans">
{i18n._(msg`Zero manual work`)}
</HeadingPart>{' '}
</HeadingPart>
<HeadingPart fontFamily="serif">
{i18n._(msg`at the core`)}
</HeadingPart>
@@ -141,6 +138,9 @@ export default async function W3villaCaseStudyPage({
};
return (
<CaseStudyPageLayout caseStudy={caseStudy} path="/customers/w3villa" />
<CustomersCaseStudyPageLayout
caseStudy={caseStudy}
path="/customers/w3villa"
/>
);
}
@@ -1,6 +1,7 @@
'use client';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
BaseButton,
buttonBaseStyles,
@@ -159,7 +160,7 @@ export function EnterpriseActivateClient() {
<ContentStack>
{loading && (
<Body size="sm" variant="body-paragraph">
{i18n._(msg`Activating your enterprise license…`)}
<Trans>Activating your enterprise license</Trans>
</Body>
)}
@@ -168,14 +169,14 @@ export function EnterpriseActivateClient() {
{result !== null && (
<>
<Body className={successLeadClassName} size="md" weight="medium">
{i18n._(
msg`Your enterprise license has been activated successfully.`,
)}
<Trans>
Your enterprise license has been activated successfully.
</Trans>
</Body>
<LicenseeRow>
<Body as="span" size="sm" weight="medium">
{i18n._(msg`Licensee:`)}
<Trans>Licensee:</Trans>
</Body>{' '}
<LicenseeValue>{result.licensee}</LicenseeValue>
</LicenseeRow>
@@ -183,13 +184,14 @@ export function EnterpriseActivateClient() {
<KeySection>
<Heading as="h2" size="xs" weight="medium">
<HeadingPart fontFamily="sans">
{i18n._(msg`Your enterprise key`)}
<Trans>Your enterprise key</Trans>
</HeadingPart>
</Heading>
<Body size="sm" variant="body-paragraph">
{i18n._(
msg`Copy this key and paste it into your Twenty self-hosted instance settings.`,
)}
<Trans>
Copy this key and paste it into your Twenty self-hosted instance
settings.
</Trans>
</Body>
<KeyBlock>
@@ -215,25 +217,25 @@ export function EnterpriseActivateClient() {
<NextStepsBox>
<Heading as="h3" size="xs" weight="medium">
<HeadingPart fontFamily="sans">
{i18n._(msg`Next steps`)}
<Trans>Next steps</Trans>
</HeadingPart>
</Heading>
<NextStepsList>
<li className={nextStepItemClassName}>
<Body size="sm">
{i18n._(msg`Copy the enterprise key above.`)}
<Trans>Copy the enterprise key above.</Trans>
</Body>
</li>
<li className={nextStepItemClassName}>
<Body size="sm">
{i18n._(
msg`Open your Twenty self-hosted instance Settings → Enterprise.`,
)}
<Trans>
Open your Twenty self-hosted instance Settings Enterprise.
</Trans>
</Body>
</li>
<li className={nextStepItemClassName}>
<Body size="sm">
{i18n._(msg`Paste the key and click Activate.`)}
<Trans>Paste the key and click Activate.</Trans>
</Body>
</li>
</NextStepsList>
@@ -0,0 +1,28 @@
import { Trans } from '@lingui/react/macro';
import { Eyebrow, HeadingPart } from '@/design-system/components';
import { HeroBody, HeroHeading, HeroSection } from '@/templates/Hero';
export function EnterpriseActivateHero() {
return (
<HeroSection scheme="muted">
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>Self-hosting</Trans>
</HeadingPart>
</Eyebrow>
<HeroHeading>
<Trans>
<HeadingPart fontFamily="serif">Enterprise</HeadingPart>
<HeadingPart fontFamily="sans">activation</HeadingPart>
</Trans>
</HeroHeading>
<HeroBody maxWidthMd={500}>
<Trans>
Your checkout is complete. Follow the steps below to copy your license
key into your Twenty instance.
</Trans>
</HeroBody>
</HeroSection>
);
}
@@ -1,19 +1,10 @@
import { msg } from '@lingui/core/macro';
import { EnterpriseActivateClient } from '@/app/[locale]/enterprise/activate/EnterpriseActivateClient';
import {
Body,
Container,
Eyebrow,
HeadingPart,
} from '@/design-system/components';
import { Body, Container } from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Hero } from '@/sections/Hero';
import { EnterpriseActivateHero } from '@/app/[locale]/enterprise/activate/_components/EnterpriseActivateHero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { theme } from '@/theme';
import { buildRouteMetadata } from '@/lib/seo';
@@ -66,36 +57,9 @@ export default async function EnterpriseActivatePage({
return (
<>
<Menu.Root
backgroundColor="#F3F3F3"
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Menu backgroundColor="#F3F3F3" socialLinks={menuSocialLinks} />
<Hero.Root scheme="muted">
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Self-hosting`)}
</HeadingPart>
</Eyebrow>
<Hero.Heading page={Pages.Pricing}>
<HeadingPart fontFamily="serif">
{i18n._(msg`Enterprise`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">{i18n._(msg`activation`)}</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.Pricing}>
{i18n._(
msg`Your checkout is complete. Follow the steps below to copy your license key into your Twenty instance.`,
)}
</Hero.Body>
</Hero.Root>
<EnterpriseActivateHero />
<ActivatePageContent>
<Container>
@@ -18,13 +18,12 @@ import { ScrollToTopOnRouteChange } from '@/app/_components/ScrollToTopOnRouteCh
import {
I18nProvider,
localeToUrlSegment,
PUBLIC_APP_LOCALE_LIST,
WEBSITE_LOCALE_LIST,
resolveLocaleParam,
} from '@/lib/i18n';
import { getLocaleMessages } from '@/lib/i18n/utils/messages-by-locale';
import { setServerI18n } from '@/lib/i18n/utils/set-server-i18n';
import { getLocaleMessages, setServerI18n } from '@/lib/i18n/server';
import { ContactCalModalRoot } from '@/sections/ContactCal';
import { Footer, FOOTER_DATA } from '@/sections/Footer';
import { Footer } from '@/sections/Footer';
import { PartnerApplicationModalRoot } from '@/sections/PartnerApplication';
const hostGrotesk = Host_Grotesk({
@@ -124,7 +123,7 @@ type LocaleLayoutParams = { locale: string };
export const dynamicParams = false;
export const generateStaticParams = (): LocaleLayoutParams[] =>
PUBLIC_APP_LOCALE_LIST.map((locale) => ({
WEBSITE_LOCALE_LIST.map((locale) => ({
locale: localeToUrlSegment(locale),
}));
@@ -162,14 +161,7 @@ const LocaleLayout = async ({
<ScrollToTopOnRouteChange />
<StyledMain>{children}</StyledMain>
<FooterVisibilityGate>
<Footer.Root>
<Footer.Logo />
<Footer.Nav groups={FOOTER_DATA.navGroups} />
<Footer.Bottom
copyright={FOOTER_DATA.bottom.copyright}
links={FOOTER_DATA.socialLinks}
/>
</Footer.Root>
<Footer />
</FooterVisibilityGate>
</PartnerApplicationModalRoot>
</ContactCalModalRoot>
@@ -0,0 +1,30 @@
import { Trans } from '@lingui/react/macro';
import { PartnerHeroCtas } from '@/app/[locale]/partners/components/PartnerApplication';
import { HeadingPart } from '@/design-system/components';
import { HeroBody, HeroCta, HeroHeading, HeroSection } from '@/templates/Hero';
import { PartnerVisual } from '@/sections/Hero';
export function PartnerHero() {
return (
<HeroSection scheme="light">
<HeroHeading>
<Trans>
<HeadingPart fontFamily="serif">Become</HeadingPart>
<br />
<HeadingPart fontFamily="sans">our partner</HeadingPart>
</Trans>
</HeroHeading>
<HeroBody maxWidthMd={500}>
<Trans>
We're building the #1 Open Source CRM, but we can't do it alone. Join
our partner ecosystem and grow with us.
</Trans>
</HeroBody>
<HeroCta>
<PartnerHeroCtas />
</HeroCta>
<PartnerVisual />
</HeroSection>
);
}
@@ -0,0 +1,30 @@
import { Trans } from '@lingui/react/macro';
import { PartnerSignoffCtas } from '@/app/[locale]/partners/components/PartnerApplication';
import { HeadingPart, ResponsiveLineBreak } from '@/design-system/components';
import { Signoff } from '@/templates/Signoff';
export function PartnerSignoff() {
return (
<Signoff
scheme="light"
centered
heading={
<Trans>
<HeadingPart fontFamily="serif">Ready to grow</HeadingPart>
<br />
<HeadingPart fontFamily="sans">with Twenty?</HeadingPart>
</Trans>
}
body={
<Trans>
Join our partner ecosystem and help businesses
<ResponsiveLineBreak />
take control of their CRM.
</Trans>
}
>
<PartnerSignoffCtas />
</Signoff>
);
}
@@ -0,0 +1,28 @@
import { msg } from '@lingui/core/macro';
import { PARTNER_TESTIMONIALS } from '@/app/[locale]/partners/testimonials.data';
import { getServerI18n } from '@/lib/i18n/server';
import {
TestimonialsPartnerCarousel,
TestimonialsPartnerVisual,
} from '@/sections/Testimonials';
import { TestimonialsSection } from '@/templates/Testimonials';
import { theme } from '@/theme';
export function PartnerTestimonials() {
const i18n = getServerI18n();
return (
<TestimonialsSection
scheme="muted"
shapeFillColor={theme.colors.secondary.background[100]}
>
<TestimonialsPartnerCarousel
eyebrow={i18n._(msg`Join our growing partner ecosystem`)}
testimonials={PARTNER_TESTIMONIALS}
>
<TestimonialsPartnerVisual />
</TestimonialsPartnerCarousel>
</TestimonialsSection>
);
}
@@ -0,0 +1,46 @@
import { Trans } from '@lingui/react/macro';
import { PARTNER_ILLUSTRATION_CARDS } from '@/app/[locale]/partners/three-cards-illustration.data';
import { Eyebrow, Heading, HeadingPart } from '@/design-system/components';
import {
IllustrationCards,
type ThreeCardsScrollLayoutOptions,
} from '@/sections/ThreeCards';
import { ThreeCardsIntro, ThreeCardsSection } from '@/templates/ThreeCards';
const SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions = {
endEdgeRatio: 0.28,
initialScale: 0.935,
initialTranslateY: 132,
opacityRamp: 0.28,
stagger: 0.16,
};
export function PartnerThreeCards() {
return (
<ThreeCardsSection scheme="muted">
<ThreeCardsIntro align="left">
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>Which partner program is right for you?</Trans>
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">
Find the program that fits your business
</HeadingPart>
<HeadingPart fontFamily="sans">
and unlock new opportunities with Twenty
</HeadingPart>
</Trans>
</Heading>
</ThreeCardsIntro>
<IllustrationCards
illustrationCards={PARTNER_ILLUSTRATION_CARDS}
layoutOptions={SCROLL_LAYOUT_OPTIONS}
variant="simple"
/>
</ThreeCardsSection>
);
}
@@ -1,7 +1,6 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { theme } from '@/theme';
@@ -44,16 +43,16 @@ const EmptyStateClearButton = styled(ClearFiltersButton)`
`;
export function EmptyState({ onClearFilters }: EmptyStateProps) {
const { i18n } = useLingui();
return (
<Wrapper>
<Heading>{i18n._(msg`No partners match your filters`)}</Heading>
<Heading>
<Trans>No partners match your filters</Trans>
</Heading>
<Subtitle>
{i18n._(msg`Try removing some filters or browse all partners.`)}
<Trans>Try removing some filters or browse all partners.</Trans>
</Subtitle>
<EmptyStateClearButton onClick={onClearFilters}>
{i18n._(msg`Clear filters`)}
<Trans>Clear filters</Trans>
</EmptyStateClearButton>
</Wrapper>
);
@@ -1,6 +1,7 @@
'use client';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
@@ -94,13 +95,17 @@ export function FilterBar({
/>
<Footer>
<ResultCount aria-live="polite">
{hasAnyFilter
? i18n._(msg`Showing ${visibleCount} of ${totalCount} partners`)
: i18n._(msg`Showing all ${totalCount} partners`)}
{hasAnyFilter ? (
<Trans>
Showing {visibleCount} of {totalCount} partners
</Trans>
) : (
<Trans>Showing all {totalCount} partners</Trans>
)}
</ResultCount>
{hasAnyFilter && (
<ClearFiltersButton onClick={onClearAll}>
{i18n._(msg`Clear filters`)}
<Trans>Clear filters</Trans>
</ClearFiltersButton>
)}
</Footer>
@@ -1,4 +1,4 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Body,
Container,
@@ -6,7 +6,6 @@ import {
Heading,
HeadingPart,
} from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -38,26 +37,26 @@ const HeaderBody = styled(Body)`
`;
export function MarketplaceHeader() {
const i18n = getServerI18n();
return (
<Section>
<StyledContainer>
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Marketplace`)}
<Trans>Marketplace</Trans>
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">{i18n._(msg`Find your`)}</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`Twenty partner`)}
</HeadingPart>
<Trans>
<HeadingPart fontFamily="serif">Find your</HeadingPart>
<HeadingPart fontFamily="sans">Twenty partner</HeadingPart>
</Trans>
</Heading>
<HeaderBody>
{i18n._(
msg`Twenty's certified partners help teams migrate, customise, and operate the open source CRM across regions, languages, and deployment models. Browse profiles and book a call.`,
)}
<Trans>
Twenty's certified partners help teams migrate, customise, and
operate the open source CRM across regions, languages, and
deployment models. Browse profiles and book a call.
</Trans>
</HeaderBody>
</StyledContainer>
</Section>
@@ -1,10 +1,7 @@
import { Suspense } from 'react';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { theme } from '@/theme';
@@ -32,17 +29,10 @@ export default async function PartnersMarketplacePage({
return (
<>
<Menu.Root
<Menu
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
/>
<MarketplaceHeader />
@@ -1,49 +1,19 @@
import { msg } from '@lingui/core/macro';
import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { TalkToUsButton } from '@/sections/ContactCal';
import { Faq } from '@/sections/Faq';
import { TrustedBy } from '@/sections/TrustedBy';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { PARTNER_ILLUSTRATION_CARDS } from '@/app/[locale]/partners/three-cards-illustration.data';
import { PARTNER_TESTIMONIALS } from '@/app/[locale]/partners/testimonials.data';
import {
PartnerHeroCtas,
PartnerSignoffCtas,
} from '@/app/[locale]/partners/components/PartnerApplication';
import {
Eyebrow,
Heading,
HeadingPart,
LinkButton,
} from '@/design-system/components';
import { PartnerHero } from '@/app/[locale]/partners/components/PartnerHero';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudyCatalog } from '@/sections/CaseStudyCatalog';
import { Hero } from '@/sections/Hero';
import { CaseStudyCatalogPromo } from '@/sections/CaseStudyCatalog';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { Signoff } from '@/sections/Signoff';
import { Testimonials } from '@/sections/Testimonials';
import {
ThreeCards,
type ThreeCardsScrollLayoutOptions,
} from '@/sections/ThreeCards';
import { PartnerSignoff } from '@/app/[locale]/partners/components/PartnerSignoff';
import { PartnerTestimonials } from '@/app/[locale]/partners/components/PartnerTestimonials';
import { PartnerThreeCards } from '@/app/[locale]/partners/components/PartnerThreeCards';
import { theme } from '@/theme';
import { buildRouteMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
const PARTNER_ILLUSTRATION_CARDS_SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions =
{
endEdgeRatio: 0.28,
initialScale: 0.935,
initialTranslateY: 132,
opacityRamp: 0.28,
stagger: 0.16,
};
const PromoSpacing = styled.div`
margin-bottom: ${theme.spacing(8)};
@@ -59,7 +29,9 @@ type PartnerPageProps = {
};
export default async function PartnerPage({ params }: PartnerPageProps) {
const [i18n, stats] = await Promise.all([
// getRouteI18n sets the request-scoped i18n context the page's components
// read; the page renders no copy of its own, so the instance is unused here.
const [, stats] = await Promise.all([
getRouteI18n(params),
fetchCommunityStats(),
]);
@@ -67,139 +39,32 @@ export default async function PartnerPage({ params }: PartnerPageProps) {
return (
<>
<Menu.Root
<Menu
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
/>
<Hero.Root scheme="light">
<Hero.Heading page={Pages.Partners}>
<HeadingPart fontFamily="serif">{i18n._(msg`Become`)}</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`our partner`)}
</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.Partners}>
{i18n._(
msg`We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.`,
)}
</Hero.Body>
<Hero.Cta>
<PartnerHeroCtas />
</Hero.Cta>
<Hero.PartnerVisual />
</Hero.Root>
<PartnerHero />
<TrustedBy.Root
<TrustedBy
backgroundColor={theme.colors.primary.background[100]}
compactBottom
separator={i18n._(msg`trusted by`)}
logos={TRUSTED_BY_LOGOS}
clientCount={i18n._(msg`+10k others`)}
/>
<PromoSpacing>
<CaseStudyCatalog.Promo
<CaseStudyCatalogPromo
compactTop
entries={CASE_STUDY_CATALOG_ENTRIES}
/>
</PromoSpacing>
<ThreeCards.Root scheme="muted">
<ThreeCards.Intro page={Pages.Partners} align="left">
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Which partner program is right for you?`)}
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Find the program that fits your business`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`and unlock new opportunities with Twenty`)}
</HeadingPart>
</Heading>
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={PARTNER_ILLUSTRATION_CARDS}
layoutOptions={PARTNER_ILLUSTRATION_CARDS_SCROLL_LAYOUT_OPTIONS}
variant="simple"
/>
</ThreeCards.Root>
<PartnerThreeCards />
<Testimonials.Root
scheme="muted"
shapeFillColor={theme.colors.secondary.background[100]}
>
<Testimonials.PartnerCarousel
eyebrow={i18n._(msg`Join our growing partner ecosystem`)}
testimonials={PARTNER_TESTIMONIALS}
>
<Testimonials.PartnerVisual />
</Testimonials.PartnerCarousel>
</Testimonials.Root>
<PartnerTestimonials />
<Signoff.Root scheme="light" page={Pages.Partners}>
<Signoff.Heading page={Pages.Partners}>
<HeadingPart fontFamily="serif">
{i18n._(msg`Ready to grow`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`with Twenty?`)}
</HeadingPart>
</Signoff.Heading>
<Signoff.Body page={Pages.Partners}>
{i18n._(
msg`Join our partner ecosystem and help businesses\ntake control of their CRM.`,
)}
</Signoff.Body>
<Signoff.Cta>
<PartnerSignoffCtas />
</Signoff.Cta>
</Signoff.Root>
<PartnerSignoff />
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary">
<HeadingPart fontFamily="sans">
{i18n._(msg`Any Questions?`)}
</HeadingPart>
</Eyebrow>
<Faq.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`Stop fighting custom.`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`Start building, with Twenty`)}
</HeadingPart>
</Faq.Heading>
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="primary"
label={msg`Talk to us`}
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_QUESTIONS} />
</Faq.Root>
<Faq />
</>
);
}
@@ -0,0 +1,55 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { BecomePartnerButton } from '@/app/[locale]/partners/components/PartnerApplication';
import { HeadingPart } from '@/design-system/components';
import {
EngagementBandActions,
EngagementBandBody,
EngagementBandCopy,
EngagementBandHeading,
EngagementBandSection,
EngagementBandStrip,
} from '@/sections/EngagementBand';
import { theme } from '@/theme';
const PricingBannerContainer = styled.div`
margin: 0 auto;
width: 100%;
`;
export function PricingEngagementBand() {
return (
<EngagementBandSection scheme="muted">
<PricingBannerContainer>
<EngagementBandStrip
desktopCopyMaxWidth="60%"
fillColor={theme.colors.primary.background[100]}
variant="primary"
>
<EngagementBandCopy>
<EngagementBandHeading>
<HeadingPart fontFamily="serif">
<Trans>Need help with customization?</Trans>
</HeadingPart>
</EngagementBandHeading>
<EngagementBandBody>
<Trans>
Find the right partner to implement, customize, and tailor
Twenty to your team.
</Trans>
</EngagementBandBody>
</EngagementBandCopy>
<EngagementBandActions>
<BecomePartnerButton
color="secondary"
label={msg`Find a partner`}
variant="outlined"
/>
</EngagementBandActions>
</EngagementBandStrip>
</PricingBannerContainer>
</EngagementBandSection>
);
}
@@ -0,0 +1,25 @@
import { Trans } from '@lingui/react/macro';
import { HeadingPart } from '@/design-system/components';
import { HeroBody, HeroHeading, HeroSection } from '@/templates/Hero';
export function PricingHero() {
return (
<HeroSection scheme="muted">
<HeroHeading>
<Trans>
<HeadingPart fontFamily="serif">Simple</HeadingPart>
<br />
<HeadingPart fontFamily="sans">Pricing</HeadingPart>
</Trans>
</HeroHeading>
<HeroBody maxWidthMd={500}>
<Trans>
Start your free trial today
<br />
without credit card.
</Trans>
</HeroBody>
</HeroSection>
);
}
@@ -0,0 +1,20 @@
import { Trans } from '@lingui/react/macro';
import { SALESFORCE_DATA } from '@/app/[locale]/pricing/salesforce.data';
import { HeadingPart } from '@/design-system/components';
import { Salesforce } from '@/sections/Salesforce';
export function PricingSalesforce() {
return (
<Salesforce
scheme="muted"
body={SALESFORCE_DATA.body}
pricing={SALESFORCE_DATA.pricing}
>
<Trans>
<HeadingPart fontFamily="serif">Trust the n°1 CRM,</HeadingPart>
<HeadingPart fontFamily="sans">or not!</HeadingPart>
</Trans>
</Salesforce>
);
}
@@ -1,23 +1,18 @@
import { msg } from '@lingui/core/macro';
import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { TalkToUsButton } from '@/sections/ContactCal';
import { BecomePartnerButton } from '@/app/[locale]/partners/components/PartnerApplication';
import { PLAN_TABLE_DATA } from '@/app/[locale]/pricing/plan-table.data';
import { SALESFORCE_DATA } from '@/app/[locale]/pricing/salesforce.data';
import { Eyebrow, HeadingPart, LinkButton } from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { EngagementBand } from '@/sections/EngagementBand';
import { Hero } from '@/sections/Hero';
import { PricingEngagementBand } from '@/app/[locale]/pricing/_components/PricingEngagementBand';
import { PricingHero } from '@/app/[locale]/pricing/_components/PricingHero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { Plans, PricingStateProvider } from '@/sections/Plans';
import { PlanTable } from '@/sections/PlanTable';
import { Salesforce } from '@/sections/Salesforce';
import {
PlansContent,
PlansSection,
PricingStateProvider,
} from '@/sections/Plans';
import { PlanTableContent, PlanTableSection } from '@/sections/PlanTable';
import { PricingSalesforce } from '@/app/[locale]/pricing/_components/PricingSalesforce';
import { theme } from '@/theme';
import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
import { styled } from '@linaria/react';
@@ -29,11 +24,6 @@ const PricingPlansContainer = styled.div`
width: 100%;
`;
const PricingBannerContainer = styled.div`
margin: 0 auto;
width: 100%;
`;
export const generateMetadata = buildRouteMetadata('pricing');
type PricingPageProps = {
@@ -50,114 +40,27 @@ export default async function PricingPage({ params }: PricingPageProps) {
return (
<>
<JsonLd data={buildFaqPageJsonLd(FAQ_QUESTIONS, (d) => i18n._(d))} />
<Menu.Root
backgroundColor="#F3F3F3"
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Menu backgroundColor="#F3F3F3" socialLinks={menuSocialLinks} />
<Hero.Root scheme="muted">
<Hero.Heading page={Pages.Pricing}>
<HeadingPart fontFamily="serif">{i18n._(msg`Simple`)}</HeadingPart>
<br />
<HeadingPart fontFamily="sans">{i18n._(msg`Pricing`)}</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.Pricing} preserveLineBreaks>
{i18n._(msg`Start your free trial today\nwithout credit card.`)}
</Hero.Body>
</Hero.Root>
<PricingHero />
<PricingStateProvider>
<Plans.Root scheme="muted">
<PlansSection scheme="muted">
<PricingPlansContainer>
<Plans.Content />
<PlansContent />
</PricingPlansContainer>
</Plans.Root>
</PlansSection>
<EngagementBand.Root scheme="muted">
<PricingBannerContainer>
<EngagementBand.Strip
desktopCopyMaxWidth="60%"
fillColor={theme.colors.primary.background[100]}
variant="primary"
>
<EngagementBand.Copy>
<EngagementBand.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`Need help with customization?`)}
</HeadingPart>
</EngagementBand.Heading>
<EngagementBand.Body>
{i18n._(
msg`Find the right partner to implement, customize, and tailor Twenty to your team.`,
)}
</EngagementBand.Body>
</EngagementBand.Copy>
<EngagementBand.Actions>
<BecomePartnerButton
color="secondary"
label={msg`Find a partner`}
variant="outlined"
/>
</EngagementBand.Actions>
</EngagementBand.Strip>
</PricingBannerContainer>
</EngagementBand.Root>
<PricingEngagementBand />
<PlanTable.Root scheme="dark">
<PlanTable.Content data={PLAN_TABLE_DATA} />
</PlanTable.Root>
<PlanTableSection scheme="dark">
<PlanTableContent data={PLAN_TABLE_DATA} />
</PlanTableSection>
</PricingStateProvider>
<Salesforce.Flow
scheme="muted"
body={SALESFORCE_DATA.body}
pricing={SALESFORCE_DATA.pricing}
>
<HeadingPart fontFamily="serif">
{i18n._(msg`Trust the n°1 CRM,`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">{i18n._(msg`or not !`)}</HeadingPart>
</Salesforce.Flow>
<PricingSalesforce />
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary">
<HeadingPart fontFamily="sans">
{i18n._(msg`Any Questions?`)}
</HeadingPart>
</Eyebrow>
<Faq.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`Stop fighting custom.`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`Start building, with Twenty`)}
</HeadingPart>
</Faq.Heading>
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="primary"
label={msg`Talk to us`}
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_QUESTIONS} />
</Faq.Root>
<Faq />
</>
);
}
@@ -1,7 +1,7 @@
import { MENU_DATA } from '@/sections/Menu';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocument } from '@/sections/LegalDocument';
import { LegalDocumentPage } from '@/sections/LegalDocument';
import { buildRouteMetadata } from '@/lib/seo';
import { PrivacyPolicyDocument } from './_components';
@@ -13,11 +13,11 @@ export default async function PrivacyPolicyPage() {
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<LegalDocument.Page
<LegalDocumentPage
menuData={{ navItems: MENU_DATA.navItems, socialLinks: menuSocialLinks }}
title="Privacy Policy"
>
<PrivacyPolicyDocument />
</LegalDocument.Page>
</LegalDocumentPage>
);
}
@@ -1,9 +1,19 @@
import { Container } from '@/design-system/components';
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
import {
Container,
Eyebrow,
Heading,
HeadingPart,
LinkButton,
} from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { AppPreview } from '@/sections/AppPreview';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import NextImage from 'next/image';
import type { ReactNode } from 'react';
const RootWrapper = styled.div`
display: grid;
@@ -65,9 +75,26 @@ const StyledContainer = styled(Container)`
}
`;
type RootProps = { children: ReactNode };
const CtasContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(4)};
justify-content: center;
`;
const PreviewRoot = styled.div`
margin-bottom: ${theme.spacing(11)};
margin-top: ${theme.spacing(12)};
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
margin-top: ${theme.spacing(19)};
}
`;
export function ProductDemo() {
const i18n = getServerI18n();
export function Root({ children }: RootProps) {
return (
<RootWrapper>
<BackgroundLayer aria-hidden>
@@ -82,7 +109,31 @@ export function Root({ children }: RootProps) {
</PatternLayer>
</BackgroundLayer>
<StyledSection>
<StyledContainer>{children}</StyledContainer>
<StyledContainer>
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>Try it live</Trans>
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">A demo worth a</HeadingPart>
<br />
<HeadingPart fontFamily="sans">thousand words</HeadingPart>
</Trans>
</Heading>
<CtasContainer>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Try Twenty Cloud`)}
variant="contained"
/>
</CtasContainer>
<PreviewRoot>
<AppPreview showTerminal={false} visual={APP_PREVIEW_DATA.visual} />
</PreviewRoot>
</StyledContainer>
</StyledSection>
</RootWrapper>
);
@@ -0,0 +1,47 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
import { AI_HERO_TABS } from '@/app/[locale]/product/ai-hero-tabs.data';
import { Heading, HeadingPart } from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { HeroVisualScroll } from '@/sections/Hero';
export function ProductHero() {
const i18n = getServerI18n();
return (
<HeroVisualScroll
aiBody={i18n._(
msg`Ask questions, automate tasks, and get insights. All powered by AI that understands your data.`,
)}
aiHeading={
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">AI that actually</HeadingPart>
<br />
<HeadingPart fontFamily="serif">helps you</HeadingPart>
<HeadingPart fontFamily="sans">work faster</HeadingPart>
</Trans>
</Heading>
}
ctaHref="https://app.twenty.com/welcome"
ctaLabel={i18n._(msg`Get started`)}
introBody={i18n._(
msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
)}
introHeading={
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">A CRM for teams</HeadingPart>
<br />
<HeadingPart fontFamily="serif">that</HeadingPart>
<HeadingPart fontFamily="sans">move fast</HeadingPart>
</Trans>
</Heading>
}
tabs={AI_HERO_TABS}
visual={APP_PREVIEW_DATA.visual}
/>
);
}
@@ -0,0 +1,27 @@
import { Trans } from '@lingui/react/macro';
import { ILLUSTRATION_CARDS } from '@/app/[locale]/product/three-cards.data';
import { Eyebrow, Heading, HeadingPart } from '@/design-system/components';
import { IllustrationCards } from '@/sections/ThreeCards';
import { ThreeCardsIntro, ThreeCardsSection } from '@/templates/ThreeCards';
export function ProductThreeCards() {
return (
<ThreeCardsSection scheme="light">
<ThreeCardsIntro align="left" maxWidthMd={600}>
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>Stop settling for trade-offs.</Trans>
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">A modern CRM with</HeadingPart>
<HeadingPart fontFamily="sans">an intuitive interface</HeadingPart>
</Trans>
</Heading>
</ThreeCardsIntro>
<IllustrationCards illustrationCards={ILLUSTRATION_CARDS} />
</ThreeCardsSection>
);
}
@@ -1,10 +1,17 @@
import { Container } from '@/design-system/components';
import type { MessageDescriptor } from '@lingui/core';
import {
Container,
Eyebrow,
Heading,
HeadingPart,
} from '@/design-system/components';
import { type Page, Pages } from '@/lib/pages';
import { theme, type Scheme } from '@/theme';
import { Trans } from '@lingui/react/macro';
import type { MessageDescriptor } from '@lingui/core';
import { styled } from '@linaria/react';
import type { ComponentType, ReactNode } from 'react';
import { FEATURE_TILES } from './feature-tiles.data';
import { FeatureScrollEntrance } from './FeatureScrollEntrance';
import { TileContent } from './TileContent';
import { TileVisual } from './TileVisual';
@@ -22,8 +29,6 @@ export type FeatureTileType = {
visual: ComponentType<{ active: boolean }>;
};
// --- Root ---
const StyledSection = styled.section`
width: 100%;
@@ -75,8 +80,6 @@ function Root({ backgroundColor, children, scheme }: RootProps) {
);
}
// --- Intro ---
const StyledIntro = styled.div`
display: grid;
grid-template-columns: 1fr;
@@ -113,8 +116,6 @@ function Intro({ align, children, page }: IntroProps) {
);
}
// --- Tiles ---
const BORDER = `1px solid ${theme.colors.primary.border[20]}`;
const Grid = styled.div`
@@ -251,4 +252,23 @@ function Tiles({ tiles }: TilesProps) {
);
}
export const Feature = { Intro, Root, Tiles };
export function ProductFeature() {
return (
<Root scheme="light">
<Intro align="center" page={Pages.Product}>
<Eyebrow>
<HeadingPart fontFamily="sans">
<Trans>Core Features</Trans>
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">Everything you need,</HeadingPart>
<HeadingPart fontFamily="sans">out of the box</HeadingPart>
</Trans>
</Heading>
</Intro>
<Tiles tiles={FEATURE_TILES} />
</Root>
);
}
@@ -3,10 +3,10 @@ import { styled } from '@linaria/react';
import { Body, Heading, HeadingPart } from '@/design-system/components';
import { INFORMATIVE_ICONS } from '@/icons';
import { getServerI18n } from '@/lib/i18n/utils/get-server-i18n';
import { getServerI18n } from '@/lib/i18n/server';
import { theme } from '@/theme';
import type { FeatureTileType } from './Feature';
import type { FeatureTileType } from './ProductFeature';
const DIVIDER_COLOR = theme.colors.primary.border[10];
const ICON_STROKE_WIDTH = 1.5;
@@ -1,13 +1,13 @@
import { msg } from '@lingui/core/macro';
import type { FeatureTileType } from '@/sections/Feature';
import { ContactsVisual } from '@/sections/Feature/visuals/ContactsVisual';
import { DashboardVisual } from '@/sections/Feature/visuals/DashboardVisual';
import { EmailsVisual } from '@/sections/Feature/visuals/EmailsVisual';
import { FilesVisual } from '@/sections/Feature/visuals/FilesVisual';
import { ImportVisual } from '@/sections/Feature/visuals/ImportVisual';
import { PipelineVisual } from '@/sections/Feature/visuals/PipelineVisual';
import { TasksVisual } from '@/sections/Feature/visuals/TasksVisual';
import type { FeatureTileType } from './ProductFeature';
import { ContactsVisual } from './visuals/ContactsVisual';
import { DashboardVisual } from './visuals/DashboardVisual';
import { EmailsVisual } from './visuals/EmailsVisual';
import { FilesVisual } from './visuals/FilesVisual';
import { ImportVisual } from './visuals/ImportVisual';
import { PipelineVisual } from './visuals/PipelineVisual';
import { TasksVisual } from './visuals/TasksVisual';
export const FEATURE_TILES: FeatureTileType[] = [
{
@@ -0,0 +1 @@
export { ProductFeature, type FeatureTileType } from './ProductFeature';
@@ -0,0 +1,62 @@
import { HeadingPart } from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Flow } from './Flow';
import type { ProductStepperStepType } from './types';
import { DataModelVisual } from './visuals/DataModelVisual';
import { LayoutVisual } from './visuals/LayoutVisual';
import { WorkflowVisual } from './visuals/WorkflowVisual';
export function ProductStepperSection() {
const i18n = getServerI18n();
const steps: ProductStepperStepType[] = [
{
icon: 'users',
heading: (
<HeadingPart fontFamily="sans">
<Trans>Data model</Trans>
</HeadingPart>
),
body: msg`Add objects and fields`,
visual: DataModelVisual,
},
{
icon: 'check',
heading: (
<HeadingPart fontFamily="sans">
<Trans>Automation</Trans>
</HeadingPart>
),
body: msg`Create a workflow`,
visual: WorkflowVisual,
},
{
icon: 'eye',
heading: (
<HeadingPart fontFamily="sans">
<Trans>Layout</Trans>
</HeadingPart>
),
body: msg`Tailor record pages, menus, and views`,
visual: LayoutVisual,
},
];
return (
<Flow
body={i18n._(
msg`Need a quick change? Skip the engineering ticket. Customize your workspace in minutes.`,
)}
eyebrow={i18n._(msg`Customization`)}
steps={steps}
>
<Trans>
<HeadingPart fontFamily="serif">Go the extra mile</HeadingPart>
<HeadingPart fontFamily="sans">with no-code</HeadingPart>
</Trans>
</Flow>
);
}
@@ -0,0 +1 @@
export { ProductStepperSection } from './ProductStepperSection';
@@ -1,42 +1,20 @@
import { APP_PREVIEW_DATA } from '@/app/[locale]/(home)/app-preview.data';
import { AI_HERO_TABS } from '@/app/[locale]/product/ai-hero-tabs.data';
import { FEATURE_TILES } from '@/app/[locale]/product/feature.data';
import { ILLUSTRATION_CARDS } from '@/app/[locale]/product/three-cards.data';
import {
Eyebrow,
Heading,
HeadingPart,
LinkButton,
} from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import {
buildBreadcrumbListJsonLd,
buildRouteMetadata,
JsonLd,
} from '@/lib/seo';
import { TalkToUsButton } from '@/sections/ContactCal';
import { Demo } from '@/sections/Demo';
import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { Feature } from '@/sections/Feature';
import { Hero } from '@/sections/Hero';
import { ProductDemo } from '@/app/[locale]/product/_components/ProductDemo';
import { ProductFeature } from '@/app/[locale]/product/_components/feature';
import { Faq } from '@/sections/Faq';
import { ProductHero } from '@/app/[locale]/product/_components/ProductHero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import {
ProductStepper,
type ProductStepperStepType,
} from '@/sections/ProductStepper';
import { DataModelVisual } from '@/sections/ProductStepper/visuals/DataModelVisual';
import { LayoutVisual } from '@/sections/ProductStepper/visuals/LayoutVisual';
import { WorkflowVisual } from '@/sections/ProductStepper/visuals/WorkflowVisual';
import { ThreeCards } from '@/sections/ThreeCards';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { ProductStepperSection } from '@/app/[locale]/product/_components/product-stepper';
import { ProductThreeCards } from '@/app/[locale]/product/_components/ProductThreeCards';
import { TrustedBy } from '@/sections/TrustedBy';
import { theme } from '@/theme';
import { msg } from '@lingui/core/macro';
import type { AppLocale } from 'twenty-shared/translations';
export const generateMetadata = buildRouteMetadata('product');
@@ -52,33 +30,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
]);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
const PRODUCT_STEPS: ProductStepperStepType[] = [
{
icon: 'users',
heading: (
<HeadingPart fontFamily="sans">{i18n._(msg`Data model`)}</HeadingPart>
),
body: msg`Add objects and fields`,
visual: DataModelVisual,
},
{
icon: 'check',
heading: (
<HeadingPart fontFamily="sans">{i18n._(msg`Automation`)}</HeadingPart>
),
body: msg`Create a workflow`,
visual: WorkflowVisual,
},
{
icon: 'eye',
heading: (
<HeadingPart fontFamily="sans">{i18n._(msg`Layout`)}</HeadingPart>
),
body: msg`Tailor record pages, menus, and views`,
visual: LayoutVisual,
},
];
return (
<>
<JsonLd
@@ -90,174 +41,24 @@ export default async function ProductPage({ params }: ProductPageProps) {
i18n.locale as AppLocale,
)}
/>
<Menu.Root
<Menu
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.HeroVisualScroll
aiBody={i18n._(
msg`Ask questions, automate tasks, and get insights. All powered by AI that understands your data.`,
)}
aiHeading={
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`AI that actually`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`helps you`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`work faster`)}
</HeadingPart>
</Heading>
}
ctaHref="https://app.twenty.com/welcome"
ctaLabel={i18n._(msg`Get started`)}
introBody={i18n._(
msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
)}
introHeading={
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A CRM for teams`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`that`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`move fast`)}
</HeadingPart>
</Heading>
}
tabs={AI_HERO_TABS}
visual={APP_PREVIEW_DATA.visual}
/>
<TrustedBy.Root
separator={i18n._(msg`trusted by`)}
logos={TRUSTED_BY_LOGOS}
clientCount={i18n._(msg`+10k others`)}
/>
<ProductHero />
<Feature.Root scheme="light">
<Feature.Intro align="center" page={Pages.Product}>
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Core Features`)}
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`Everything you need,`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`out of the box`)}
</HeadingPart>
</Heading>
</Feature.Intro>
<Feature.Tiles tiles={FEATURE_TILES} />
</Feature.Root>
<TrustedBy />
<ThreeCards.Root scheme="light">
<ThreeCards.Intro page={Pages.Product} align="left">
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Stop settling for trade-offs.`)}
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A modern CRM with`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`an intuitive interface`)}
</HeadingPart>
</Heading>
</ThreeCards.Intro>
<ThreeCards.IllustrationCards illustrationCards={ILLUSTRATION_CARDS} />
</ThreeCards.Root>
<ProductFeature />
<ProductStepper.Flow
body={i18n._(
msg`Need a quick change? Skip the engineering ticket. Customize your workspace in minutes.`,
)}
eyebrow={i18n._(msg`Customization`)}
steps={PRODUCT_STEPS}
>
<HeadingPart fontFamily="serif">
{i18n._(msg`Go the extra mile`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">{i18n._(msg`with no-code`)}</HeadingPart>
</ProductStepper.Flow>
<ProductThreeCards />
<Demo.Root>
<Eyebrow>
<HeadingPart fontFamily="sans">
{i18n._(msg`Try it live`)}
</HeadingPart>
</Eyebrow>
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A demo worth a`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`thousand words`)}
</HeadingPart>
</Heading>
<Demo.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Try Twenty Cloud`)}
variant="contained"
/>
</Demo.Cta>
<Demo.Preview visual={APP_PREVIEW_DATA.visual} />
</Demo.Root>
<ProductStepperSection />
<Faq.Root>
<Faq.Intro>
<Eyebrow colorScheme="secondary">
<HeadingPart fontFamily="sans">
{i18n._(msg`Any Questions?`)}
</HeadingPart>
</Eyebrow>
<Faq.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`Stop fighting custom.`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans">
{i18n._(msg`Start building, with Twenty`)}
</HeadingPart>
</Faq.Heading>
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
<TalkToUsButton
color="primary"
label={msg`Talk to us`}
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_QUESTIONS} />
</Faq.Root>
<ProductDemo />
<Faq />
</>
);
}
@@ -0,0 +1,41 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { HeadingPart, LinkButton } from '@/design-system/components';
import { GitHubIcon } from '@/icons';
import { getServerI18n } from '@/lib/i18n/server';
import { HeroBody, HeroCta, HeroHeading, HeroSection } from '@/templates/Hero';
import { ReleaseNotesVisual } from '@/sections/Hero';
export function ReleasesHero() {
const i18n = getServerI18n();
return (
<HeroSection scheme="light">
<HeroHeading size="lg" weight="light">
<Trans>
<HeadingPart fontFamily="serif">Latest</HeadingPart>
<br />
<HeadingPart fontFamily="sans">Releases</HeadingPart>
</Trans>
</HeroHeading>
<HeroBody maxWidthMd={591} size="sm">
<Trans>
Discover the newest features and improvements in Twenty,
<br />
the #1 Open Source CRM.
</Trans>
</HeroBody>
<HeroCta>
<LinkButton
color="secondary"
href="https://github.com/twentyhq/twenty/releases"
label={i18n._(msg`Technical notes`)}
leadingIcon={<GitHubIcon fillColor="currentColor" size={14} />}
variant="outlined"
/>
</HeroCta>
<ReleaseNotesVisual />
</HeroSection>
);
}
@@ -1,19 +1,17 @@
import { msg } from '@lingui/core/macro';
import { HeadingPart, LinkButton } from '@/design-system/components';
import { GitHubIcon } from '@/icons';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { fetchLatestGithubReleaseTag } from '@/lib/releases/fetch-latest-release-tag';
import { getVisibleReleaseNotes } from '@/lib/releases/get-visible-releases';
import { loadLocalReleaseNotes } from '@/lib/releases/load-local-release-notes';
import { Hero } from '@/sections/Hero';
import { ReleasesHero } from '@/app/[locale]/releases/_components/ReleasesHero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { ReleaseNotes } from '@/sections/ReleaseNotes';
import {
ReleaseNotesDivider,
ReleaseNotesEmptyMessage,
ReleaseNotesReleaseEntry,
ReleaseNotesSection,
} from '@/sections/ReleaseNotes';
import { theme } from '@/theme';
import { buildReleaseListJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
import { Fragment } from 'react';
@@ -28,7 +26,7 @@ type ReleasesPageProps = {
export default async function ReleasesPage({ params }: ReleasesPageProps) {
const allNotes = loadLocalReleaseNotes();
const [i18n, latestTag, stats] = await Promise.all([
const [, latestTag, stats] = await Promise.all([
getRouteI18n(params),
fetchLatestGithubReleaseTag(),
fetchCommunityStats(),
@@ -53,68 +51,38 @@ export default async function ReleasesPage({ params }: ReleasesPageProps) {
href="/illustrations/generated/milestone.jpg"
rel="preload"
/>
<Menu.Root
<Menu
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
/>
<Hero.Root scheme="light">
<Hero.Heading page={Pages.ReleaseNotes} size="lg" weight="light">
<HeadingPart fontFamily="serif">{i18n._(msg`Latest`)}</HeadingPart>
<br />
<HeadingPart fontFamily="sans">{i18n._(msg`Releases`)}</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.ReleaseNotes} size="sm">
{i18n._(
msg`Discover the newest features and improvements in Twenty,\nthe #1 Open Source CRM.`,
)}
</Hero.Body>
<Hero.Cta>
<LinkButton
color="secondary"
href="https://github.com/twentyhq/twenty/releases"
label={i18n._(msg`Technical notes`)}
leadingIcon={<GitHubIcon fillColor="currentColor" size={14} />}
variant="outlined"
/>
</Hero.Cta>
<Hero.ReleaseNotesVisual />
</Hero.Root>
<ReleasesHero />
<ReleaseNotes.Root>
<ReleaseNotesSection>
{allNotes.length === 0 ? (
<ReleaseNotes.EmptyMessage>
<ReleaseNotesEmptyMessage>
Releases were not found. Add MDX under{' '}
<strong>packages/twenty-website/src/content/releases</strong> and
images under{' '}
<strong>packages/twenty-website/public/images/releases</strong>.
</ReleaseNotes.EmptyMessage>
</ReleaseNotesEmptyMessage>
) : visibleNotes.length === 0 ? (
<ReleaseNotes.EmptyMessage>
<ReleaseNotesEmptyMessage>
No releases are visible yet for the current published version.
</ReleaseNotes.EmptyMessage>
</ReleaseNotesEmptyMessage>
) : (
visibleNotes.map((note, index) => (
<Fragment key={note.slug}>
<ReleaseNotes.ReleaseEntry
<ReleaseNotesReleaseEntry
content={note.content}
date={note.date}
release={note.release}
/>
{index < visibleNotes.length - 1 ? (
<ReleaseNotes.Divider />
) : null}
{index < visibleNotes.length - 1 ? <ReleaseNotesDivider /> : null}
</Fragment>
))
)}
</ReleaseNotes.Root>
</ReleaseNotesSection>
</>
);
}
@@ -1,7 +1,7 @@
import { MENU_DATA } from '@/sections/Menu';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocument } from '@/sections/LegalDocument';
import { LegalDocumentPage } from '@/sections/LegalDocument';
import { buildRouteMetadata } from '@/lib/seo';
import { TermsDocument } from './_components';
@@ -13,11 +13,11 @@ export default async function TermsPage() {
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
return (
<LegalDocument.Page
<LegalDocumentPage
menuData={{ navItems: MENU_DATA.navItems, socialLinks: menuSocialLinks }}
title="Terms of Service"
>
<TermsDocument />
</LegalDocument.Page>
</LegalDocumentPage>
);
}
@@ -0,0 +1,34 @@
import { Trans } from '@lingui/react/macro';
import { css } from '@linaria/core';
import { HeadingPart } from '@/design-system/components';
import { HeroBody, HeroHeading, HeroSection } from '@/templates/Hero';
import { WhyTwentyVisual } from '@/sections/Hero';
import { theme } from '@/theme';
const headingClassName = css`
color: ${theme.colors.secondary.text[100]};
`;
export function WhyTwentyHero() {
return (
<HeroSection scheme="dark">
<HeroHeading className={headingClassName} size="xl">
<Trans>
<HeadingPart fontFamily="serif">
The future of CRM is built,
</HeadingPart>
<HeadingPart fontFamily="sans">not bought.</HeadingPart>
</Trans>
</HeroHeading>
<HeroBody maxWidthMd={443}>
<Trans>
CRM was a database you filled on Fridays. AI turned it into the system
that runs your go-to-market. To differentiate, you have to build what
your competitors can't buy.
</Trans>
</HeroBody>
<WhyTwentyVisual />
</HeroSection>
);
}
@@ -1,11 +1,8 @@
import { theme } from '@/theme';
import { Trans } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
export type MarqueeSegment = {
fontFamily: string;
text: ReactNode;
};
import { theme } from '@/theme';
const MARQUEE_REPEAT = 6;
@@ -16,16 +13,6 @@ const StyledSection = styled.section`
padding-top: ${theme.spacing(20)};
width: 100%;
&[data-scheme='light'] {
background-color: var(--color-white);
color: var(--color-text);
}
&[data-scheme='muted'] {
background-color: var(--color-neutral);
color: var(--color-text);
}
&[data-scheme='dark'] {
background-color: var(--color-black);
color: var(--color-text);
@@ -111,14 +98,12 @@ const Segment = styled.span`
}
`;
type Scheme = 'light' | 'muted' | 'dark';
function Phrase({ segments }: { segments: MarqueeSegment[] }) {
function Phrase({ segments }: { segments: ReactNode[] }) {
return (
<PhraseGroup>
{segments.map((segment, segmentIndex) => (
<Segment key={segmentIndex} data-segment-index={segmentIndex}>
{segment.text}
{segment}
</Segment>
))}
</PhraseGroup>
@@ -129,7 +114,7 @@ function Track({
segments,
reversed,
}: {
segments: MarqueeSegment[];
segments: ReactNode[];
reversed: boolean;
}) {
const phrases = Array.from({ length: MARQUEE_REPEAT }, (_, index) => (
@@ -144,19 +129,16 @@ function Track({
);
}
type RootProps = {
backgroundColor?: string;
color?: string;
segments: MarqueeSegment[];
scheme?: Scheme;
};
// Segment styling is positional via data-segment-index.
export function WhyTwentyMarquee() {
const segments: ReactNode[] = [
<Trans>Same CRM</Trans>,
<Trans>Same output</Trans>,
<Trans>Same results</Trans>,
];
function Root({ backgroundColor, color, segments, scheme }: RootProps) {
return (
<StyledSection
data-scheme={scheme}
style={scheme ? undefined : { backgroundColor, color }}
>
<StyledSection data-scheme="dark">
<Viewport>
<Row>
<Track segments={segments} reversed={false} />
@@ -168,5 +150,3 @@ function Root({ backgroundColor, color, segments, scheme }: RootProps) {
</StyledSection>
);
}
export const Marquee = { Root };
@@ -0,0 +1,42 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { HeadingPart } from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { Editorial } from '@/templates/Editorial';
import { theme } from '@/theme';
export function WhyTwentyMeaningEditorial() {
const i18n = getServerI18n();
return (
<Editorial
scheme="dark"
crosshair={{
crossX: '120px',
crossY: '0px',
lineColor: theme.colors.secondary.border[10],
}}
introAlign="right"
eyebrowColorScheme="secondary"
eyebrow={i18n._(msg`What this means`)}
heading={
<Trans>
<HeadingPart fontFamily="serif">Differentiation now</HeadingPart>
<HeadingPart fontFamily="sans">
lives in the code you own.
</HeadingPart>
</Trans>
}
bodyLayout="two-column-right"
bodyParagraphs={[
i18n._(
msg`You don't buy your deployment pipeline off the shelf. You don't rent your data warehouse from a vendor who decides the schema. You build it, you own it, you iterate on it every week. CRM is going the same way. The teams that treat it as infrastructure they own will compound an advantage every quarter.`,
),
i18n._(
msg`Tuesday your team learns that deals with a technical champion close 3x faster. Wednesday you add the field, wire up the scoring, adjust the workflow. By Thursday your agents are acting on it. That feedback loop is the edge. And it only works if the CRM is yours.`,
),
]}
/>
);
}
@@ -0,0 +1,43 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { HeadingPart } from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { Editorial } from '@/templates/Editorial';
import { theme } from '@/theme';
export function WhyTwentyOpportunityEditorial() {
const i18n = getServerI18n();
return (
<Editorial
scheme="dark"
crosshair={{
crossX: 'calc(100% - 120px)',
crossY: '0px',
lineColor: theme.colors.secondary.border[10],
}}
eyebrowColorScheme="secondary"
eyebrow={i18n._(msg`The opportunity`)}
heading={
<Trans>
<HeadingPart fontFamily="serif">
Build it in an afternoon.
</HeadingPart>
<HeadingPart fontFamily="sans">
AI made the gap that small.
</HeadingPart>
</Trans>
}
bodyLayout="two-column-left"
bodyParagraphs={[
i18n._(
msg`A year ago, customizing your CRM meant hiring a Salesforce consultant, learning Apex, waiting months. The gap between "I want this" and "it's live" was measured in quarters and invoices. So people settled. They bent their process to fit the tool and called it adoption.`,
),
i18n._(
msg`Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you.`,
),
]}
/>
);
}
@@ -0,0 +1,41 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { HeadingPart } from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { Editorial } from '@/templates/Editorial';
import { theme } from '@/theme';
export function WhyTwentyShiftEditorial() {
const i18n = getServerI18n();
return (
<Editorial
scheme="dark"
crosshair={{
crossX: 'calc(100% - 120px)',
crossY: '0px',
lineColor: theme.colors.secondary.border[10],
}}
eyebrowColorScheme="secondary"
eyebrow={i18n._(msg`The shift`)}
heading={
<Trans>
<HeadingPart fontFamily="serif">CRM was a ledger.</HeadingPart>
<HeadingPart fontFamily="sans">
AI turned it into an operating system.
</HeadingPart>
</Trans>
}
bodyLayout="two-column-left"
bodyParagraphs={[
i18n._(
msg`For twenty years, CRM meant the same thing: a place to log calls, track deals, and pull reports on Friday. The real work happened in people's heads, in Slack threads, in hallway conversations. The CRM kept score. Nobody expected more from it.`,
),
i18n._(
msg`AI agents are starting to draft outreach, score leads, research accounts, write follow-ups, update deal stages. Every one of these actions reads from and writes to the CRM. The scoreboard became the playbook. The database became the brain.`,
),
]}
/>
);
}
@@ -0,0 +1,32 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { HeadingPart, LinkButton } from '@/design-system/components';
import { getServerI18n } from '@/lib/i18n/server';
import { Signoff } from '@/templates/Signoff';
export function WhyTwentySignoff() {
const i18n = getServerI18n();
return (
<Signoff
scheme="dark"
heading={
<Trans>
<HeadingPart fontFamily="serif">
Build a CRM your competitors
</HeadingPart>
<HeadingPart fontFamily="sans">can't buy.</HeadingPart>
</Trans>
}
body={<Trans>Open-source, AI-ready, and yours to shape.</Trans>}
>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
</Signoff>
);
}
@@ -1,59 +1,15 @@
import { msg } from '@lingui/core/macro';
import { HeadingPart, LinkButton } from '@/design-system/components';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/lib/i18n/utils/get-route-i18n';
import { Pages } from '@/lib/pages';
import { getRouteI18n, type LocaleRouteParams } from '@/lib/i18n/server';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Editorial } from '@/sections/Editorial';
import { Hero } from '@/sections/Hero';
import { Marquee } from '@/sections/Marquee';
import { WhyTwentyMarquee } from '@/app/[locale]/why-twenty/_components/WhyTwentyMarquee';
import { WhyTwentyHero } from '@/app/[locale]/why-twenty/_components/WhyTwentyHero';
import { WhyTwentyMeaningEditorial } from '@/app/[locale]/why-twenty/_components/WhyTwentyMeaningEditorial';
import { WhyTwentyOpportunityEditorial } from '@/app/[locale]/why-twenty/_components/WhyTwentyOpportunityEditorial';
import { WhyTwentyShiftEditorial } from '@/app/[locale]/why-twenty/_components/WhyTwentyShiftEditorial';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { Signoff } from '@/sections/Signoff';
import { WhyTwentySignoff } from '@/app/[locale]/why-twenty/_components/WhyTwentySignoff';
import { theme } from '@/theme';
import { buildRouteMetadata } from '@/lib/seo';
import { css } from '@linaria/core';
const editorialOneIntroClass = css`
margin-bottom: ${theme.spacing(4)};
--editorial-heading-max-width: 760px;
--editorial-intro-max-width: 760px;
@media (min-width: ${theme.breakpoints.md}px) {
margin-bottom: ${theme.spacing(8)};
}
`;
const editorialRightIntroClass = css`
margin-bottom: ${theme.spacing(4)};
--editorial-heading-max-width: 760px;
--editorial-intro-max-width: 760px;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: flex-end;
margin-bottom: ${theme.spacing(8)};
margin-left: auto;
margin-right: 0;
text-align: right;
width: auto;
}
`;
const crosshairLineColor = theme.colors.secondary.border[10];
const sectionCrosshairLeft = {
crossX: '120px',
crossY: '0px',
lineColor: crosshairLineColor,
};
const sectionCrosshairRight = {
crossX: 'calc(100% - 120px)',
crossY: '0px',
lineColor: crosshairLineColor,
};
export const generateMetadata = buildRouteMetadata('whyTwenty');
@@ -62,7 +18,9 @@ type WhyTwentyPageProps = {
};
export default async function WhyTwentyPage({ params }: WhyTwentyPageProps) {
const [i18n, stats] = await Promise.all([
// getRouteI18n sets the request-scoped i18n context the page's components
// read; the page renders no copy of its own, so the instance is unused here.
const [, stats] = await Promise.all([
getRouteI18n(params),
fetchCommunityStats(),
]);
@@ -80,135 +38,23 @@ export default async function WhyTwentyPage({ params }: WhyTwentyPageProps) {
href="/illustrations/why-twenty/hero/hero.glb"
rel="preload"
/>
<Menu.Root
<Menu
backgroundColor={theme.colors.secondary.background[100]}
scheme="secondary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="secondary" />
<Menu.Nav scheme="secondary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="secondary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="secondary" />
</Menu.Root>
<Hero.Root scheme="dark">
<Hero.Heading page={Pages.WhyTwenty} size="xl">
<HeadingPart fontFamily="serif">
{i18n._(msg`The future of CRM is built,`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`not bought.`)}
</HeadingPart>
</Hero.Heading>
<Hero.Body page={Pages.WhyTwenty}>
{i18n._(
msg`CRM was a database you filled on Fridays. AI turned it into the system that runs your go-to-market. To differentiate, you have to build what your competitors can't buy.`,
)}
</Hero.Body>
<Hero.WhyTwentyVisual />
</Hero.Root>
<Editorial.Root scheme="dark" crosshair={sectionCrosshairRight}>
<Editorial.Intro className={editorialOneIntroClass}>
<Editorial.Eyebrow colorScheme="secondary">
{i18n._(msg`The shift`)}
</Editorial.Eyebrow>
<Editorial.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`CRM was a ledger.`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`AI turned it into an operating system.`)}
</HeadingPart>
</Editorial.Heading>
</Editorial.Intro>
<Editorial.Body layout="two-column-left">
{i18n._(
msg`For twenty years, CRM meant the same thing: a place to log calls, track deals, and pull reports on Friday. The real work happened in people's heads, in Slack threads, in hallway conversations. The CRM kept score. Nobody expected more from it.`,
)}
{i18n._(
msg`AI agents are starting to draft outreach, score leads, research accounts, write follow-ups, update deal stages. Every one of these actions reads from and writes to the CRM. The scoreboard became the playbook. The database became the brain.`,
)}
</Editorial.Body>
</Editorial.Root>
<Editorial.Root scheme="dark" crosshair={sectionCrosshairLeft}>
<Editorial.Intro className={editorialRightIntroClass}>
<Editorial.Eyebrow colorScheme="secondary">
{i18n._(msg`What this means`)}
</Editorial.Eyebrow>
<Editorial.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`Differentiation now`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`lives in the code you own.`)}
</HeadingPart>
</Editorial.Heading>
</Editorial.Intro>
<Editorial.Body layout="two-column-right">
{i18n._(
msg`You don't buy your deployment pipeline off the shelf. You don't rent your data warehouse from a vendor who decides the schema. You build it, you own it, you iterate on it every week. CRM is going the same way. The teams that treat it as infrastructure they own will compound an advantage every quarter.`,
)}
{i18n._(
msg`Tuesday your team learns that deals with a technical champion close 3x faster. Wednesday you add the field, wire up the scoring, adjust the workflow. By Thursday your agents are acting on it. That feedback loop is the edge. And it only works if the CRM is yours.`,
)}
</Editorial.Body>
</Editorial.Root>
<Editorial.Root scheme="dark" crosshair={sectionCrosshairRight}>
<Editorial.Intro className={editorialOneIntroClass}>
<Editorial.Eyebrow colorScheme="secondary">
{i18n._(msg`The opportunity`)}
</Editorial.Eyebrow>
<Editorial.Heading>
<HeadingPart fontFamily="serif">
{i18n._(msg`Build it in an afternoon.`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`AI made the gap that small.`)}
</HeadingPart>
</Editorial.Heading>
</Editorial.Intro>
<Editorial.Body layout="two-column-left">
{i18n._(
msg`A year ago, customizing your CRM meant hiring a Salesforce consultant, learning Apex, waiting months. The gap between "I want this" and "it's live" was measured in quarters and invoices. So people settled. They bent their process to fit the tool and called it adoption.`,
)}
{i18n._(
msg`Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you.`,
)}
</Editorial.Body>
</Editorial.Root>
<Marquee.Root
scheme="dark"
segments={[
{ fontFamily: 'serif', text: i18n._(msg`Same CRM`) },
{ fontFamily: 'sans', text: i18n._(msg`Same output`) },
{ fontFamily: 'serif', text: i18n._(msg`Same results`) },
]}
/>
<Signoff.Root scheme="dark" page={Pages.WhyTwenty}>
<Signoff.Heading page={Pages.WhyTwenty}>
<HeadingPart fontFamily="serif">
{i18n._(msg`Build a CRM your competitors`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">{i18n._(msg`can't buy.`)}</HeadingPart>
</Signoff.Heading>
<Signoff.Body page={Pages.WhyTwenty}>
{i18n._(msg`Open-source, AI-ready, and yours to shape.`)}
</Signoff.Body>
<Signoff.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label={i18n._(msg`Get started`)}
variant="contained"
/>
</Signoff.Cta>
</Signoff.Root>
<WhyTwentyHero />
<WhyTwentyShiftEditorial />
<WhyTwentyMeaningEditorial />
<WhyTwentyOpportunityEditorial />
<WhyTwentyMarquee />
<WhyTwentySignoff />
</>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { MetadataRoute } from 'next';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { PUBLIC_APP_LOCALE_LIST, localeToUrlSegment } from '@/lib/i18n';
import { WEBSITE_LOCALE_LIST, localeToUrlSegment } from '@/lib/i18n';
import { getSiteUrl } from '@/lib/seo';
import {
getIndexedWebsiteRoutes,
@@ -19,7 +19,7 @@ const buildLocalizedUrl = (locale: AppLocale, path: string): string => {
};
const getRouteLocales = (route: WebsiteRoute): readonly AppLocale[] =>
route.localeMode === 'source' ? [SOURCE_LOCALE] : PUBLIC_APP_LOCALE_LIST;
route.localeMode === 'source' ? [SOURCE_LOCALE] : WEBSITE_LOCALE_LIST;
const buildLanguageAlternates = (
path: string,

Some files were not shown because too many files have changed in this diff Show More