Fixes and updates to the website. (#19350)

This PR implements a whole lot of fixes and updates to the website. It
adds a release-notes page, terms and conditions page, privacy policy
page, while fixing HomeStepper, ProductStepper, and WhyTwentyStepper. 3D
models still need to be styled and their sizes need to be fixed.
This commit is contained in:
Abdullah.
2026-04-06 12:42:30 +05:00
committed by GitHub
parent e062343802
commit 3a1e112b86
150 changed files with 3690 additions and 1275 deletions
+17 -1
View File
@@ -1,5 +1,21 @@
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
const nextConfig: LinariaConfig = { reactCompiler: true };
const nextConfig: LinariaConfig = {
images: {
remotePatterns: [
{
hostname: 'avatars.githubusercontent.com',
pathname: '/**',
protocol: 'https',
},
{
hostname: 'twenty-icons.com',
pathname: '/**',
protocol: 'https',
},
],
},
reactCompiler: true,
};
module.exports = withLinaria(nextConfig);
+12 -1
View File
@@ -10,19 +10,30 @@
"dependencies": {
"@base-ui/react": "^1.3.0",
"@base-ui/utils": "^0.2.6",
"@gsap/react": "2.1.2",
"@linaria/core": "^7.0.0",
"@linaria/react": "^7.0.1",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tabler/icons-react": "^3.41.1",
"@wyw-in-js/babel-preset": "^0.8.1",
"axios": "^1.14.0",
"gray-matter": "^4.0.3",
"gsap": "^3.14.2",
"lottie-react": "^2.4.1",
"next": "16.1.7",
"next-with-linaria": "^1.3.0",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"three": "^0.183.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.183.1",
"babel-plugin-react-compiler": "1.0.0"
}
}
File diff suppressed because one or more lines are too long
@@ -25,7 +25,7 @@ export const HELPED_DATA: HelpedDataType = {
text: 'Realytics built lead scoring into their CRM and increased qualified outbound by 40%.',
},
illustration: {
src: 'https://app.endlesstools.io/embed/54cb2a73-baef-4eff-9957-17d262b5bbaf',
src: '/illustrations/home/helped/one.glb',
title: '',
},
},
@@ -36,7 +36,7 @@ export const HELPED_DATA: HelpedDataType = {
text: 'Beagle launched a custom onboarding pipeline and shortened their sales cycle by 30%.',
},
illustration: {
src: 'https://app.endlesstools.io/embed/0988b17d-7648-4b95-bd6b-8fab1e798da9',
src: '/illustrations/home/helped/two.glb',
title: '',
},
},
@@ -48,7 +48,7 @@ export const HELPED_DATA: HelpedDataType = {
text: 'Evergreen unified sales and CS workflows, boosting expansion revenue by 25%.',
},
illustration: {
src: 'https://app.endlesstools.io/embed/18fc3259-eb0d-4254-961c-0fc26aa7b205',
src: '/illustrations/home/helped/three.glb',
title: '',
},
},
@@ -0,0 +1,7 @@
export { HELPED_DATA } from './helped';
export { HERO_DATA } from './hero';
export { HOME_STEPPER_DATA } from './home-stepper';
export { PROBLEM_DATA } from './problem';
export { TESTIMONIALS_DATA } from './testimonials';
export { THREE_CARDS_FEATURE_DATA } from './three-cards-feature';
export { THREE_CARDS_ILLUSTRATION_DATA } from './three-cards-illustration';
@@ -1,4 +1,4 @@
import { ProblemDataType } from '@/sections/Problem/types';
import type { ProblemDataType } from '@/sections/Problem/types';
export const PROBLEM_DATA: ProblemDataType = {
eyebrow: { heading: { text: 'The Problem:', fontFamily: 'sans' } },
@@ -52,7 +52,7 @@ export const TESTIMONIALS_DATA: TestimonialsDataType = {
},
],
illustration: {
src: 'https://app.endlesstools.io/embed/61c7255f-cf10-46f6-ac99-91047fd76d75',
title: 'Endless Tools Editor',
src: '/illustrations/home/testimonials/testimonials.glb',
title: 'Testimonials illustration',
},
};
@@ -1,4 +1,4 @@
import { ThreeCardsFeatureCardsDataType } from '@/sections/ThreeCards/types';
import type { ThreeCardsFeatureCardsDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
eyebrow: {
@@ -1,4 +1,4 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
@@ -23,8 +23,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
company: { text: 'Mid-Market Fintech' },
},
illustration: {
src: 'https://app.endlesstools.io/embed/ea42603a-8644-4634-8b7a-9b7bd9a8d899',
title: 'Endless Tools Editor',
src: '/illustrations/home/three-cards-illustration/one.glb',
title: 'Production grade illustration',
},
},
{
@@ -38,8 +38,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
company: { text: 'Mid-Market Fintech' },
},
illustration: {
src: 'https://app.endlesstools.io/embed/99a4f00e-bb70-4192-82e1-24e9ce8cd136',
title: 'Endless Tools Editor',
src: '/illustrations/home/three-cards-illustration/two.glb',
title: 'Rapid iteration illustration',
},
},
{
@@ -53,8 +53,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
company: { text: 'Mid-Market Fintech' },
},
illustration: {
src: 'https://app.endlesstools.io/embed/ba47c943-9565-412e-ae45-4ec092c149aa',
title: 'Endless Tools Editor',
src: '/illustrations/home/three-cards-illustration/three.glb',
title: 'Control illustration',
},
},
],
@@ -1,15 +1,18 @@
import { FAQ_DATA } from '@/app/(home)/constants/faq';
import { HELPED_DATA } from '@/app/(home)/constants/helped';
import { HERO_DATA } from '@/app/(home)/constants/hero';
import { HOME_STEPPER_DATA } from '@/app/(home)/constants/home-stepper';
import { MENU_DATA } from '@/app/(home)/constants/menu';
import { PROBLEM_DATA } from '@/app/(home)/constants/problem';
import { TESTIMONIALS_DATA } from '@/app/(home)/constants/testimonials';
import { THREE_CARDS_FEATURE_DATA } from '@/app/(home)/constants/three-cards-feature';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/(home)/constants/three-cards-illustration';
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import {
HELPED_DATA,
HERO_DATA,
HOME_STEPPER_DATA,
PROBLEM_DATA,
TESTIMONIALS_DATA,
THREE_CARDS_FEATURE_DATA,
THREE_CARDS_ILLUSTRATION_DATA,
} from '@/app/(home)/_constants';
import { FAQ_DATA, MENU_DATA, TRUSTED_BY_DATA } from '@/app/_constants';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { ScrollReveal } from '@/motion/ScrollReveal';
import { Faq } from '@/sections/Faq/components';
import { Helped } from '@/sections/Helped/components';
import { Hero } from '@/sections/Hero/components';
@@ -20,133 +23,164 @@ import { Testimonials } from '@/sections/Testimonials/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Twenty — Open Source CRM',
description: 'Modular, scalable open source CRM for modern teams.',
};
export default async function HomePage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(
MENU_DATA.socialLinks,
stats,
);
export default function HomePage() {
return (
<>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={MENU_DATA.socialLinks}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={MENU_DATA.socialLinks} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Home} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Home} body={HERO_DATA.body} size="sm" />
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
</Hero.Cta>
<Hero.HomeVisual visual={HERO_DATA.visual} />
</Hero.Root>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
<Problem.Root>
<Problem.Visual />
<Problem.Content>
<Eyebrow
colorScheme="primary"
heading={PROBLEM_DATA.eyebrow.heading}
/>
<Problem.Heading segments={PROBLEM_DATA.heading} />
<Problem.Points points={PROBLEM_DATA.points} />
</Problem.Content>
</Problem.Root>
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Home} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
/>
</ThreeCards.Root>
<HomeStepper.ScrollSection steps={HOME_STEPPER_DATA.steps} />
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Home} align="center">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_FEATURE_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_FEATURE_DATA.heading}
size="lg"
weight="light"
/>
</ThreeCards.Intro>
<ThreeCards.FeatureCards
featureCards={THREE_CARDS_FEATURE_DATA.featureCards}
/>
</ThreeCards.Root>
<Helped.Root backgroundColor={theme.colors.secondary.background[5]}>
<Helped.Scene data={HELPED_DATA} />
</Helped.Root>
<Testimonials.Root
backgroundColor={theme.colors.secondary.background[5]}
color={theme.colors.primary.text[100]}
shapeFillColor={theme.colors.primary.background[100]}
>
<Testimonials.Carousel
eyebrow={TESTIMONIALS_DATA.eyebrow}
illustration={TESTIMONIALS_DATA.illustration}
testimonials={TESTIMONIALS_DATA.testimonials}
/>
</Testimonials.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<ScrollReveal>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Home} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Home} body={HERO_DATA.body} size="sm" />
<Hero.Cta>
<LinkButton
color="primary"
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<LinkButton
color="primary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="outlined"
</Hero.Cta>
<Hero.HomeVisual visual={HERO_DATA.visual} />
</Hero.Root>
</ScrollReveal>
<ScrollReveal>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
</ScrollReveal>
<ScrollReveal>
<Problem.Root>
<Problem.Visual />
<Problem.Content>
<Eyebrow
colorScheme="primary"
heading={PROBLEM_DATA.eyebrow.heading}
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
<Problem.Heading segments={PROBLEM_DATA.heading} />
<Problem.Points points={PROBLEM_DATA.points} />
</Problem.Content>
</Problem.Root>
</ScrollReveal>
<ScrollReveal>
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Home} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
/>
</ThreeCards.Root>
</ScrollReveal>
<HomeStepper.ScrollSection steps={HOME_STEPPER_DATA.steps} />
<ScrollReveal>
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Home} align="center">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_FEATURE_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_FEATURE_DATA.heading}
size="lg"
weight="light"
/>
</ThreeCards.Intro>
<ThreeCards.FeatureCards
featureCards={THREE_CARDS_FEATURE_DATA.featureCards}
/>
</ThreeCards.Root>
</ScrollReveal>
<ScrollReveal>
<Helped.Root backgroundColor={theme.colors.secondary.background[5]}>
<Helped.Scene data={HELPED_DATA} />
</Helped.Root>
</ScrollReveal>
<ScrollReveal>
<Testimonials.Root
backgroundColor={theme.colors.secondary.background[5]}
color={theme.colors.primary.text[100]}
shapeFillColor={theme.colors.primary.background[100]}
>
<Testimonials.Carousel
eyebrow={TESTIMONIALS_DATA.eyebrow}
illustration={TESTIMONIALS_DATA.illustration}
testimonials={TESTIMONIALS_DATA.testimonials}
/>
</Testimonials.Root>
</ScrollReveal>
<ScrollReveal>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow
colorScheme="secondary"
heading={FAQ_DATA.eyebrow.heading}
/>
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<LinkButton
color="primary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
</ScrollReveal>
</>
);
}
@@ -1,9 +1,9 @@
import { FaqDataType } from '@/sections/Faq/types';
import type { FaqDataType } from '@/sections/Faq/types';
export const FAQ_DATA: FaqDataType = {
illustration: {
src: 'https://app.endlesstools.io/embed/0510f6d4-e09b-4470-86f8-7fb8d9db4c45',
title: 'Endless Tools Editor',
src: '/illustrations/common/faq/faq.glb',
title: 'FAQ illustration',
},
eyebrow: {
heading: {
@@ -28,7 +28,7 @@ export const FAQ_DATA: FaqDataType = {
fontFamily: 'sans',
},
answer: {
text: "Toute personne souhaitant investir dans l'immobilier de luxe, qu'elle soit expérimentée ou novice, peut soumettre une candidature pour rejoindre le Kretz Club.",
text: 'You can seamlessly deploy Twenty CRM to your production within hours. For highly bespoke on-premise implementations and massive scale data structures, our guided integration typically takes less than two weeks.',
},
},
{
@@ -2,12 +2,11 @@ import type { FooterDataType } from '@/sections/Footer/types';
export const FOOTER_DATA: FooterDataType = {
illustration: {
src: 'https://app.endlesstools.io/embed/73beb0a6-bcda-410f-8e4c-a3bcd70224b0',
src: '/illustrations/common/footer/footer.glb',
title: 'Footer background',
},
bottom: {
copyright: '© 2026 Twenty',
credit: 'Design by Ardent.',
},
navGroups: [
{
@@ -30,7 +29,7 @@ export const FOOTER_DATA: FooterDataType = {
{ label: 'Developers', href: '#', external: false },
{ label: 'FAQ', href: '#', external: false },
{ label: 'Support', href: '#', external: false },
{ label: 'Release Notes', href: '#', external: false },
{ label: 'Release Notes', href: '/release-notes', external: false },
],
},
{
@@ -39,8 +38,8 @@ export const FOOTER_DATA: FooterDataType = {
ctas: [],
links: [
{ label: 'Cookie Policy', href: '#', external: false },
{ label: 'Privacy Policy', href: '#', external: false },
{ label: 'Terms and Conditions', href: '#', external: false },
{ label: 'Privacy Policy', href: '/privacy-policy', external: false },
{ label: 'Terms and Conditions', href: '/terms', external: false },
],
},
{
@@ -75,33 +74,22 @@ export const FOOTER_DATA: FooterDataType = {
{
href: 'https://github.com/twentyhq/twenty',
icon: 'github',
label: '39.8K',
ariaLabel: 'GitHub (opens in new tab)',
showInDesktop: true,
showInDrawer: true,
},
{
href: 'https://discord.gg/cx5n4Jzs57',
icon: 'discord',
label: '5.6K',
ariaLabel: 'Discord (opens in new tab)',
className: 'discord-link',
showInDesktop: true,
showInDrawer: true,
},
{
href: 'https://www.linkedin.com/company/twenty',
icon: 'linkedin',
ariaLabel: 'LinkedIn (opens in new tab)',
showInDesktop: false,
showInDrawer: true,
},
{
href: 'https://x.com/twentycrm',
icon: 'x',
ariaLabel: 'X (opens in new tab)',
showInDesktop: false,
showInDrawer: true,
},
],
};
@@ -0,0 +1,4 @@
export { FAQ_DATA } from './faq';
export { FOOTER_DATA } from './footer';
export { MENU_DATA } from './menu';
export { TRUSTED_BY_DATA } from './trusted-by';
@@ -12,7 +12,6 @@ export const MENU_DATA: MenuDataType = {
ariaLabel: 'GitHub (opens in new tab)',
href: 'https://github.com/twentyhq/twenty',
icon: 'github',
label: '39.8K',
showInDesktop: true,
showInDrawer: true,
},
@@ -21,7 +20,6 @@ export const MENU_DATA: MenuDataType = {
className: 'discord-link',
href: 'https://discord.gg/cx5n4Jzs57',
icon: 'discord',
label: '5.6K',
showInDesktop: true,
showInDrawer: true,
},
@@ -1,4 +1,4 @@
import { type TrustedByDataType } from '@/sections/TrustedBy/types';
import type { TrustedByDataType } from '@/sections/TrustedBy/types';
export const TRUSTED_BY_DATA: TrustedByDataType = {
clientCountLabel: { text: '+10k others' },
+15 -7
View File
@@ -1,4 +1,7 @@
import { FOOTER_DATA } from '@/app/(home)/constants/footer';
import { FOOTER_DATA } from '@/app/_constants/footer';
import { GlbWarmCache } from '@/constants/glb-warm-cache.component';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Footer } from '@/sections/Footer/components';
import { theme } from '@/theme';
import { cssVariables } from '@/theme/css-variables';
@@ -62,24 +65,29 @@ export const metadata: Metadata = {
description: 'Modular, scalable open source CRM for modern teams.',
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const stats = await fetchCommunityStats();
const footerSocialLinks = mergeSocialLinkLabels(
FOOTER_DATA.socialLinks,
stats,
);
return (
<html lang="en" className="light">
<html lang="en">
<body
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable}`}
>
<GlbWarmCache />
<StyledMain>{children}</StyledMain>
<Footer.Root illustration={FOOTER_DATA.illustration}>
<Footer.Logo />
<Footer.Nav groups={FOOTER_DATA.navGroups} />
<Footer.Bottom
copyright={FOOTER_DATA.bottom.copyright}
credit={FOOTER_DATA.bottom.credit}
>
<Footer.Social links={FOOTER_DATA.socialLinks} />
</Footer.Bottom>
links={footerSocialLinks}
/>
</Footer.Root>
</body>
</html>
@@ -1,4 +1,4 @@
import { HeroIllustrationDataType } from '@/sections/Hero/types';
import type { HeroIllustrationDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroIllustrationDataType = {
heading: [
@@ -10,6 +10,6 @@ export const HERO_DATA: HeroIllustrationDataType = {
},
illustration: {
src: 'https://app.endlesstools.io/embed/1c6c8259-3276-4cf2-84d8-6b7e87e7ec95',
title: 'Endless Tools Editor',
title: 'Partner hero illustration',
},
};
@@ -0,0 +1,5 @@
export { ENGAGEMENT_BAND_DATA } from './engagement-band';
export { HERO_DATA } from './hero';
export { SIGNOFF_DATA } from './signoff';
export { TESTIMONIALS_DATA } from './testimonials';
export { THREE_CARDS_ILLUSTRATION_DATA } from './three-cards-illustration';
@@ -1,4 +1,4 @@
import { SignoffDataType } from '@/sections/Signoff/types';
import type { SignoffDataType } from '@/sections/Signoff/types';
export const SIGNOFF_DATA: SignoffDataType = {
heading: [
@@ -52,7 +52,7 @@ export const TESTIMONIALS_DATA: TestimonialsDataType = {
},
],
illustration: {
src: 'https://app.endlesstools.io/embed/48586814-c307-4abd-8eec-0270ef3e91ce',
title: 'Endless Tools Editor',
src: '/illustrations/partner/testimonials/testimonials.glb',
title: 'Partner testimonials illustration',
},
};
@@ -1,4 +1,4 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
@@ -25,8 +25,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
],
attribution: undefined,
illustration: {
src: 'https://app.endlesstools.io/embed/627cb43a-5b5f-41a7-9642-26e66b9c4890',
title: 'Endless Tools Editor',
src: '/illustrations/partner/three-cards-illustration/one.glb',
title: 'Technology partners illustration',
},
},
{
@@ -41,8 +41,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
],
attribution: undefined,
illustration: {
src: 'https://app.endlesstools.io/embed/4ef11ab1-d013-46dd-bf2e-cbf913370a8a',
title: 'Endless Tools Editor',
src: '/illustrations/partner/three-cards-illustration/two.glb',
title: 'Content and community partners illustration',
},
},
{
@@ -57,8 +57,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
],
attribution: undefined,
illustration: {
src: 'https://app.endlesstools.io/embed/624e9b40-f52c-4da2-9a2e-72d1fb2b5356',
title: 'Endless Tools Editor',
src: '/illustrations/partner/three-cards-illustration/three.glb',
title: 'Solutions partners illustration',
},
},
],
@@ -1,13 +1,16 @@
import { FAQ_DATA } from '@/app/(home)/constants/faq';
import { MENU_DATA } from '@/app/(home)/constants/menu';
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { ENGAGEMENT_BAND_DATA } from '@/app/partner/constants/engagement-band';
import { HERO_DATA } from '@/app/partner/constants/hero';
import { TESTIMONIALS_DATA } from '@/app/partner/constants/testimonials';
import { SIGNOFF_DATA } from '@/app/partner/constants/signoff';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/partner/constants/three-cards-illustration';
import { FAQ_DATA, MENU_DATA, TRUSTED_BY_DATA } from '@/app/_constants';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import {
ENGAGEMENT_BAND_DATA,
HERO_DATA,
SIGNOFF_DATA,
TESTIMONIALS_DATA,
THREE_CARDS_ILLUSTRATION_DATA,
} from '@/app/partner/_constants';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { ScrollReveal } from '@/motion/ScrollReveal';
import { EngagementBand } from '@/sections/EngagementBand/components';
import { Faq } from '@/sections/Faq/components';
import { Hero } from '@/sections/Hero/components';
@@ -17,155 +20,185 @@ import { Testimonials } from '@/sections/Testimonials/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Partners — Twenty',
description:
'Join our partner ecosystem and grow with us as we build the #1 open-source CRM.',
};
export default async function PartnerPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(
MENU_DATA.socialLinks,
stats,
);
export default function PartnerPage() {
return (
<>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={MENU_DATA.socialLinks}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={MENU_DATA.socialLinks} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Partner} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Partner} body={HERO_DATA.body} />
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Become a partner"
type="anchor"
variant="outlined"
/>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Find a partner"
type="anchor"
variant="contained"
/>
</Hero.Cta>
<Hero.Illustration
illustration={HERO_DATA.illustration}
backgroundColor={theme.colors.secondary.background[100]}
/>
</Hero.Root>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
<EngagementBand.Root
backgroundColor={theme.colors.primary.background[100]}
>
<EngagementBand.Strip
fillColor={theme.colors.secondary.background[100]}
variant="secondary"
>
<EngagementBand.Copy>
<EngagementBand.Heading segments={ENGAGEMENT_BAND_DATA.heading} />
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
</EngagementBand.Copy>
<EngagementBand.Actions>
<ScrollReveal>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Partner} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Partner} body={HERO_DATA.body} />
<Hero.Cta>
<LinkButton
color="primary"
color="secondary"
href="https://app.twenty.com/welcome"
label="Read our case studies"
type="anchor"
variant="contained"
/>
</EngagementBand.Actions>
</EngagementBand.Strip>
</EngagementBand.Root>
<ThreeCards.Root backgroundColor={theme.colors.secondary.background[5]}>
<ThreeCards.Intro page={Pages.Partner} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
variant="simple"
/>
</ThreeCards.Root>
<Testimonials.Root
backgroundColor={theme.colors.secondary.background[5]}
color={theme.colors.primary.text[100]}
>
<Testimonials.Carousel
eyebrow={TESTIMONIALS_DATA.eyebrow}
illustration={TESTIMONIALS_DATA.illustration}
testimonials={TESTIMONIALS_DATA.testimonials}
/>
</Testimonials.Root>
<Signoff.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.primary.text[100]}
>
<Signoff.Heading segments={SIGNOFF_DATA.heading} />
<Signoff.Body body={SIGNOFF_DATA.body} />
<Signoff.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Become a partner"
type="anchor"
variant="outlined"
/>
<LinkButton
color="secondary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="contained"
/>
</Signoff.Cta>
</Signoff.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<LinkButton
color="primary"
href="https://twenty.com/contact"
label="Talk to us"
label="Become a partner"
type="anchor"
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Find a partner"
type="anchor"
variant="contained"
/>
</Hero.Cta>
<Hero.Illustration
illustration={HERO_DATA.illustration}
backgroundColor={theme.colors.secondary.background[100]}
/>
</Hero.Root>
</ScrollReveal>
<ScrollReveal>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
</ScrollReveal>
<ScrollReveal>
<EngagementBand.Root
backgroundColor={theme.colors.primary.background[100]}
>
<EngagementBand.Strip
fillColor={theme.colors.secondary.background[100]}
variant="secondary"
>
<EngagementBand.Copy>
<EngagementBand.Heading segments={ENGAGEMENT_BAND_DATA.heading} />
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
</EngagementBand.Copy>
<EngagementBand.Actions>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Read our case studies"
type="anchor"
variant="contained"
/>
</EngagementBand.Actions>
</EngagementBand.Strip>
</EngagementBand.Root>
</ScrollReveal>
<ScrollReveal>
<ThreeCards.Root backgroundColor={theme.colors.secondary.background[5]}>
<ThreeCards.Intro page={Pages.Partner} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
variant="simple"
/>
</ThreeCards.Root>
</ScrollReveal>
<ScrollReveal>
<Testimonials.Root
backgroundColor={theme.colors.secondary.background[5]}
color={theme.colors.primary.text[100]}
>
<Testimonials.Carousel
eyebrow={TESTIMONIALS_DATA.eyebrow}
illustration={TESTIMONIALS_DATA.illustration}
testimonials={TESTIMONIALS_DATA.testimonials}
/>
</Testimonials.Root>
</ScrollReveal>
<ScrollReveal>
<Signoff.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.primary.text[100]}
>
<Signoff.Heading segments={SIGNOFF_DATA.heading} />
<Signoff.Body body={SIGNOFF_DATA.body} />
<Signoff.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Become a partner"
type="anchor"
variant="outlined"
/>
<LinkButton
color="secondary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="contained"
/>
</Signoff.Cta>
</Signoff.Root>
</ScrollReveal>
<ScrollReveal>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow
colorScheme="secondary"
heading={FAQ_DATA.eyebrow.heading}
/>
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<LinkButton
color="primary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
</ScrollReveal>
</>
);
}
@@ -1,4 +1,4 @@
import { HeroBaseDataType } from '@/sections/Hero/types';
import type { HeroBaseDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroBaseDataType = {
heading: [
@@ -0,0 +1,5 @@
export { ENGAGEMENT_BAND_DATA } from './engagement-band';
export { HERO_DATA } from './hero';
export { PLAN_TABLE_DATA } from './plan-table';
export { PLANS_DATA } from './plans';
export { SALESFORCE_DATA } from './salesforce';
@@ -5,13 +5,13 @@ const ORGANIZATION_HEADING = {
};
const PRO_ILLUSTRATION = {
src: 'https://app.endlesstools.io/embed/207b0732-c6ef-414e-add5-462e68689a2e',
title: 'Endless Tools Editor',
src: '/illustrations/pricing/Price/pro.glb',
title: 'Pro plan illustration',
};
const ORGANIZATION_ILLUSTRATION = {
src: 'https://app.endlesstools.io/embed/588638be-e22e-4467-b701-3f36e900613d',
title: 'Endless Tools Editor',
src: '/illustrations/pricing/Price/organization.glb',
title: 'Organization plan illustration',
};
const PRO_FEATURES_TITLE = { text: 'Key Features' };
@@ -1,11 +1,15 @@
import { FAQ_DATA } from '@/app/(home)/constants/faq';
import { MENU_DATA } from '@/app/(home)/constants/menu';
import { ENGAGEMENT_BAND_DATA } from '@/app/partner/constants/engagement-band';
import { HERO_DATA } from '@/app/pricing/constants/hero';
import { PLAN_TABLE_DATA } from '@/app/pricing/constants/plan-table';
import { SALESFORCE_DATA } from '@/app/pricing/constants/salesforce';
import { FAQ_DATA, MENU_DATA } from '@/app/_constants';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import {
ENGAGEMENT_BAND_DATA,
HERO_DATA,
PLAN_TABLE_DATA,
SALESFORCE_DATA,
} from '@/app/pricing/_constants';
import { Eyebrow, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { ScrollReveal } from '@/motion/ScrollReveal';
import { EngagementBand } from '@/sections/EngagementBand/components';
import { Faq } from '@/sections/Faq/components';
import { Hero } from '@/sections/Hero/components';
@@ -14,57 +18,80 @@ import { Plans } from '@/sections/Plans/components';
import { PlanTable } from '@/sections/PlanTable/components';
import { Salesforce } from '@/sections/Salesforce/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Pricing — Twenty',
description:
'Plans that scale with your team. Compare tiers and see how Twenty stacks up for your open source CRM.',
};
export default async function PricingPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(
MENU_DATA.socialLinks,
stats,
);
export default function PricingPage() {
return (
<>
<Menu.Root
backgroundColor={theme.colors.secondary.background[5]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={MENU_DATA.socialLinks}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={MENU_DATA.socialLinks} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.Root backgroundColor={theme.colors.secondary.background[5]}>
<Hero.Heading page={Pages.Pricing} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Pricing} body={HERO_DATA.body} />
</Hero.Root>
<ScrollReveal>
<Hero.Root backgroundColor={theme.colors.secondary.background[5]}>
<Hero.Heading page={Pages.Pricing} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Pricing} body={HERO_DATA.body} />
</Hero.Root>
</ScrollReveal>
<Plans.Root backgroundColor={theme.colors.secondary.background[5]}>
<Plans.Content />
</Plans.Root>
<ScrollReveal>
<Plans.Root backgroundColor={theme.colors.secondary.background[5]}>
<Plans.Content />
</Plans.Root>
</ScrollReveal>
<EngagementBand.Root
backgroundColor={theme.colors.secondary.background[5]}
>
<EngagementBand.Strip
fillColor={theme.colors.primary.background[100]}
variant="primary"
<ScrollReveal>
<EngagementBand.Root
backgroundColor={theme.colors.secondary.background[5]}
>
<EngagementBand.Copy>
<EngagementBand.Heading segments={ENGAGEMENT_BAND_DATA.heading} />
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
</EngagementBand.Copy>
<EngagementBand.Actions>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Read our case studies"
type="anchor"
variant="contained"
/>
</EngagementBand.Actions>
</EngagementBand.Strip>
</EngagementBand.Root>
<EngagementBand.Strip
fillColor={theme.colors.primary.background[100]}
variant="primary"
>
<EngagementBand.Copy>
<EngagementBand.Heading segments={ENGAGEMENT_BAND_DATA.heading} />
<EngagementBand.Body body={ENGAGEMENT_BAND_DATA.body} />
</EngagementBand.Copy>
<EngagementBand.Actions>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Read our case studies"
type="anchor"
variant="contained"
/>
</EngagementBand.Actions>
</EngagementBand.Strip>
</EngagementBand.Root>
</ScrollReveal>
<PlanTable.Root backgroundColor={theme.colors.secondary.background[100]}>
<PlanTable.Content data={PLAN_TABLE_DATA} />
</PlanTable.Root>
<ScrollReveal>
<PlanTable.Root
backgroundColor={theme.colors.secondary.background[100]}
>
<PlanTable.Content data={PLAN_TABLE_DATA} />
</PlanTable.Root>
</ScrollReveal>
<Salesforce.Flow
backgroundColor={theme.colors.secondary.background[5]}
@@ -73,29 +100,34 @@ export default function PricingPage() {
pricing={SALESFORCE_DATA.pricing}
/>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
<ScrollReveal>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow
colorScheme="secondary"
heading={FAQ_DATA.eyebrow.heading}
/>
<LinkButton
color="primary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<LinkButton
color="primary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
</ScrollReveal>
</>
);
}
@@ -0,0 +1,63 @@
import { MENU_DATA } from '@/app/_constants';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocumentPage } from '@/sections/LegalDocument/legal-document-page';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Privacy Policy — Twenty',
description: 'How Twenty collects, uses, and protects your information.',
};
export default async function PrivacyPolicyPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(
MENU_DATA.socialLinks,
stats,
);
return (
<LegalDocumentPage
menuData={{ navItems: MENU_DATA.navItems, socialLinks: menuSocialLinks }}
title="Privacy Policy"
>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat.
</p>
<h2>Information we collect</h2>
<p>
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum
dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
accusantium doloremque laudantium.
</p>
<p>
Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi
architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem
quia voluptas sit aspernatur aut odit aut fugit.
</p>
<h2>How we use information</h2>
<p>
At vero eos et accusamus et iusto odio dignissimos ducimus qui
blanditiis praesentium voluptatum deleniti atque corrupti quos dolores
et quas molestias excepturi sint occaecati cupiditate non provident.
</p>
<p>
Similique sunt in culpa qui officia deserunt mollitia animi, id est
laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita
distinctio nam libero tempore, cum soluta nobis est eligendi optio
cumque nihil impedit quo minus id quod maxime placeat facere possimus.
</p>
<h2>Contact</h2>
<p>
Omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem
quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet
ut et voluptates repudiandae sint et molestiae non recusandae.
</p>
</LegalDocumentPage>
);
}
@@ -1,4 +1,4 @@
import { HeroIllustrationDataType } from '@/sections/Hero/types';
import type { HeroIllustrationDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroIllustrationDataType = {
heading: [
@@ -9,7 +9,7 @@ export const HERO_DATA: HeroIllustrationDataType = {
text: 'Modern interface. AI assistance. All the features you need, ready from day one.',
},
illustration: {
src: 'https://app.endlesstools.io/embed/0bcf3ac2-58cf-4cd5-90bd-e8fada9816a9',
title: 'Endless Tools Editor',
src: '/illustrations/product/hero/hero.glb',
title: 'Product hero illustration',
},
};
@@ -0,0 +1,6 @@
export { DEMO_DATA } from './demo';
export { FEATURE_DATA } from './feature';
export { HERO_DATA } from './hero';
export { STEPPER_DATA } from './stepper';
export { TABS_DATA } from './tabs';
export { THREE_CARDS_ILLUSTRATION_DATA } from './three-cards';
@@ -1,4 +1,4 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
import type { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
@@ -18,8 +18,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
benefits: undefined,
attribution: undefined,
illustration: {
src: 'https://app.endlesstools.io/embed/21767ad6-67f4-4941-a668-6cf772c27621',
title: 'Endless Tools Editor',
src: '/illustrations/product/three-cards-illustration/one.glb',
title: 'Built for speed illustration',
},
},
{
@@ -30,8 +30,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
benefits: undefined,
attribution: undefined,
illustration: {
src: 'https://app.endlesstools.io/embed/9c504c9c-46f9-423c-98b3-2e840ddb3b1e',
title: 'Endless Tools Editor',
src: '/illustrations/product/three-cards-illustration/two.glb',
title: 'Real-time data illustration',
},
},
{
@@ -42,8 +42,8 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
benefits: undefined,
attribution: undefined,
illustration: {
src: 'https://app.endlesstools.io/embed/8d577747-2718-4e7e-87a6-dd9c334f3903',
title: 'Endless Tools Editor',
src: '/illustrations/product/three-cards-illustration/three.glb',
title: 'Stay in flow illustration',
},
},
],
@@ -1,17 +1,20 @@
import { FAQ_DATA } from '@/app/(home)/constants/faq';
import { MENU_DATA } from '@/app/(home)/constants/menu';
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { DEMO_DATA } from '@/app/product/constants/demo';
import { FEATURE_DATA } from '@/app/product/constants/feature';
import { HERO_DATA } from '@/app/product/constants/hero';
import { STEPPER_DATA } from '@/app/product/constants/stepper';
import { TABS_DATA } from '@/app/product/constants/tabs';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/product/constants/three-cards';
import { FAQ_DATA, MENU_DATA, TRUSTED_BY_DATA } from '@/app/_constants';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import {
DEMO_DATA,
FEATURE_DATA,
HERO_DATA,
STEPPER_DATA,
TABS_DATA,
THREE_CARDS_ILLUSTRATION_DATA,
} from '@/app/product/_constants';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { ScrollReveal } from '@/motion/ScrollReveal';
import { Demo } from '@/sections/Demo/components';
import { Feature } from '@/sections/Feature/components';
import { Faq } from '@/sections/Faq/components';
import { Feature } from '@/sections/Feature/components';
import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { ProductStepper } from '@/sections/ProductStepper/components';
@@ -19,83 +22,109 @@ import { Tabs } from '@/sections/Tabs/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Product — Twenty',
description:
'Modern interface. AI assistance. All the features you need, ready from day one.',
};
export default async function ProductPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(
MENU_DATA.socialLinks,
stats,
);
export default function ProductPage() {
return (
<>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={MENU_DATA.socialLinks}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={MENU_DATA.socialLinks} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Product} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Product} body={HERO_DATA.body} />
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
<ScrollReveal>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading page={Pages.Product} segments={HERO_DATA.heading} />
<Hero.Body page={Pages.Product} body={HERO_DATA.body} />
<Hero.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
</Hero.Cta>
<Hero.Illustration
illustration={HERO_DATA.illustration}
backgroundColor={theme.colors.secondary.background[5]}
/>
</Hero.Cta>
<Hero.Illustration
illustration={HERO_DATA.illustration}
backgroundColor={theme.colors.secondary.background[5]}
/>
</Hero.Root>
</Hero.Root>
</ScrollReveal>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
<ScrollReveal>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
</ScrollReveal>
<Tabs.Root>
<Eyebrow colorScheme="secondary" heading={TABS_DATA.eyebrow.heading} />
<Tabs.Heading segments={TABS_DATA.heading} />
<Tabs.Body body={TABS_DATA.body} />
<Tabs.TabGroup tabs={TABS_DATA.tabs} />
</Tabs.Root>
<Feature.Root backgroundColor={theme.colors.primary.background[100]}>
<Feature.Intro align="center" page={Pages.Product}>
<ScrollReveal>
<Tabs.Root>
<Eyebrow
colorScheme="primary"
heading={FEATURE_DATA.eyebrow.heading}
colorScheme="secondary"
heading={TABS_DATA.eyebrow.heading}
/>
<Heading segments={FEATURE_DATA.heading} size="lg" weight="light" />
</Feature.Intro>
<Feature.Tiles mask={FEATURE_DATA.mask} tiles={FEATURE_DATA.tiles} />
</Feature.Root>
<Tabs.Heading segments={TABS_DATA.heading} />
<Tabs.Body body={TABS_DATA.body} />
<Tabs.TabGroup tabs={TABS_DATA.tabs} />
</Tabs.Root>
</ScrollReveal>
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Product} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
<ScrollReveal>
<Feature.Root backgroundColor={theme.colors.primary.background[100]}>
<Feature.Intro align="center" page={Pages.Product}>
<Eyebrow
colorScheme="primary"
heading={FEATURE_DATA.eyebrow.heading}
/>
<Heading segments={FEATURE_DATA.heading} size="lg" weight="light" />
</Feature.Intro>
<Feature.Tiles mask={FEATURE_DATA.mask} tiles={FEATURE_DATA.tiles} />
</Feature.Root>
</ScrollReveal>
<ScrollReveal>
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Product} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
/>
</ThreeCards.Root>
</ThreeCards.Root>
</ScrollReveal>
<ProductStepper.Flow
body={STEPPER_DATA.body}
@@ -104,44 +133,51 @@ export default function ProductPage() {
steps={STEPPER_DATA.steps}
/>
<Demo.Root>
<Eyebrow colorScheme="primary" heading={DEMO_DATA.eyebrow.heading} />
<Demo.Heading segments={DEMO_DATA.heading} />
<Demo.Cta>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Try twenty cloud"
type="anchor"
variant="contained"
/>
</Demo.Cta>
<Demo.Screenshot image={DEMO_DATA.image} />
</Demo.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<ScrollReveal>
<Demo.Root>
<Eyebrow colorScheme="primary" heading={DEMO_DATA.eyebrow.heading} />
<Demo.Heading segments={DEMO_DATA.heading} />
<Demo.Cta>
<LinkButton
color="primary"
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
label="Try twenty cloud"
type="anchor"
variant="contained"
/>
<LinkButton
color="primary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="outlined"
</Demo.Cta>
<Demo.Screenshot image={DEMO_DATA.image} />
</Demo.Root>
</ScrollReveal>
<ScrollReveal>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow
colorScheme="secondary"
heading={FAQ_DATA.eyebrow.heading}
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<LinkButton
color="primary"
href="https://twenty.com/contact"
label="Talk to us"
type="anchor"
variant="outlined"
/>
</Faq.Cta>
</Faq.Intro>
<Faq.Items questions={FAQ_DATA.questions} />
</Faq.Root>
</ScrollReveal>
</>
);
}
@@ -0,0 +1,11 @@
import type { BodyType } from '@/design-system/components/Body/types/Body';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
export const RELEASE_NOTES_HERO_HEADING: HeadingType[] = [
{ fontFamily: 'sans', text: 'Release ' },
{ fontFamily: 'serif', text: 'notes' },
];
export const RELEASE_NOTES_HERO_BODY: BodyType = {
text: 'New features, fixes, and improvements across Twenty. Notes are maintained alongside the main marketing site content.',
};
@@ -0,0 +1,107 @@
import { MENU_DATA } from '@/app/_constants';
import {
RELEASE_NOTES_HERO_BODY,
RELEASE_NOTES_HERO_HEADING,
} from '@/app/release-notes/_constants/hero';
import { LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { fetchLatestGithubReleaseTag } from '@/lib/github/fetch-latest-release-tag';
import { getVisibleReleaseNotes } from '@/lib/releases/get-visible-releases';
import { loadLocalReleaseNotes } from '@/lib/releases/load-local-release-notes';
import { ScrollReveal } from '@/motion/ScrollReveal';
import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { ReleaseNotes } from '@/sections/ReleaseNotes/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
import { Fragment } from 'react';
export const metadata: Metadata = {
title: 'Release notes — Twenty',
description:
'Discover the newest features and improvements in Twenty, the open-source CRM.',
};
export default async function ReleaseNotesPage() {
const allNotes = loadLocalReleaseNotes();
const [latestTag, stats] = await Promise.all([
fetchLatestGithubReleaseTag(),
fetchCommunityStats(),
]);
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
const visibleNotes =
process.env.NODE_ENV === 'development'
? allNotes
: getVisibleReleaseNotes(allNotes, latestTag);
return (
<>
<Menu.Root
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>
<ScrollReveal>
<Hero.Root backgroundColor={theme.colors.primary.background[100]}>
<Hero.Heading
page={Pages.ReleaseNotes}
segments={RELEASE_NOTES_HERO_HEADING}
size="lg"
weight="light"
/>
<Hero.Body
page={Pages.ReleaseNotes}
body={RELEASE_NOTES_HERO_BODY}
size="sm"
/>
<Hero.Cta>
<LinkButton
color="secondary"
href="https://github.com/twentyhq/twenty/releases"
label="GitHub releases"
type="anchor"
variant="outlined"
/>
</Hero.Cta>
</Hero.Root>
</ScrollReveal>
<ReleaseNotes.Root>
{allNotes.length === 0 ? (
<ReleaseNotes.EmptyMessage>
Release notes were not found. This app reads release MDX from{' '}
<strong>packages/twenty-website/src/content/releases</strong> in the
monorepo. Ensure that folder is present when building or running
locally.
</ReleaseNotes.EmptyMessage>
) : visibleNotes.length === 0 ? (
<ReleaseNotes.EmptyMessage>
No releases are visible yet for the current published version.
</ReleaseNotes.EmptyMessage>
) : (
visibleNotes.map((note, index) => (
<Fragment key={note.slug}>
<ReleaseNotes.ReleaseEntry
content={note.content}
date={note.date}
release={note.release}
/>
{index < visibleNotes.length - 1 ? (
<ReleaseNotes.Divider />
) : null}
</Fragment>
))
)}
</ReleaseNotes.Root>
</>
);
}
@@ -0,0 +1,71 @@
import { MENU_DATA } from '@/app/_constants';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocumentPage } from '@/sections/LegalDocument/legal-document-page';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Terms and Conditions — Twenty',
description: 'Terms governing your use of Twenty products and services.',
};
export default async function TermsPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(
MENU_DATA.socialLinks,
stats,
);
return (
<LegalDocumentPage
menuData={{ navItems: MENU_DATA.navItems, socialLinks: menuSocialLinks }}
title="Terms and Conditions"
>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec
odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla
quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent
mauris.
</p>
<h2>Acceptance of terms</h2>
<p>
Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum
lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent
per conubia nostra, per inceptos himenaeos. Curabitur sodales ligula in
libero. Sed dignissim lacinia nunc.
</p>
<p>
Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at
dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel
nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis,
luctus non, massa.
</p>
<h2>Use of the service</h2>
<p>
Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus
metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque
volutpat condimentum velit. Class aptent taciti sociosqu ad litora
torquent per conubia nostra, per inceptos himenaeos.
</p>
<p>
Nam nec ante. Sed lacinia, urna non tincidunt mattis, tortor neque
adipiscing diam, a cursus ipsum ante quis turpis. Nulla facilisi. Ut
fringilla. Suspendisse potenti. Nunc feugiat mi a tellus consequat
imperdiet. Vestibulum sapien. Proin quam.
</p>
<h2>Limitation of liability</h2>
<p>
Etiam ultrices. Suspendisse in justo eu magna luctus suscipit. Sed
lectus. Integer euismod lacus luctus magna. Quisque cursus, metus vitae
pharetra auctor, sem massa mattis sem, at interdum magna augue eget
diam.
</p>
<p>
Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere
cubilia curae; Morbi lacinia molestie dui. Praesent blandit dolor. Sed
non quam. In vel mi sit amet augue congue elementum. Morbi in ipsum sit
amet pede facilisis laoreet.
</p>
</LegalDocumentPage>
);
}
@@ -1,4 +1,4 @@
import { HeroWhyTwentyDataType } from '@/sections/Hero/types';
import type { HeroWhyTwentyDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroWhyTwentyDataType = {
heading: [
@@ -13,7 +13,7 @@ export const HERO_DATA: HeroWhyTwentyDataType = {
alt: '',
},
illustration: {
src: 'https://app.endlesstools.io/embed/479566da-0f55-44ef-99fe-90350aa88b9c',
title: 'Endless Tools Editor',
src: '/illustrations/why-twenty/hero/logo.glb',
title: 'Why Twenty hero illustration',
},
};
@@ -0,0 +1,11 @@
export { EDITORIAL_FOUR } from './editorial-four';
export { EDITORIAL_ONE } from './editorial-one';
export { EDITORIAL_THREE } from './editorial-three';
export { EDITORIAL_TWO } from './editorial-two';
export { HERO_DATA } from './hero';
export { MARQUEE_DATA } from './marquee';
export { QUOTE_DATA } from './quote';
export { SIGNOFF_DATA } from './signoff';
export { STATEMENT_ONE } from './statement-one';
export { STATEMENT_TWO } from './statement-two';
export { STEPPER_DATA } from './stepper';
@@ -2,8 +2,8 @@ import type { QuoteDataType } from '@/sections/Quote/types/QuoteData';
export const QUOTE_DATA: QuoteDataType = {
illustration: {
src: 'https://app.endlesstools.io/embed/48586814-c307-4abd-8eec-0270ef3e91ce',
title: 'Endless Tools Editor',
src: '/illustrations/why-twenty/quote/quote.glb',
title: 'Quote illustration',
},
heading: [
{ text: '“In the AI age,\n', fontFamily: 'serif' },
@@ -1,4 +1,4 @@
import { SignoffDataType } from '@/sections/Signoff/types';
import type { SignoffDataType } from '@/sections/Signoff/types';
export const SIGNOFF_DATA: SignoffDataType = {
heading: [
@@ -14,7 +14,7 @@ export const STEPPER_DATA: WhyTwentyStepperDataType = {
},
],
illustration: {
src: 'https://app.endlesstools.io/embed/ddc485f9-cc8e-422b-a076-5430eb84e21a',
title: 'Endless Tools Editor',
src: '/illustrations/why-twenty/stepper/logo.glb',
title: 'Our vision illustration',
},
};
@@ -1,17 +1,22 @@
import { MENU_DATA } from '@/app/(home)/constants/menu';
import { EDITORIAL_FOUR } from '@/app/why-twenty/constants/editorial-four';
import { EDITORIAL_ONE } from '@/app/why-twenty/constants/editorial-one';
import { EDITORIAL_THREE } from '@/app/why-twenty/constants/editorial-three';
import { EDITORIAL_TWO } from '@/app/why-twenty/constants/editorial-two';
import { HERO_DATA } from '@/app/why-twenty/constants/hero';
import { MARQUEE_DATA } from '@/app/why-twenty/constants/marquee';
import { QUOTE_DATA } from '@/app/why-twenty/constants/quote';
import { SIGNOFF_DATA } from '@/app/why-twenty/constants/signoff';
import { STATEMENT_ONE } from '@/app/why-twenty/constants/statement-one';
import { STATEMENT_TWO } from '@/app/why-twenty/constants/statement-two';
import { STEPPER_DATA } from '@/app/why-twenty/constants/stepper';
import { MENU_DATA } from '@/app/_constants';
import {
EDITORIAL_FOUR,
EDITORIAL_ONE,
EDITORIAL_THREE,
EDITORIAL_TWO,
HERO_DATA,
MARQUEE_DATA,
QUOTE_DATA,
SIGNOFF_DATA,
STATEMENT_ONE,
STATEMENT_TWO,
STEPPER_DATA,
} from '@/app/why-twenty/_constants';
import { LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { ScrollReveal } from '@/motion/ScrollReveal';
import { Editorial } from '@/sections/Editorial/components';
import { Hero } from '@/sections/Hero/components';
import { Marquee } from '@/sections/Marquee/components';
@@ -21,112 +26,140 @@ import { Signoff } from '@/sections/Signoff/components';
import { Statement } from '@/sections/Statement/components';
import { WhyTwentyStepper } from '@/sections/WhyTwentyStepper/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Why Twenty — Twenty',
description:
'Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.',
};
export default async function WhyTwentyPage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
export default function WhyTwentyPage() {
return (
<>
<Menu.Root
backgroundColor={theme.colors.secondary.background[100]}
scheme="secondary"
navItems={MENU_DATA.navItems}
socialLinks={MENU_DATA.socialLinks}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="secondary" />
<Menu.Nav scheme="secondary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="secondary" socialLinks={MENU_DATA.socialLinks} />
<Menu.Social scheme="secondary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="secondary" />
</Menu.Root>
<Hero.Root backgroundColor={theme.colors.secondary.background[100]}>
<Hero.Heading
page={Pages.WhyTwenty}
segments={HERO_DATA.heading}
size="xl"
<ScrollReveal>
<Hero.Root backgroundColor={theme.colors.secondary.background[100]}>
<Hero.Heading
page={Pages.WhyTwenty}
segments={HERO_DATA.heading}
size="xl"
/>
<Hero.Body page={Pages.WhyTwenty} body={HERO_DATA.body} />
<Hero.WhyTwentyVisual
image={HERO_DATA.image}
illustration={HERO_DATA.illustration}
/>
</Hero.Root>
</ScrollReveal>
<ScrollReveal>
<Editorial.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
mutedColor={theme.colors.secondary.text[60]}
>
<Editorial.Intro>
<Editorial.Eyebrow
colorScheme="secondary"
eyebrow={EDITORIAL_ONE.eyebrow!}
/>
<Editorial.Heading segments={EDITORIAL_ONE.heading!} />
</Editorial.Intro>
<Editorial.Body body={EDITORIAL_ONE.body} layout="two-column" />
</Editorial.Root>
</ScrollReveal>
<ScrollReveal>
<Quote.Root backgroundColor={theme.colors.secondary.background[100]}>
<Quote.Visual illustration={QUOTE_DATA.illustration} />
<Quote.Heading segments={QUOTE_DATA.heading} />
</Quote.Root>
</ScrollReveal>
<ScrollReveal>
<Editorial.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
mutedColor={theme.colors.secondary.text[60]}
>
<Editorial.Body body={EDITORIAL_TWO.body} layout="centered" />
</Editorial.Root>
</ScrollReveal>
<ScrollReveal>
<Marquee.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
heading={MARQUEE_DATA.heading}
/>
<Hero.Body page={Pages.WhyTwenty} body={HERO_DATA.body} />
<Hero.WhyTwentyVisual
image={HERO_DATA.image}
illustration={HERO_DATA.illustration}
/>
</Hero.Root>
</ScrollReveal>
<Editorial.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
mutedColor={theme.colors.secondary.text[60]}
>
<Editorial.Intro>
<Editorial.Eyebrow
colorScheme="secondary"
eyebrow={EDITORIAL_ONE.eyebrow!}
/>
<Editorial.Heading segments={EDITORIAL_ONE.heading!} />
</Editorial.Intro>
<Editorial.Body body={EDITORIAL_ONE.body} layout="two-column" />
</Editorial.Root>
<ScrollReveal>
<Editorial.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
mutedColor={theme.colors.secondary.text[60]}
>
<Editorial.Intro>
<Editorial.Eyebrow
colorScheme="secondary"
eyebrow={EDITORIAL_THREE.eyebrow!}
/>
<Editorial.Heading segments={EDITORIAL_THREE.heading!} />
</Editorial.Intro>
<Editorial.Body body={EDITORIAL_THREE.body} layout="indented" />
</Editorial.Root>
</ScrollReveal>
<Quote.Root backgroundColor={theme.colors.secondary.background[100]}>
<Quote.Visual illustration={QUOTE_DATA.illustration} />
<Quote.Heading segments={QUOTE_DATA.heading} />
</Quote.Root>
<ScrollReveal>
<Statement.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
>
<Statement.Heading segments={STATEMENT_ONE.heading} />
</Statement.Root>
</ScrollReveal>
<Editorial.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
mutedColor={theme.colors.secondary.text[60]}
>
<Editorial.Body body={EDITORIAL_TWO.body} layout="centered" />
</Editorial.Root>
<ScrollReveal>
<Statement.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.primary.text[100]}
>
<Statement.Heading segments={STATEMENT_TWO.heading} />
</Statement.Root>
</ScrollReveal>
<Marquee.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
heading={MARQUEE_DATA.heading}
/>
<Editorial.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
mutedColor={theme.colors.secondary.text[60]}
>
<Editorial.Intro>
<Editorial.Eyebrow
colorScheme="secondary"
eyebrow={EDITORIAL_THREE.eyebrow!}
/>
<Editorial.Heading segments={EDITORIAL_THREE.heading!} />
</Editorial.Intro>
<Editorial.Body body={EDITORIAL_THREE.body} layout="indented" />
</Editorial.Root>
<Statement.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
>
<Statement.Heading segments={STATEMENT_ONE.heading} />
</Statement.Root>
<Statement.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.primary.text[100]}
>
<Statement.Heading segments={STATEMENT_TWO.heading} />
</Statement.Root>
<Editorial.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.primary.text[100]}
mutedColor={theme.colors.primary.text[60]}
>
<Editorial.Intro>
<Editorial.Eyebrow
colorScheme="primary"
eyebrow={EDITORIAL_FOUR.eyebrow!}
/>
<Editorial.Heading segments={EDITORIAL_FOUR.heading!} />
</Editorial.Intro>
<Editorial.Body body={EDITORIAL_FOUR.body} layout="two-column" />
</Editorial.Root>
<ScrollReveal>
<Editorial.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.primary.text[100]}
mutedColor={theme.colors.primary.text[60]}
>
<Editorial.Intro>
<Editorial.Eyebrow
colorScheme="primary"
eyebrow={EDITORIAL_FOUR.eyebrow!}
/>
<Editorial.Heading segments={EDITORIAL_FOUR.heading!} />
</Editorial.Intro>
<Editorial.Body body={EDITORIAL_FOUR.body} layout="two-column" />
</Editorial.Root>
</ScrollReveal>
<WhyTwentyStepper.Flow
body={STEPPER_DATA.body}
@@ -134,24 +167,26 @@ export default function WhyTwentyPage() {
illustration={STEPPER_DATA.illustration}
/>
<Signoff.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.secondary.text[100]}
variant="shaped"
shapeFillColor={theme.colors.secondary.background[100]}
>
<Signoff.Heading segments={SIGNOFF_DATA.heading} />
<Signoff.Body body={SIGNOFF_DATA.body} />
<Signoff.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
</Signoff.Cta>
</Signoff.Root>
<ScrollReveal>
<Signoff.Root
backgroundColor={theme.colors.primary.text[10]}
color={theme.colors.secondary.text[100]}
variant="shaped"
shapeFillColor={theme.colors.secondary.background[100]}
>
<Signoff.Heading segments={SIGNOFF_DATA.heading} />
<Signoff.Body body={SIGNOFF_DATA.body} />
<Signoff.Cta>
<LinkButton
color="primary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
</Signoff.Cta>
</Signoff.Root>
</ScrollReveal>
</>
);
}
@@ -0,0 +1,19 @@
'use client';
import { getAllWebsiteGlbUrls } from '@/constants/website-glb-urls';
import { useEffect } from 'react';
// Warms the browser cache for every marketing GLB right after hydration so LazyEmbed + useGLTF hit disk cache.
export const GlbWarmCache = () => {
useEffect(() => {
const urls = getAllWebsiteGlbUrls();
for (const url of urls) {
void fetch(url, {
priority: 'low',
} as RequestInit & { priority?: RequestPriority });
}
}, []);
return null;
};
@@ -0,0 +1,57 @@
import { HELPED_DATA } from '@/app/(home)/_constants/helped';
import { TESTIMONIALS_DATA as HOME_TESTIMONIALS_DATA } from '@/app/(home)/_constants/testimonials';
import { THREE_CARDS_ILLUSTRATION_DATA as HOME_THREE_CARDS_DATA } from '@/app/(home)/_constants/three-cards-illustration';
import { FAQ_DATA } from '@/app/_constants/faq';
import { FOOTER_DATA } from '@/app/_constants/footer';
import { TESTIMONIALS_DATA as PARTNER_TESTIMONIALS_DATA } from '@/app/partner/_constants/testimonials';
import { THREE_CARDS_ILLUSTRATION_DATA as PARTNER_THREE_CARDS_DATA } from '@/app/partner/_constants/three-cards-illustration';
import { PLANS_DATA } from '@/app/pricing/_constants/plans';
import { HERO_DATA as PRODUCT_HERO_DATA } from '@/app/product/_constants/hero';
import { THREE_CARDS_ILLUSTRATION_DATA as PRODUCT_THREE_CARDS_DATA } from '@/app/product/_constants/three-cards';
import { HERO_DATA as WHY_TWENTY_HERO_DATA } from '@/app/why-twenty/_constants/hero';
import { QUOTE_DATA } from '@/app/why-twenty/_constants/quote';
import { STEPPER_DATA } from '@/app/why-twenty/_constants/stepper';
const addIfGlb = (urls: Set<string>, src: string) => {
if (src.toLowerCase().endsWith('.glb')) {
urls.add(src);
}
};
// Single list of on-disk GLBs used across marketing routes; keeps HTTP cache warm after first paint.
export const getAllWebsiteGlbUrls = (): string[] => {
const urls = new Set<string>();
addIfGlb(urls, FOOTER_DATA.illustration.src);
addIfGlb(urls, FAQ_DATA.illustration.src);
addIfGlb(urls, HOME_TESTIMONIALS_DATA.illustration.src);
for (const card of HOME_THREE_CARDS_DATA.illustrationCards) {
addIfGlb(urls, card.illustration.src);
}
for (const card of HELPED_DATA.cards) {
addIfGlb(urls, card.illustration.src);
}
addIfGlb(urls, PLANS_DATA.pro.illustration.src);
addIfGlb(urls, PLANS_DATA.organization.illustration.src);
addIfGlb(urls, PRODUCT_HERO_DATA.illustration.src);
for (const card of PRODUCT_THREE_CARDS_DATA.illustrationCards) {
addIfGlb(urls, card.illustration.src);
}
for (const card of PARTNER_THREE_CARDS_DATA.illustrationCards) {
addIfGlb(urls, card.illustration.src);
}
addIfGlb(urls, PARTNER_TESTIMONIALS_DATA.illustration.src);
addIfGlb(urls, WHY_TWENTY_HERO_DATA.illustration.src);
addIfGlb(urls, STEPPER_DATA.illustration.src);
addIfGlb(urls, QUOTE_DATA.illustration.src);
return [...urls];
};
@@ -4,6 +4,7 @@ import { BodyType } from './types/Body';
const bodyClassName = css`
margin: 0;
letter-spacing: -0.01em;
&[data-family='sans'] {
font-family: ${theme.font.family.sans};
@@ -15,6 +16,7 @@ const bodyClassName = css`
&[data-family='mono'] {
font-family: ${theme.font.family.mono};
letter-spacing: -0.02em;
}
&[data-weight='light'] {
@@ -24,6 +24,16 @@ export const buttonBaseStyles = `
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
&:hover {
transform: scale(1.04);
}
&:active {
transform: scale(0.96);
}
`;
const Label = styled.span`
@@ -74,17 +74,17 @@ const headingRootClassName = css`
const StyledSpan = styled.span`
&[data-family='sans'] {
font-family: ${theme.font.family.sans};
letter-spacing: -0.02em;
letter-spacing: -0.04em;
}
&[data-family='serif'] {
font-family: ${theme.font.family.serif};
letter-spacing: 0;
letter-spacing: -0.02em;
}
&[data-family='mono'] {
font-family: ${theme.font.family.mono};
letter-spacing: -0.02em;
letter-spacing: -0.04em;
}
`;
@@ -35,6 +35,16 @@ const StyledButton = styled.button<{ size: number; borderColor: string }>`
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
&:hover {
transform: scale(1.08); /* Apple-like slight growth */
}
&:active {
transform: scale(0.96); /* Apple-like click shrink */
}
`;
export function IconButton({
@@ -1,5 +1,6 @@
'use client';
import dynamic from 'next/dynamic';
import {
type ComponentPropsWithoutRef,
useEffect,
@@ -12,6 +13,14 @@ const DEFAULT_LOAD_ROOT_MARGIN = '1200px 0px';
const DEFAULT_UNLOAD_ROOT_MARGIN = '1800px 0px';
const DEFAULT_UNLOAD_DELAY_MS = 1500;
const LazyGlbIllustration = dynamic(
() =>
import('./LazyGlbIllustration').then((mod) => ({
default: mod.LazyGlbIllustration,
})),
{ loading: () => null, ssr: false },
);
type LazyEmbedProps = Omit<ComponentPropsWithoutRef<'iframe'>, 'loading' | 'src'> & {
eager?: boolean;
rootMargin?: string;
@@ -21,6 +30,10 @@ type LazyEmbedProps = Omit<ComponentPropsWithoutRef<'iframe'>, 'loading' | 'src'
unloadRootMargin?: string;
};
function isGlbSrc(src: string) {
return src.toLowerCase().endsWith('.glb');
}
export function LazyEmbed({
eager = false,
rootMargin = DEFAULT_LOAD_ROOT_MARGIN,
@@ -28,6 +41,7 @@ export function LazyEmbed({
unloadWhenHidden = true,
unloadRootMargin = DEFAULT_UNLOAD_ROOT_MARGIN,
src,
title,
className,
style,
role,
@@ -38,6 +52,7 @@ export function LazyEmbed({
const [hostNode, setHostNode] = useState<HTMLDivElement | null>(null);
const [shouldRender, setShouldRender] = useState(eager);
const unloadTimeoutRef = useRef<number | null>(null);
const useGlbRenderer = isGlbSrc(src);
const wrapperProps = useMemo(
() => ({
@@ -138,19 +153,24 @@ export function LazyEmbed({
ref={setHostNode}
>
{shouldRender ? (
<iframe
{...iframeProps}
aria-hidden={ariaHidden}
loading={eager ? 'eager' : 'lazy'}
src={src}
style={{
border: 'none',
display: 'block',
height: '100%',
width: '100%',
}}
tabIndex={tabIndex}
/>
useGlbRenderer ? (
<LazyGlbIllustration src={src} title={title} />
) : (
<iframe
{...iframeProps}
aria-hidden={ariaHidden}
loading={eager ? 'eager' : 'lazy'}
src={src}
style={{
border: 'none',
display: 'block',
height: '100%',
width: '100%',
}}
tabIndex={tabIndex}
title={title}
/>
)
) : null}
</div>
);
@@ -0,0 +1,13 @@
import { styled } from '@linaria/react';
export const LazyGlbCanvasRoot = styled.div`
display: block;
height: 100%;
width: 100%;
& canvas {
display: block;
height: 100%;
width: 100%;
}
`;
@@ -0,0 +1,142 @@
'use client';
import { Bounds, useGLTF, Float, PresentationControls } from '@react-three/drei';
import { Canvas, useFrame } from '@react-three/fiber';
import { Suspense, useMemo, useRef, useState, useCallback, useEffect } from 'react';
import * as THREE from 'three';
import { clone } from 'three/examples/jsm/utils/SkeletonUtils.js';
import { LazyGlbCanvasRoot } from './LazyGlbCanvasRoot';
type LazyGlbIllustrationProps = {
src: string;
title?: string;
};
function InteractiveGlbModel({ src }: { src: string }) {
const gltf = useGLTF(src);
const groupRef = useRef<THREE.Group>(null);
const [hovered, setHovered] = useState(false);
const model = useMemo(() => {
const sceneClone = clone(gltf.scene) as THREE.Group;
const bounds = new THREE.Box3().setFromObject(sceneClone);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 1);
const scale = 2.4 / maxAxis;
sceneClone.position.sub(center);
sceneClone.scale.setScalar(scale);
return sceneClone;
}, [gltf.scene]);
useEffect(() => {
return () => {
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry?.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((mat) => mat.dispose());
} else {
child.material?.dispose();
}
}
});
};
}, [model]);
const handlePointerOver = useCallback(() => {
setHovered(true);
}, []);
const handlePointerOut = useCallback(() => {
setHovered(false);
}, []);
useFrame((state, delta) => {
if (!groupRef.current) return;
const amp = hovered ? 1.5 : 0.8;
const targetRotationX = (-state.pointer.y * Math.PI * amp) / 10;
const targetRotationY = (state.pointer.x * Math.PI * amp) / 10;
groupRef.current.rotation.x = THREE.MathUtils.damp(
groupRef.current.rotation.x,
targetRotationX,
4,
delta,
);
groupRef.current.rotation.y = THREE.MathUtils.damp(
groupRef.current.rotation.y,
targetRotationY,
4,
delta,
);
groupRef.current.rotation.z = THREE.MathUtils.damp(
groupRef.current.rotation.z,
Math.sin(state.clock.elapsedTime * 0.5) * 0.05,
2,
delta,
);
const targetScale = hovered ? 1.15 : 1;
groupRef.current.scale.setScalar(
THREE.MathUtils.damp(groupRef.current.scale.x, targetScale, 6, delta),
);
});
return (
<group
ref={groupRef}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
>
<primitive object={model} dispose={null} />
</group>
);
}
export function LazyGlbIllustration({ src, title }: LazyGlbIllustrationProps) {
return (
<LazyGlbCanvasRoot aria-label={title} role={title ? 'img' : undefined}>
<Canvas
dpr={[1, 1.5]}
frameloop="demand"
gl={{
alpha: true,
antialias: true,
powerPreference: 'low-power',
}}
camera={{ fov: 30, far: 100, near: 0.1, position: [0, 0, 7] }}
onCreated={({ gl, scene }) => {
scene.background = null;
gl.setClearColor(0x000000, 0);
}}
style={{ height: '100%', width: '100%', cursor: 'pointer' }}
>
<ambientLight intensity={0.8} />
<directionalLight intensity={2.5} position={[5, 8, 10]} />
<directionalLight intensity={1.0} position={[-5, -4, 5]} color="#f0f0ff" />
<Suspense fallback={null}>
<Float speed={2.5} rotationIntensity={0.25} floatIntensity={0.7}>
<PresentationControls
global
rotation={[0.1, 0.1, 0]}
polar={[-0.2, 0.2]}
azimuth={[-0.5, 0.5]}
snap={true}
>
<Bounds clip fit margin={1.2} observe>
<InteractiveGlbModel src={src} />
</Bounds>
</PresentationControls>
</Float>
</Suspense>
</Canvas>
</LazyGlbCanvasRoot>
);
}
@@ -0,0 +1,132 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const RailContainer = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: flex-start;
align-self: stretch;
display: flex;
flex-direction: column;
min-height: 0;
}
`;
const StickyViewportCenter = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
height: 100vh;
justify-content: flex-start;
max-height: 100vh;
position: sticky;
top: 0;
`;
const Rail = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
`;
const StepRow = styled.div`
align-items: center;
display: flex;
height: 16px;
justify-content: center;
position: relative;
transition: height 0.4s ease;
width: ${theme.spacing(1)};
&[data-active='true'] {
height: 80px;
}
`;
const PillTrack = styled.div`
border-radius: 50%;
display: flex;
height: ${theme.spacing(1)};
overflow: hidden;
transition:
height 0.4s ease,
border-radius 0.4s ease;
width: ${theme.spacing(1)};
&[data-active='true'] {
border-radius: ${theme.radius(8)};
height: 100%;
}
`;
const PillFill = styled.div`
background-color: ${theme.colors.highlight[100]};
border-radius: ${theme.radius(8)};
transition: height 0.15s linear;
width: 100%;
`;
const StepLabel = styled.div`
color: ${theme.colors.highlight[100]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: 700;
left: calc(100% + ${theme.spacing(4)});
line-height: 16px;
opacity: 0;
position: absolute;
text-transform: uppercase;
top: 0;
transition: opacity 0.3s ease;
white-space: nowrap;
&[data-visible='true'] {
opacity: 1;
}
`;
type StepperProgressRailProps = {
activeStepIndex: number;
inactiveColor?: string;
localProgress: number;
stepCount: number;
};
export function StepperProgressRail({
activeStepIndex,
inactiveColor = theme.colors.primary.border[20],
localProgress,
stepCount,
}: StepperProgressRailProps) {
return (
<RailContainer>
<StickyViewportCenter>
<Rail>
{Array.from({ length: stepCount }, (_, index) => {
const isActive = index === activeStepIndex;
const fillPercentage = isActive
? Math.min(100, Math.max(0, localProgress * 100))
: 0;
return (
<StepRow data-active={String(isActive)} key={`step-${index}`}>
<PillTrack
data-active={String(isActive)}
style={{ backgroundColor: inactiveColor }}
>
<PillFill style={{ height: `${fillPercentage}%` }} />
</PillTrack>
<StepLabel data-visible={String(isActive)}>
{String(index + 1).padStart(2, '0')}
</StepLabel>
</StepRow>
);
})}
</Rail>
</StickyViewportCenter>
</RailContainer>
);
}
@@ -7,3 +7,4 @@ export { Heading } from './Heading/Heading';
export { IconButton } from './IconButton/IconButton';
export { Image } from './Image/Image';
export { LazyEmbed } from './LazyEmbed/LazyEmbed';
export { StepperProgressRail } from './StepperProgressRail/StepperProgressRail';
@@ -1,7 +1,8 @@
export enum Pages {
Home = "home",
Partner = "partner",
Pricing = "pricing",
Product = "product",
WhyTwenty = "whyTwenty",
ReleaseNotes = 'releaseNotes',
Home = 'home',
Partner = 'partner',
Pricing = 'pricing',
Product = 'product',
WhyTwenty = 'whyTwenty',
}
@@ -0,0 +1,30 @@
import { cache } from 'react';
import { fetchDiscordMemberCount } from '@/lib/community/fetch-discord-member-count';
import { fetchGithubStarCount } from '@/lib/community/fetch-github-star-count';
import { formatCompactCount } from '@/lib/community/format-compact-count';
export type CommunityStats = {
discord: string;
github: string;
};
export const fetchCommunityStats = cache(async (): Promise<CommunityStats> => {
const [githubStars, discordMembers] = await Promise.all([
fetchGithubStarCount(),
fetchDiscordMemberCount(),
]);
let discord = '';
let github = '';
if (githubStars != null) {
github = formatCompactCount(githubStars);
}
if (discordMembers != null) {
discord = formatCompactCount(discordMembers);
}
return { discord, github };
});
@@ -0,0 +1,16 @@
import axios from 'axios';
export async function fetchDiscordMemberCount(): Promise<number | null> {
try {
const response = await axios.get(
'https://discord.com/api/v10/invites/cx5n4Jzs57',
{
params: { with_counts: true },
},
);
return response.data.profile.member_count;
} catch {
return null;
}
}
@@ -0,0 +1,23 @@
import axios from 'axios';
export async function fetchGithubStarCount(): Promise<number | null> {
try {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const response = await axios.get(
'https://api.github.com/repos/twentyhq/twenty',
{ headers },
);
return response.data.stargazers_count;
} catch {
return null;
}
}
@@ -0,0 +1,10 @@
const compactFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
minimumFractionDigits: 1,
});
export function formatCompactCount(value: number): string {
if (!Number.isFinite(value) || value < 0) return '';
return compactFormatter.format(value);
}
@@ -0,0 +1,19 @@
type SocialLinkWithIcon = {
icon: string;
label?: string;
};
export function mergeSocialLinkLabels<T extends SocialLinkWithIcon>(
links: readonly T[],
labels: { discord: string; github: string },
): T[] {
return links.map((link) => {
if (link.icon === 'github') {
return { ...link, label: labels.github };
}
if (link.icon === 'discord') {
return { ...link, label: labels.discord };
}
return link;
});
}
@@ -0,0 +1,23 @@
import axios from 'axios';
export async function fetchLatestGithubReleaseTag(): Promise<string | null> {
try {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const response = await axios.get(
'https://api.github.com/repos/twentyhq/twenty/releases/latest',
{ headers },
);
return response.data.tag_name;
} catch {
return null;
}
}
@@ -0,0 +1,21 @@
export function formatReleaseDisplayDate(dateString: string): string {
if (!dateString.trim()) {
return '';
}
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) {
return '';
}
const now = new Date();
const isCurrentYear = date.getFullYear() === now.getFullYear();
const formatter = new Intl.DateTimeFormat('en-US', {
month: isCurrentYear ? 'long' : 'short',
day: 'numeric',
...(isCurrentYear ? {} : { year: 'numeric' }),
});
return formatter.format(date);
}
@@ -0,0 +1,32 @@
import type { LocalReleaseNote } from '@/lib/releases/types';
import { getFormattedReleaseNumber } from '@/lib/semver/get-formatted-release-number';
function safeReleaseNumber(version: string): number | null {
try {
return getFormattedReleaseNumber(version);
} catch {
return null;
}
}
export function getVisibleReleaseNotes(
notes: LocalReleaseNote[],
latestPublishedTag: string | null,
): LocalReleaseNote[] {
if (!latestPublishedTag) {
return notes;
}
const publishedNumber = safeReleaseNumber(latestPublishedTag);
if (publishedNumber === null) {
return notes;
}
return notes.filter((note) => {
const noteNumber = safeReleaseNumber(note.release);
if (noteNumber === null) {
return true;
}
return noteNumber <= publishedNumber;
});
}
@@ -0,0 +1,69 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import type { LocalReleaseNote } from '@/lib/releases/types';
import { compareSemanticVersions } from '@/lib/semver/compare-semantic-versions';
function resolveReleasesDirectory(): string | null {
const candidates = [
path.join(process.cwd(), '..', 'twenty-website', 'src', 'content', 'releases'),
path.join(
process.cwd(),
'..',
'..',
'packages',
'twenty-website',
'src',
'content',
'releases',
),
];
for (const directoryPath of candidates) {
if (fs.existsSync(directoryPath)) {
return directoryPath;
}
}
return null;
}
export function loadLocalReleaseNotes(): LocalReleaseNote[] {
const directoryPath = resolveReleasesDirectory();
if (!directoryPath) {
return [];
}
const fileNames = fs.readdirSync(directoryPath);
const notes: LocalReleaseNote[] = [];
for (const fileName of fileNames) {
if (!fileName.endsWith('.md') && !fileName.endsWith('.mdx')) {
continue;
}
const fullPath = path.join(directoryPath, fileName);
const raw = fs.readFileSync(fullPath, 'utf-8');
const { data, content } = matter(raw);
const dateValue = data.Date;
const releaseValue = data.release;
const date = typeof dateValue === 'string' ? dateValue : '';
const release = typeof releaseValue === 'string' ? releaseValue : '';
if (!release) {
continue;
}
notes.push({
slug: fileName.replace(/\.mdx?$/i, ''),
date,
release,
content,
});
}
notes.sort((a, b) => compareSemanticVersions(b.release, a.release));
return notes;
}
@@ -0,0 +1,6 @@
export type LocalReleaseNote = {
content: string;
date: string;
release: string;
slug: string;
};
@@ -0,0 +1,16 @@
export function compareSemanticVersions(a: string, b: string) {
const a1 = a.split('.');
const b1 = b.split('.');
const len = Math.min(a1.length, b1.length);
for (let i = 0; i < len; i++) {
const a2 = Number(a1[i]) || 0;
const b2 = Number(b1[i]) || 0;
if (a2 !== b2) {
return a2 > b2 ? 1 : -1;
}
}
return b1.length - a1.length;
}
@@ -0,0 +1,18 @@
export const getFormattedReleaseNumber = (versionNumber: string) => {
const formattedVersion = versionNumber.replace(/^v/i, '');
const parts = formattedVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
throw new Error('Version must be in the format major.minor.patch');
}
const majorWeight = 10000;
const minorWeight = 100;
const patchWeight = 1;
const numericVersion =
parts[0] * majorWeight + parts[1] * minorWeight + parts[2] * patchWeight;
return numericVersion;
};
@@ -0,0 +1,50 @@
'use client';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/dist/ScrollTrigger';
import { ReactNode, useRef } from 'react';
gsap.registerPlugin(ScrollTrigger);
gsap.registerPlugin(useGSAP);
export function ScrollReveal({
children,
stagger = false,
}: {
children: ReactNode;
stagger?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
if (!containerRef.current) return;
// Select the direct children or just use the container if stagger isn't needed.
const targets = stagger ? containerRef.current.children : containerRef.current;
gsap.fromTo(
targets,
{
opacity: 0,
y: 40,
scale: 0.98,
},
{
opacity: 1,
y: 0,
scale: 1,
duration: 1,
ease: 'power3.out',
stagger: stagger ? 0.15 : 0,
scrollTrigger: {
trigger: containerRef.current,
start: 'top 85%',
toggleActions: 'play none none reverse',
},
}
);
}, { scope: containerRef });
return <div ref={containerRef}>{children}</div>;
}
@@ -5,6 +5,11 @@ import { styled } from '@linaria/react';
const StyledHeading = styled(BaseHeading)`
max-width: 360px;
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
&:hover {
transform: translateY(-4px);
}
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
@@ -6,6 +6,7 @@ import { styled } from '@linaria/react';
const BodyParagraph = styled.div`
color: var(--editorial-body-color);
min-width: 0;
transition: color 0.4s ease, opacity 0.4s ease;
`;
const TwoColumnGrid = styled.div`
@@ -18,6 +19,11 @@ const TwoColumnGrid = styled.div`
row-gap: ${theme.spacing(6)};
width: 100%;
/* When hovering a paragraph, dim the others */
&:has(${BodyParagraph}:hover) ${BodyParagraph}:not(:hover) {
opacity: 0.4;
}
@media (min-width: ${theme.breakpoints.md}px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -29,6 +35,11 @@ const SingleColumnBody = styled.div`
min-width: 0;
width: 100%;
/* When hovering a paragraph, dim the others */
&:has(${BodyParagraph}:hover) ${BodyParagraph}:not(:hover) {
opacity: 0.4;
}
@media (min-width: ${theme.breakpoints.md}px) {
margin-left: 384px;
margin-right: 500px;
@@ -6,6 +6,11 @@ const HeadingWrap = styled.div`
max-width: 617px;
min-width: 0;
width: 100%;
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
&:hover {
transform: translateX(8px);
}
`;
type EditorialHeadingProps = {
@@ -14,6 +14,23 @@ const Inner = styled.div`
flex-direction: column;
gap: ${theme.spacing(10)};
width: 100%;
/* Smooth transition for the children elements */
& > * {
transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
/* When hovering over any direct child, aggressively shrink and dim the other */
&:has(> div:hover) > div:not(:hover) {
opacity: 0.1;
transform: scale(0.95) translateY(12px);
}
/* Expand the hovered element */
&:has(> div:hover) > div:hover {
transform: scale(1.02);
z-index: 2;
}
`;
const StyledContainer = styled(Container)`
@@ -5,6 +5,12 @@ import { styled } from '@linaria/react';
const StyledHeading = styled(BaseHeading)`
white-space: pre-line;
transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
&:hover {
opacity: 0.8;
transform: translateY(-4px);
}
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 668px;
@@ -2,6 +2,7 @@ import { Heading as BaseHeading } from '@/design-system/components';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
const faqHeadingClassName = css`
white-space: pre-line;
@@ -11,18 +12,31 @@ const faqHeadingClassName = css`
}
`;
const HeadingWrapper = styled.div`
transition:
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.4s ease;
&:hover {
transform: translateX(4px);
opacity: 0.8;
}
`;
type HeadingProps = {
segments: HeadingType[];
};
export function Heading({ segments }: HeadingProps) {
return (
<BaseHeading
as="h2"
className={faqHeadingClassName}
segments={segments}
size="lg"
weight="light"
/>
<HeadingWrapper>
<BaseHeading
as="h2"
className={faqHeadingClassName}
segments={segments}
size="lg"
weight="light"
/>
</HeadingWrapper>
);
}
@@ -1,19 +1,19 @@
'use client';
import {
Accordion as BaseAccordion,
type AccordionTriggerState,
} from '@base-ui/react/accordion';
import { IconButton } from '@/design-system/components';
import {
MinusIcon,
PlusIcon,
RectangleFillIcon,
RectangleOutlineIcon,
} from '@/icons';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { IconButton } from '@/design-system/components';
import type { FaqQuestionType } from '@/sections/Faq/types/FaqQuestion';
import { theme } from '@/theme';
import {
Accordion as BaseAccordion,
type AccordionTriggerState,
} from '@base-ui/react/accordion';
import { styled } from '@linaria/react';
const QuestionText = styled.span`
color: ${theme.colors.secondary.text[40]};
@@ -1,4 +1,4 @@
import { Container } from '@/design-system/components';
import { Container, LazyEmbed } from '@/design-system/components';
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -7,11 +7,40 @@ import type { ReactNode } from 'react';
const StyledSection = styled.section`
background-color: ${theme.colors.secondary.background[100]};
color: ${theme.colors.secondary.text[100]};
isolation: isolate;
overflow: hidden;
position: relative;
width: 100%;
`;
const IllustrationLayer = styled.div`
inset: 0;
opacity: 0.45;
overflow: hidden;
pointer-events: none;
position: absolute;
z-index: 0;
`;
const FaqIllustrationEmbed = styled(LazyEmbed)`
border: none;
top: -15%;
right: -5%;
left: auto;
transform: none;
display: block;
height: min(60vh, 550px);
position: absolute;
width: min(70vw, 750px);
@media (min-width: ${theme.breakpoints.md}px) {
top: -20%;
right: -10%;
height: min(70vh, 700px);
width: min(60vw, 900px);
}
`;
const StyledContainer = styled(Container)`
display: grid;
grid-template-columns: 1fr;
@@ -37,6 +66,17 @@ type RootProps = {
export function Root({ children, illustration }: RootProps) {
return (
<StyledSection>
<IllustrationLayer aria-hidden>
<FaqIllustrationEmbed
allow="clipboard-write; encrypted-media; gyroscope; web-share"
allowFullScreen
eager
referrerPolicy="strict-origin-when-cross-origin"
rootMargin="400px 0px"
src={illustration.src}
title={illustration.title}
/>
</IllustrationLayer>
<StyledContainer>{children}</StyledContainer>
</StyledSection>
);
@@ -1,6 +1,9 @@
import { ArrowRightUpIcon, SOCIAL_ICONS } from '@/icons';
import type { FooterSocialLinkType } from '@/sections/Footer/types';
import { theme } from '@/theme';
import { Separator } from '@base-ui/react/separator';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import React from 'react';
const BottomGrid = styled.div`
display: grid;
@@ -15,41 +18,101 @@ const BottomGrid = styled.div`
const Copyright = styled.div`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.mono};
grid-column: 1;
grid-column: 1 / -1;
grid-row: 2;
justify-self: start;
text-transform: uppercase;
@media (min-width: ${theme.breakpoints.md}px) {
grid-column: 1;
grid-row: 1;
}
`;
const Credit = styled.div`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.mono};
grid-column: 2;
grid-row: 2;
justify-self: end;
text-transform: uppercase;
const SocialNav = styled.nav`
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(6)};
grid-column: 1 / -1;
grid-row: 1;
justify-content: start;
max-width: 100%;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
grid-column: 1 / -1;
flex-wrap: nowrap;
grid-column: 2;
justify-content: flex-end;
justify-self: end;
}
`;
const SocialDivider = styled(Separator)`
background-color: ${theme.colors.primary.border[40]};
border: none;
height: 10px;
width: 1px;
`;
const SocialLink = styled.a`
align-items: center;
color: ${theme.colors.primary.text[100]};
display: flex;
flex-shrink: 0;
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
gap: ${theme.spacing(3)};
line-height: 14px;
text-decoration: none;
white-space: nowrap;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
type BottomProps = {
children?: ReactNode;
copyright: string;
credit: string;
links: FooterSocialLinkType[];
};
export function Bottom({ children, copyright, credit }: BottomProps) {
export function Bottom({ copyright, links }: BottomProps) {
return (
<BottomGrid>
<Copyright>{copyright}</Copyright>
{children}
<Credit>{credit}</Credit>
<SocialNav aria-label="Social media">
{links.map((link, index) => {
const IconComponent = SOCIAL_ICONS[link.icon];
return (
<React.Fragment key={link.href}>
{index > 0 && <SocialDivider orientation="vertical" />}
<SocialLink
href={link.href}
target="_blank"
rel="noopener noreferrer"
aria-label={link.ariaLabel}
>
<IconComponent
size={14}
fillColor={theme.colors.secondary.background[100]}
aria-hidden
/>
{link.label}
{link.label && (
<ArrowRightUpIcon
size={8}
strokeColor={theme.colors.highlight[100]}
aria-hidden
/>
)}
</SocialLink>
</React.Fragment>
);
})}
</SocialNav>
</BottomGrid>
);
}
@@ -7,6 +7,7 @@ export const FooterBackgroundCanvasRoot = styled.div`
pointer-events: none;
overflow: hidden;
& canvas,
& iframe {
display: block;
height: 100%;
@@ -1,94 +0,0 @@
import { ArrowRightUpIcon, SOCIAL_ICONS } from '@/icons';
import type { FooterSocialLinkType } from '@/sections/Footer/types';
import { theme } from '@/theme';
import { Separator } from '@base-ui/react/separator';
import { styled } from '@linaria/react';
import React from 'react';
const SocialContainer = styled.nav`
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(6)};
grid-column: 1 / -1;
grid-row: 1;
justify-content: center;
max-width: 100%;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
flex-wrap: nowrap;
grid-column: 2;
justify-content: flex-end;
justify-self: end;
}
`;
const SocialDivider = styled(Separator)`
background-color: ${theme.colors.primary.border[40]};
border: none;
height: 10px;
width: 1px;
`;
const SocialItem = styled.a`
align-items: center;
color: ${theme.colors.primary.text[100]};
display: flex;
flex-shrink: 0;
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
gap: ${theme.spacing(3)};
line-height: 14px;
text-decoration: none;
white-space: nowrap;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
type SocialProps = {
links: FooterSocialLinkType[];
};
export function Social({ links }: SocialProps) {
const drawerLinks = links.filter(
(item) => item.showInDrawer && SOCIAL_ICONS[item.icon],
);
return (
<SocialContainer aria-label="Social media">
{drawerLinks.map((item, index) => {
const IconComponent = SOCIAL_ICONS[item.icon];
return (
<React.Fragment key={item.href}>
{index > 0 && <SocialDivider orientation="vertical" />}
<SocialItem
href={item.href}
target="_blank"
rel="noopener noreferrer"
aria-label={item.ariaLabel}
>
<IconComponent
size={14}
fillColor={theme.colors.secondary.background[100]}
aria-hidden
/>
{item.label}
{item.label != null && (
<ArrowRightUpIcon
size={8}
strokeColor={theme.colors.highlight[100]}
aria-hidden
/>
)}
</SocialItem>
</React.Fragment>
);
})}
</SocialContainer>
);
}

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