First PR to bring in the new twenty website. (#19035)

This PR contains Menu, Hero, TrustedBy, Problem, ThreeCards and Footer
sections of the new website.

Most components in there match the Figma designs, except for two things.
- Zoom levels on 3D illustrations from Endless Tools.
- Menu needs to have the same color as Hero - it's not happening at the
moment since Menu is in the layout, not nested inside pages or Hero.

Images are placeholders (same as Figma).
This commit is contained in:
Abdullah.
2026-03-27 17:48:03 +05:00
committed by GitHub
parent 50ea560e57
commit 22c9693ce5
146 changed files with 4827 additions and 5 deletions
+1
View File
@@ -203,6 +203,7 @@
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-website-new",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
+39
View File
@@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# DB
*.sqlite
@@ -0,0 +1,48 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
+4
View File
@@ -0,0 +1,4 @@
# Twenty-Website
This is used for the marketing website (twenty.com).
This is not related in any way to the main app, which you can find in twenty-front and twenty-server.
@@ -0,0 +1,5 @@
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
const nextConfig: LinariaConfig = { reactCompiler: true };
module.exports = withLinaria(nextConfig);
+27
View File
@@ -0,0 +1,27 @@
{
"name": "twenty-website-new",
"private": true,
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-website-new node ../../node_modules/nx/bin/nx.js",
"dev": "npx next dev",
"build": "npx next build",
"start": "npx next start"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@base-ui/utils": "^0.2.6",
"@linaria/core": "^7.0.0",
"@linaria/react": "^7.0.1",
"@wyw-in-js/babel-preset": "^0.8.1",
"next": "16.1.7",
"next-with-linaria": "^1.3.0",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0"
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "twenty-website-new",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-website-new/src",
"projectType": "application",
"tags": ["scope:website"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/.next"],
"options": {
"cwd": "{projectRoot}",
"command": "npx next build"
},
"dependsOn": ["^build"]
},
"dev": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"cwd": "{projectRoot}",
"command": "npx next dev"
}
},
"start": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["build"],
"options": {
"cwd": "{projectRoot}",
"command": "npx next start"
}
},
"lint": {},
"lint:diff-with-main": {},
"typecheck": {},
"fmt": {
"options": {
"files": "."
},
"configurations": {
"fix": {}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,12 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'Build', fontFamily: 'sans' },
{ text: ' your Enterprise CRM ', fontFamily: 'serif' },
{ text: 'at AI Speed', fontFamily: 'sans' },
],
body: {
text: 'Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves.',
},
};
@@ -0,0 +1,23 @@
import { ProblemDataType } from '@/sections/Problem/types';
export const PROBLEM_DATA: ProblemDataType = {
eyebrow: { heading: { text: 'The Problem:', fontFamily: 'sans' } },
heading: [
{ text: 'A custom CRM gives your org an edge, ', fontFamily: 'serif' },
{ text: 'but building one comes with tradeoffs', fontFamily: 'sans' },
],
points: [
{
heading: { text: 'The Giant Monolith', fontFamily: 'sans' },
body: {
text: 'Proprietary languages, slow deployment cycles, and "black box" logic.',
},
},
{
heading: { text: 'The In-house Burden', fontFamily: 'sans' },
body: {
text: "It's fragile. V1 ships quickly, but maintaining and making changes is a long term burden.",
},
},
],
};
@@ -0,0 +1,34 @@
import { ThreeCardsFeatureCardsDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
eyebrow: {
heading: {
text: 'Skip the clunky UX that always comes with custom.',
fontFamily: 'sans',
},
},
heading: [
{ text: 'Make your GTM team happy with ', fontFamily: 'serif' },
{ text: "a CRM they'll love", fontFamily: 'sans' },
],
featureCards: [
{
heading: { text: 'Familiar, modern interface', fontFamily: 'sans' },
body: {
text: "Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.",
},
},
{
heading: { text: 'Fast path to action', fontFamily: 'sans' },
body: {
text: 'Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.',
},
},
{
heading: { text: 'Live data and AI Built', fontFamily: 'sans' },
body: {
text: 'Everything updates in real time, with AI chat always ready to help you move faster.',
},
},
],
};
@@ -0,0 +1,49 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
heading: { text: 'Stop settling for trade-offs.', fontFamily: 'sans' },
},
heading: [
{ text: 'Assemble, iterate and adapt a robust CRM,', fontFamily: 'serif' },
{ text: " that's quick to flex", fontFamily: 'sans' },
],
body: {
text: 'Compose your CRM and interal apps with a single exesibility toolkit.',
},
illustrationCards: [
{
heading: { text: 'Production grade quality', fontFamily: 'sans' },
body: {
text: "What stood out with Twenty wasn't just flexibility it was the quality. The system feels stable, polished, and ready for real teams with real stakes.",
},
benefits: undefined,
attribution: {
role: { text: 'Head of Engineering' },
company: { text: 'Mid-Market Fintech' },
},
},
{
heading: { text: 'AI for rapid iterations', fontFamily: 'sans' },
body: {
text: 'Twenty removes friction from iteration and lets us adapt our CRM as the business changes.',
},
benefits: undefined,
attribution: {
role: { text: 'Head of Engineering' },
company: { text: 'Mid-Market Fintech' },
},
},
{
heading: { text: 'Control without drag', fontFamily: 'sans' },
body: {
text: "It's a modern, open-source alternative to Salesforce/Hubspot that lets you manage your customer relationships in a secure + privacy-first way.",
},
benefits: undefined,
attribution: {
role: { text: 'Head of Engineering' },
company: { text: 'Mid-Market Fintech' },
},
},
],
};
@@ -0,0 +1,7 @@
import { TrustedByDataType } from '@/sections/TrustedBy/types';
export const TRUSTED_BY_DATA: TrustedByDataType = {
separator: { text: 'trusted by' },
clientCountLabel: { text: '+ 10 000 other clients' },
logos: [{ icon: 'flexport' }, { icon: 'zapier' }],
};
@@ -0,0 +1,88 @@
import { HERO_DATA } from '@/app/(home)/constants/hero';
import { PROBLEM_DATA } from '@/app/(home)/constants/problem';
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 { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { Problem } from '@/sections/Problem/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
export default function HomePage() {
return (
<>
<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 />
</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>
<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>
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

@@ -0,0 +1,89 @@
import { Footer } from '@/sections/Footer/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { cssVariables } from '@/theme/css-variables';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
import { Aleo, Azeret_Mono, Host_Grotesk } from 'next/font/google';
const hostGrotesk = Host_Grotesk({
subsets: ['latin'],
weight: ['300', '400', '500', '600'],
variable: '--font-sans',
display: 'swap',
});
const aleo = Aleo({
subsets: ['latin'],
weight: ['300'],
variable: '--font-serif',
display: 'swap',
});
const azeretMono = Azeret_Mono({
subsets: ['latin'],
weight: ['300', '500'],
variable: '--font-mono',
display: 'swap',
});
css`
:global(*),
:global(*::before),
:global(*::after) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(html) {
background-color: ${theme.colors.primary.background[100]};
}
:global(body) {
color: ${theme.colors.primary.text[100]};
display: flex;
font-family: ${theme.font.family.sans};
flex-direction: column;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`;
const StyledMain = styled.main`
flex-grow: 1;
`;
export const metadata: Metadata = {
title: 'Twenty — Open Source CRM',
description: 'Modular, scalable open source CRM for modern teams.',
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable}`}
>
<Menu.Root>
<Menu.Logo />
<Menu.Nav />
<Menu.Social />
<Menu.Cta />
</Menu.Root>
<StyledMain>{children}</StyledMain>
<Footer.Root>
<Footer.Logo />
<Footer.Nav />
<Footer.Bottom>
<Footer.Social />
</Footer.Bottom>
</Footer.Root>
</body>
</html>
);
}
@@ -0,0 +1,11 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'Become a ', fontFamily: 'serif' },
{ text: 'Twenty Partner', fontFamily: 'sans' },
],
body: {
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
},
};
@@ -0,0 +1,53 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
heading: {
text: 'Which partner program is right for you?',
fontFamily: 'sans',
},
},
heading: [
{ text: 'Find the program that fits your business ', fontFamily: 'serif' },
{ text: 'and unlock new opportunities with Twenty', fontFamily: 'sans' },
],
body: { text: '' },
illustrationCards: [
{
heading: { text: 'Technology Partners', fontFamily: 'sans' },
body: {
text: 'Build integrations that connect Twenty with the tools your customers already use. Help us expand the Twenty ecosystem.',
},
benefits: [
{ text: 'Co-marketing opportunities' },
{ text: 'Listing on Twenty integrations page' },
{ text: 'Soon: earn revenue' },
],
attribution: undefined,
},
{
heading: { text: 'Content & Community Partners', fontFamily: 'sans' },
body: {
text: "Share Twenty with your audience and help shape the future of open-source CRM. We're looking for creators, educators, and community builders who want to showcase great software.",
},
benefits: [
{ text: 'Revenue share for referred customers' },
{ text: 'Exclusive content collaboration opportunities' },
{ text: 'Marketing assets & brand resources' },
],
attribution: undefined,
},
{
heading: { text: 'Solutions Partners', fontFamily: 'sans' },
body: {
text: 'Help customers implement, customize, and succeed with Twenty. Combine sales and services to grow your business.',
},
benefits: [
{ text: 'Resale discounts & revenue share' },
{ text: 'Marketplace listing' },
{ text: 'Dedicated partner support' },
],
attribution: undefined,
},
],
};
@@ -0,0 +1,68 @@
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { HERO_DATA } from '@/app/partner/constants/hero';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/partner/constants/three-cards-illustration';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
export default function PartnerPage() {
return (
<>
<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
src="https://app.endlesstools.io/embed/1c6c8259-3276-4cf2-84d8-6b7e87e7ec95"
title="Endless Tools Editor"
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>
<ThreeCards.Root backgroundColor={theme.colors.secondary.background[5]}>
<ThreeCards.Intro 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>
</>
);
}
@@ -0,0 +1,11 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'A pricing that scales ', fontFamily: 'serif' },
{ text: 'with your need', fontFamily: 'sans' },
],
body: {
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
},
};
@@ -0,0 +1,15 @@
import { HERO_DATA } from '@/app/pricing/constants/hero';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { theme } from '@/theme';
export default function PricingPage() {
return (
<>
<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>
</>
);
}
@@ -0,0 +1,11 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'The CRM that moves ', fontFamily: 'serif' },
{ text: 'as fast as you do', fontFamily: 'sans' },
],
body: {
text: 'Modern interface. AI assistance. All the features you need, ready from day one.',
},
};
@@ -0,0 +1,38 @@
import { ThreeCardsIllustrationDataType } from '@/sections/ThreeCards/types';
export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
eyebrow: {
heading: { text: 'Stop settling for trade-offs.', fontFamily: 'sans' },
},
heading: [
{ text: 'A modern CRM with ', fontFamily: 'serif' },
{ text: 'an intuitive interface', fontFamily: 'sans' },
],
body: { text: '' },
illustrationCards: [
{
heading: { text: 'Built for speed', fontFamily: 'sans' },
body: {
text: 'Fly through your workspace with shortcuts and short load times.',
},
benefits: undefined,
attribution: undefined,
},
{
heading: { text: 'Real-time data', fontFamily: 'sans' },
body: {
text: 'See updates as they happen. Work with your team and agents seamlessly.',
},
benefits: undefined,
attribution: undefined,
},
{
heading: { text: 'Stay in Flow', fontFamily: 'sans' },
body: {
text: 'AI chat, settings, and records in a side panels for fast, single-screen access.',
},
benefits: undefined,
attribution: undefined,
},
],
};
@@ -0,0 +1,60 @@
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { HERO_DATA } from '@/app/product/constants/hero';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/product/constants/three-cards';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
export default function ProductPage() {
return (
<>
<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
src="https://app.endlesstools.io/embed/0bcf3ac2-58cf-4cd5-90bd-e8fada9816a9"
title="Endless Tools Editor"
backgroundColor={theme.colors.secondary.background[5]}
/>
<TrustedBy.Root>
<TrustedBy.Separator separator={TRUSTED_BY_DATA.separator} />
<TrustedBy.Logos
clientCountLabel={TRUSTED_BY_DATA.clientCountLabel}
logos={TRUSTED_BY_DATA.logos}
/>
</TrustedBy.Root>
<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}
/>
</ThreeCards.Root>
</Hero.Root>
</>
);
}
@@ -0,0 +1,11 @@
import { HeroDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroDataType = {
heading: [
{ text: 'The future of CRM is built, ', fontFamily: 'serif' },
{ text: 'not bought.', fontFamily: 'sans' },
],
body: {
text: 'Most packaged software makes companies more similar. CRM is no exception. AI is turning software into a commodity. That does not make your go-to-market a commodity. It makes it the battleground.',
},
};
@@ -0,0 +1,14 @@
import { HERO_DATA } from '@/app/why-twenty/constants/hero';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { theme } from '@/theme';
export default function WhyTwentyPage() {
return (
<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 />
</Hero.Root>
);
}
@@ -0,0 +1,99 @@
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { BodyType } from './types/Body';
const bodyClassName = css`
margin: 0;
&[data-family='sans'] {
font-family: ${theme.font.family.sans};
}
&[data-family='serif'] {
font-family: ${theme.font.family.serif};
}
&[data-family='mono'] {
font-family: ${theme.font.family.mono};
}
&[data-weight='light'] {
font-weight: ${theme.font.weight.light};
}
&[data-weight='regular'] {
font-weight: ${theme.font.weight.regular};
}
&[data-weight='medium'] {
font-weight: ${theme.font.weight.medium};
}
&[data-size='md'] {
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
}
&[data-size='sm'] {
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
}
&[data-size='xs'] {
font-size: ${theme.font.size(3)};
line-height: ${theme.lineHeight(3.5)};
}
@media (min-width: ${theme.breakpoints.md}px) {
&[data-size='md'] {
font-size: ${theme.font.size(4.5)};
line-height: ${theme.lineHeight(6)};
}
&[data-size='sm'] {
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
}
&[data-size='xs'] {
font-size: ${theme.font.size(3)};
line-height: ${theme.lineHeight(3.5)};
}
}
`;
export type BodyAs = 'p' | 'span' | 'div';
export type BodyFamily = 'sans' | 'serif' | 'mono';
export type BodyWeight = 'light' | 'regular' | 'medium';
export type BodySize = 'md' | 'sm' | 'xs';
export type BodyProps = {
as?: BodyAs;
body: BodyType;
family?: BodyFamily;
weight?: BodyWeight;
size?: BodySize;
className?: string;
};
export function Body({
as: Tag = 'p',
body,
family = 'sans',
weight = 'regular',
size = 'md',
className,
}: BodyProps) {
const rootClassName = [bodyClassName, className].filter(Boolean).join(' ');
return (
<Tag
className={rootClassName}
data-family={family}
data-weight={weight}
data-size={size}
>
{body.text}
</Tag>
);
}
@@ -0,0 +1,3 @@
export type BodyType = {
text: string;
}
@@ -0,0 +1,84 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { ButtonShape } from './ButtonShape';
export const buttonBaseStyles = `
align-items: center;
background: transparent;
border: none;
border-radius: ${theme.radius(2)};
cursor: pointer;
display: inline-flex;
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
height: ${theme.spacing(10)};
justify-content: center;
letter-spacing: 0;
padding: 0 ${theme.spacing(5)};
position: relative;
text-decoration: none;
text-transform: uppercase;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
const Label = styled.span`
position: relative;
z-index: 1;
&[data-color='primary'] {
color: ${theme.colors.primary.text[100]};
}
&[data-color='secondary'] {
color: ${theme.colors.secondary.text[100]};
}
`;
export type BaseButtonProps = {
color: 'primary' | 'secondary';
label: string;
variant: 'contained' | 'outlined';
};
export function BaseButton({ color, label, variant }: BaseButtonProps) {
let fillColor: string;
let strokeColor: string;
let labelColor: 'primary' | 'secondary';
switch (`${variant}.${color}`) {
case 'contained.primary':
fillColor = theme.colors.primary.background[100];
strokeColor = 'none';
labelColor = 'primary';
break;
case 'contained.secondary':
fillColor = theme.colors.secondary.background[100];
strokeColor = 'none';
labelColor = 'secondary';
break;
case 'outlined.primary':
fillColor = 'none';
strokeColor = theme.colors.primary.background[100];
labelColor = 'secondary';
break;
case 'outlined.secondary':
fillColor = 'none';
strokeColor = theme.colors.secondary.background[100];
labelColor = 'primary';
break;
default:
throw new Error(`Unhandled button appearance: ${variant} ${color}`);
}
return (
<>
<ButtonShape fillColor={fillColor} strokeColor={strokeColor} />
<Label data-color={labelColor}>{label}</Label>
</>
);
}
@@ -0,0 +1,74 @@
import { styled } from "@linaria/react";
interface ButtonShapeProps {
fillColor: string;
strokeColor: string;
}
const LEFT_FILL = "M4 0 A4 4 0 0 0 0 4 V36 A4 4 0 0 0 4 40 Z";
const LEFT_OUTLINE = "M4 0.5 A3.5 3.5 0 0 0 0.5 4 V36 A3.5 3.5 0 0 0 4 39.5";
const RIGHT_FILL = "M0 0 h11 a4 4 0 0 1 4 4 v20.523 a6 6 0 0 1 -1.544 4.019 l-8.548 9.477 A6 6 0 0 1 0.453 40 H0 Z";
const RIGHT_OUTLINE = "M0 0.5 h11 a3.5 3.5 0 0 1 3.5 3.5 v20.523 a5.5 5.5 0 0 1 -1.416 3.684 l-8.547 9.477 a5.5 5.5 0 0 1 -4.084 1.816 H0";
const ShapeContainer = styled.div`
display: flex;
height: 40px;
inset: 0;
pointer-events: none;
position: absolute;
width: 100%;
z-index: 0;
`;
const LeftCap = styled.svg`
display: block;
flex-shrink: 0;
`;
const MiddleSegment = styled.svg`
display: block;
flex-grow: 1;
margin: 0 -1px;
min-width: 0;
`;
const RightCap = styled.svg`
display: block;
flex-shrink: 0;
`;
export function ButtonShape({ fillColor, strokeColor }: ButtonShapeProps) {
const isOutline = fillColor === "none";
return (
<ShapeContainer>
<LeftCap width="4" height="40" viewBox="0 0 4 40" fill="none" xmlns="http://www.w3.org/2000/svg">
{isOutline ? (
<path d={LEFT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" />
) : (
<path d={LEFT_FILL} fill={fillColor} stroke={strokeColor} />
)}
</LeftCap>
<MiddleSegment width="100%" height="40" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
{isOutline ? (
<>
<line x1="0" y1="0.5" x2="100%" y2="0.5" stroke={strokeColor} strokeWidth="1" />
<line x1="0" y1="39.5" x2="100%" y2="39.5" stroke={strokeColor} strokeWidth="1" />
</>
) : (
<rect width="100%" height="40" fill={fillColor} stroke={strokeColor} />
)}
</MiddleSegment>
<RightCap width="15" height="40" viewBox="0 0 15 40" fill="none" xmlns="http://www.w3.org/2000/svg">
{isOutline ? (
<path d={RIGHT_OUTLINE} fill={fillColor} stroke={strokeColor} strokeWidth="1" strokeLinejoin="round" strokeLinecap="round" />
) : (
<path d={RIGHT_FILL} fill={fillColor} stroke={strokeColor} />
)}
</RightCap>
</ShapeContainer>
);
}
@@ -0,0 +1,49 @@
import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType';
import { styled } from '@linaria/react';
import Link from 'next/link';
import {
BaseButton,
type BaseButtonProps,
buttonBaseStyles,
} from './BaseButton';
const StyledButtonAnchor = styled.a`
${buttonBaseStyles}
`;
const StyledButtonLink = styled(Link)`
${buttonBaseStyles}
`;
export type LinkButtonPresentation = 'anchor' | 'link';
export type LinkButtonProps = Omit<BaseButtonProps, 'label'> &
LinkButtonType & { type: LinkButtonPresentation };
export function LinkButton({
color,
href,
label,
type,
variant,
}: LinkButtonProps) {
const inner = <BaseButton color={color} label={label} variant={variant} />;
if (type === 'anchor') {
return (
<StyledButtonAnchor
href={href}
rel="noopener noreferrer"
target="_blank"
>
{inner}
</StyledButtonAnchor>
);
}
return (
<StyledButtonLink href={href}>
{inner}
</StyledButtonLink>
);
}
@@ -0,0 +1,27 @@
import { styled } from '@linaria/react';
import {
BaseButton,
type BaseButtonProps,
buttonBaseStyles,
} from './BaseButton';
import type { SubmitButtonType } from './types/SubmitButtonType';
const StyledSubmitButton = styled.button`
${buttonBaseStyles}
`;
export type SubmitButtonProps = Omit<BaseButtonProps, 'label'> &
SubmitButtonType & { onClick?: () => void };
export function SubmitButton({
color,
label,
onClick,
variant,
}: SubmitButtonProps) {
return (
<StyledSubmitButton type="submit" onClick={onClick}>
<BaseButton color={color} label={label} variant={variant} />
</StyledSubmitButton>
);
}
@@ -0,0 +1 @@
export type LinkButtonType = { href: string; label: string };
@@ -0,0 +1 @@
export type SubmitButtonType = { label: string };
@@ -0,0 +1,15 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const ContainerRoot = styled.div`
margin: 0 auto;
max-width: ${theme.breakpoints.maxContent}px;
width: 100%;
`;
export type ContainerProps = { children: ReactNode; className?: string };
export function Container({ children, className }: ContainerProps) {
return <ContainerRoot className={className}>{children}</ContainerRoot>;
}
@@ -0,0 +1,53 @@
import { Heading } from '@/design-system/components/Heading/Heading';
import { HeadingType } from '@/design-system/components/Heading/types/Heading';
import { RectangleFillIcon } from '@/icons';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
const EyebrowRow = styled.div`
align-items: start;
display: grid;
gap: ${theme.spacing(2)};
grid-template-columns: auto 1fr;
text-align: start;
`;
const IconWrapper = styled.div`
display: flex;
align-items: center;
height: ${theme.lineHeight(6)};
`;
const eyebrowColorPrimary = css`
color: ${theme.colors.primary.text[60]};
`;
const eyebrowColorSecondary = css`
color: ${theme.colors.secondary.text[60]};
`;
type EyebrowProps = {
heading: HeadingType;
colorScheme: 'primary' | 'secondary';
};
export function Eyebrow({ heading, colorScheme }: EyebrowProps) {
const colorClassName =
colorScheme === 'primary' ? eyebrowColorPrimary : eyebrowColorSecondary;
return (
<EyebrowRow>
<IconWrapper>
<RectangleFillIcon size={14} fillColor={theme.colors.highlight[100]} />
</IconWrapper>
<Heading
as="h3"
className={colorClassName}
segments={{ fontFamily: heading.fontFamily, text: heading.text }}
size="xs"
weight="medium"
/>
</EyebrowRow>
);
}
@@ -0,0 +1,3 @@
import { HeadingType } from '@/design-system/components/Heading/types/Heading';
export type EyebrowType = { heading: HeadingType };
@@ -0,0 +1,130 @@
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { HeadingType } from './types/Heading';
const headingRootClassName = css`
margin: 0;
&[data-weight='light'] {
font-weight: ${theme.font.weight.light};
}
&[data-weight='regular'] {
font-weight: ${theme.font.weight.regular};
}
&[data-weight='medium'] {
font-weight: ${theme.font.weight.medium};
}
&[data-size='xl'] {
font-size: ${theme.font.size(15)};
line-height: ${theme.lineHeight(16.5)};
}
&[data-size='lg'] {
font-size: ${theme.font.size(10)};
line-height: ${theme.lineHeight(11.5)};
}
&[data-size='md'] {
font-size: ${theme.font.size(10)};
line-height: ${theme.lineHeight(11.5)};
}
&[data-size='sm'] {
font-size: ${theme.font.size(8)};
line-height: ${theme.lineHeight(10)};
}
&[data-size='xs'] {
font-size: ${theme.font.size(4.5)};
line-height: ${theme.lineHeight(6)};
}
@media (min-width: ${theme.breakpoints.md}px) {
&[data-size='xl'] {
font-size: ${theme.font.size(20)};
line-height: ${theme.lineHeight(21.5)};
}
&[data-size='lg'] {
font-size: ${theme.font.size(15)};
line-height: ${theme.lineHeight(16.5)};
}
&[data-size='md'] {
font-size: ${theme.font.size(12)};
line-height: ${theme.lineHeight(14)};
}
&[data-size='sm'] {
font-size: ${theme.font.size(8)};
line-height: ${theme.lineHeight(10)};
}
&[data-size='xs'] {
font-size: ${theme.font.size(5.5)};
line-height: ${theme.lineHeight(7)};
}
}
`;
const StyledSpan = styled.span`
&[data-family='sans'] {
font-family: ${theme.font.family.sans};
letter-spacing: -0.02em;
}
&[data-family='serif'] {
font-family: ${theme.font.family.serif};
letter-spacing: 0;
}
&[data-family='mono'] {
font-family: ${theme.font.family.mono};
letter-spacing: -0.02em;
}
`;
export type HeadingAs = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
export type HeadingFamily = 'sans' | 'serif' | 'mono';
export type HeadingWeight = 'light' | 'regular' | 'medium';
export type HeadingSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs';
export type HeadingProps = {
as?: HeadingAs;
segments: HeadingType | HeadingType[];
weight?: HeadingWeight;
size?: HeadingSize;
className?: string;
};
export function Heading({
as: Tag = 'h1',
segments,
weight = 'regular',
size = 'md',
className,
}: HeadingProps) {
const rootClassName = [headingRootClassName, className]
.filter(Boolean)
.join(' ');
return (
<Tag className={rootClassName} data-weight={weight} data-size={size}>
{Array.isArray(segments) ? (
segments.map((segment, index) => (
<StyledSpan key={index} data-family={segment.fontFamily}>
{segment.text}
</StyledSpan>
))
) : (
<StyledSpan data-family={segments.fontFamily}>
{segments.text}
</StyledSpan>
)}
</Tag>
);
}
@@ -0,0 +1,4 @@
export type HeadingType = {
fontFamily: 'sans' | 'serif' | 'mono';
text: string;
}
@@ -0,0 +1,68 @@
import type { ComponentType } from "react";
import { theme } from "@/theme";
import { styled } from "@linaria/react";
type IconComponent =
| ComponentType<{ size: number; fillColor: string }>
| ComponentType<{ size: number; strokeColor: string }>;
interface IconButtonProps {
icon: IconComponent;
ariaLabel: string;
borderColor: string;
iconFillColor: string;
iconSize: number;
iconStrokeColor: string;
size: number;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
ariaExpanded?: boolean;
}
const StyledButton = styled.button<{ size: number; borderColor: string }>`
align-items: center;
background: transparent;
border: 1px solid ${(p) => p.borderColor};
border-radius: ${theme.radius(2)};
cursor: pointer;
display: inline-flex;
height: ${(p) => p.size}px;
justify-content: center;
margin: 0;
padding: 0;
width: ${(p) => p.size}px;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
export function IconButton({
icon: Icon,
ariaLabel,
borderColor,
iconFillColor,
iconSize,
iconStrokeColor,
size,
onClick,
ariaExpanded,
}: IconButtonProps) {
return (
<StyledButton
type="button"
size={size}
borderColor={borderColor}
aria-label={ariaLabel}
aria-expanded={ariaExpanded}
onClick={onClick}
>
<Icon
size={iconSize}
strokeColor={iconStrokeColor}
fillColor={iconFillColor}
aria-hidden="true"
/>
</StyledButton>
);
}
@@ -0,0 +1,4 @@
export type IllustrationType = {
src: string;
title: string;
};
@@ -0,0 +1,26 @@
import NextImage from 'next/image';
import { styled } from '@linaria/react';
import type { ImageType } from './types/Image';
const ImageRoot = styled.div`
aspect-ratio: 16 / 9;
overflow: hidden;
position: relative;
width: 100%;
`;
export type ImageComponentProps = ImageType & { className?: string };
export function Image({ alt, className, src }: ImageComponentProps) {
return (
<ImageRoot className={className}>
<NextImage
alt={alt}
fill
sizes="100vw"
src={src}
style={{ objectFit: 'cover' }}
/>
</ImageRoot>
);
}
@@ -0,0 +1,4 @@
export type ImageType = {
alt: string;
src: string;
};
@@ -0,0 +1,8 @@
export { Body } from './Body/Body';
export { LinkButton } from './Button/LinkButton';
export { SubmitButton } from './Button/SubmitButton';
export { Container } from './Container/Container';
export { Eyebrow } from './Eyebrow/Eyebrow';
export { Heading } from './Heading/Heading';
export { IconButton } from './IconButton/IconButton';
export { Image } from './Image/Image';
@@ -0,0 +1,7 @@
export enum Pages {
Home = "home",
Partner = "partner",
Pricing = "pricing",
Product = "product",
WhyTwenty = "whyTwenty",
}
@@ -0,0 +1,29 @@
const FLEXPORT_VIEWBOX_WIDTH = 115;
const FLEXPORT_VIEWBOX_HEIGHT = 27;
const FLEXPORT_PATH_1 =
"M13.462 16.78V.047h5.457v16.034c0 1.13.24 1.394 1.394 1.394h.457l.649 4.423a39 39 0 0 1-3.125.169c-2.981.024-4.832-1.034-4.832-5.289M55.241 6.562l4.23-.336.41 1.274c.84-.793 2.331-1.587 5.528-1.587 5.505 0 6.78 3.87 6.78 8.198 0 4.35-1.395 8.173-7.116 8.173-2.524 0-3.726-.577-4.4-1.202v5.505h-5.456zm11.418 7.765c0-1.851-.336-3.462-2.692-3.462-2.524 0-3.27 1.299-3.27 3.462s.554 3.365 3.27 3.365c2.308-.024 2.692-1.538 2.692-3.365m24.977-7.764 4.23-.337.553 1.659c.698-.938 2.597-1.731 4.856-1.827v5.24c-3.63.024-4.183 1.106-4.183 3.27v7.452h-5.456zm11.154-.505h2.812l.89-3.197 4.206-.914v4.11h3.438v5.241h-3.438v4.568c0 1.081.169 1.634 1.875 1.634h1.563v4.543c-.817.049-1.731.073-2.765.073-4.591 0-6.105-1.467-6.105-5.433v-5.36h-2.476zM0 6.25h2.98v-.312C2.98 1.97 4.472 0 9.592 0c1.347 0 1.587.024 2.26.072v4.423h-1.514c-1.563 0-1.9.385-1.9 1.394v.361h3.414v4.616H8.414V22.02H2.98V10.866H0zm33.294 11.058c-.914.529-1.37.649-3.173.649-2.14 0-2.957-.77-3.222-2.043H37.38c.192-5.553-.264-10.048-7.26-10.048s-8.485 3.87-8.485 8.149c0 4.399 1.466 8.293 8.485 8.293 2.765 0 4.496-.505 6.347-1.707zm-3.318-6.947c2.236 0 2.404 1.057 2.5 2.356h-5.6c.192-1.515.913-2.356 3.1-2.356";
const FLEXPORT_PATH_2 =
"m48.198 22.043-2.669-3.822-2.668 3.822h-6.106l5.721-8.149-5.192-7.332h6.154l2.115 2.981 2.116-2.98h6.226l-5.289 7.307 5.722 8.173zm25.12-7.956c0-5.24 2.404-8.197 8.558-8.197 6.178 0 8.534 2.98 8.534 8.197 0 5.24-2.38 8.22-8.534 8.22-6.154.025-8.558-2.98-8.558-8.22m11.61.168c0-1.971-.504-3.414-3.052-3.414-2.572 0-3.053 1.443-3.053 3.414 0 1.947.505 3.414 3.053 3.414 2.572 0 3.053-1.443 3.053-3.414";
interface FlexportIconProps {
size: number;
fillColor: string;
}
export function FlexportIcon({ size, fillColor }: FlexportIconProps) {
const height = size * (FLEXPORT_VIEWBOX_HEIGHT / FLEXPORT_VIEWBOX_WIDTH);
return (
<svg
width={size}
height={height}
viewBox={`0 0 ${FLEXPORT_VIEWBOX_WIDTH} ${FLEXPORT_VIEWBOX_HEIGHT}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d={FLEXPORT_PATH_1} fill={fillColor} />
<path d={FLEXPORT_PATH_2} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,26 @@
const ZAPIER_VIEWBOX_WIDTH = 112;
const ZAPIER_VIEWBOX_HEIGHT = 31;
const ZAPIER_PATH =
"m26.105 20.644-.016-.05 9.885-9.05v-4.36H20.115v4.36h9.473l.015.043-9.788 9.057v4.36h16.318v-4.36zM45.87 6.77q-2.744 0-4.473.827a5.87 5.87 0 0 0-2.595 2.274 8.3 8.3 0 0 0-1.089 3.29l4.812.676q.261-1.538 1.044-2.18a3.2 3.2 0 0 1 2.068-.64q1.466 0 2.126.784c.436.522.658 1.245.658 2.198v.475h-4.627q-3.532 0-5.076 1.567t-1.54 4.136q0 2.631 1.54 3.916t3.875 1.277q2.895 0 4.454-1.992a7.4 7.4 0 0 0 1.288-2.744h.174l.664 4.36h4.36V13.952q0-3.423-1.804-5.303t-5.86-1.88m1.525 13.61c-.692.577-1.598.865-2.726.865q-1.395 0-1.938-.489a1.6 1.6 0 0 1-.545-1.24 1.63 1.63 0 0 1 .451-1.203 1.68 1.68 0 0 1 1.24-.452h4.552v.3a2.78 2.78 0 0 1-1.034 2.22M79.899 7.184h-5.151v17.823h5.15zm30.415 0q-2.183 0-3.441 1.541-.903 1.108-1.373 3.271h-.152l-.674-4.812h-4.399v17.82h5.152v-9.138q0-1.728.809-2.65t2.987-.922h1.955v-5.11zm-15.152.583q-1.804-1.074-4.55-1.07-4.324 0-6.768 2.461-2.445 2.463-2.444 6.789a10.8 10.8 0 0 0 1.11 5.037 8 8 0 0 0 3.16 3.329q2.046 1.182 4.869 1.185 2.44 0 4.078-.752a6.56 6.56 0 0 0 2.595-2.05 8.9 8.9 0 0 0 1.41-2.914l-4.438-1.242a4.1 4.1 0 0 1-1.045 1.806q-.752.753-2.52.753-2.1 0-3.133-1.204c-.499-.58-.812-1.408-.95-2.48H98.69q.076-.753.076-1.262v-1.11a9.9 9.9 0 0 0-.9-4.323 6.77 6.77 0 0 0-2.704-2.953m-4.7 3.177q3.135 0 3.554 3.084h-7.389a4.24 4.24 0 0 1 .828-1.883q1.016-1.203 3.008-1.2M77.38.002a2.9 2.9 0 0 0-2.108.81 2.8 2.8 0 0 0-.828 2.088 2.83 2.83 0 0 0 2.935 2.932 2.8 2.8 0 0 0 2.089-.828 2.87 2.87 0 0 0 .807-2.104A2.77 2.77 0 0 0 77.382.004zM66.737 6.696q-2.633 0-4.23 1.653-1.183 1.225-1.736 3.655h-.165l-.673-4.812h-4.4v22.935h5.151v-9.446h.189c.157.63.377 1.242.657 1.827a5.2 5.2 0 0 0 2.05 2.293 6 6 0 0 0 3.044.734q3.236 0 4.927-2.52 1.692-2.52 1.692-7.032 0-4.357-1.636-6.822-1.638-2.465-4.87-2.465m.522 12.914q-.9 1.306-2.82 1.306a3.32 3.32 0 0 1-2.762-1.261q-1-1.256-.998-3.44v-.338q0-2.217.998-3.395t2.762-1.164q1.953 0 2.838 1.24t.885 3.46q.006 2.295-.898 3.592zm-49.436 1.034H0v4.366h17.823z";
interface ZapierIconProps {
size: number;
fillColor: string;
}
export function ZapierIcon({ size, fillColor }: ZapierIconProps) {
const height = size * (ZAPIER_VIEWBOX_HEIGHT / ZAPIER_VIEWBOX_WIDTH);
return (
<svg
width={size}
height={height}
viewBox={`0 0 ${ZAPIER_VIEWBOX_WIDTH} ${ZAPIER_VIEWBOX_HEIGHT}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d={ZAPIER_PATH} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,13 @@
import type { ComponentType } from "react";
import { FlexportIcon } from "./Flexport";
import { ZapierIcon } from "./Zapier";
export { FlexportIcon } from "./Flexport";
export { ZapierIcon } from "./Zapier";
export type ClientIconProps = { size: number; fillColor: string };
export const CLIENT_ICONS: Record<string, ComponentType<ClientIconProps>> = {
flexport: FlexportIcon,
zapier: ZapierIcon,
};
@@ -0,0 +1,29 @@
const ARROW_LEFT_PATH =
"M.5 5.75H14m-13.5 0L5.75 11M.5 5.75 5.75.5";
interface ArrowLeftIconProps {
size: number;
strokeColor: string;
fillColor?: string;
}
export function ArrowLeftIcon({ size, strokeColor }: ArrowLeftIconProps) {
return (
<svg
width={size}
height={size * (12 / 15)}
viewBox="0 0 15 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d={ARROW_LEFT_PATH}
stroke={strokeColor}
strokeMiterlimit={10}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,29 @@
const ARROW_RIGHT_PATH =
"M14 5.75H.5m13.5 0L8.75.5M14 5.75 8.75 11";
interface ArrowRightIconProps {
size: number;
strokeColor: string;
fillColor?: string;
}
export function ArrowRightIcon({ size, strokeColor }: ArrowRightIconProps) {
return (
<svg
width={size}
height={size * (12 / 15)}
viewBox="0 0 15 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d={ARROW_RIGHT_PATH}
stroke={strokeColor}
strokeMiterlimit={10}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,30 @@
const ARROW_RIGHT_UP_PATH =
"M6.069.5.5 6.068M6.069.5H1.738m4.33 0v4.331";
interface ArrowRightUpIconProps {
size: number;
strokeColor: string;
}
export function ArrowRightUpIcon({
size,
strokeColor,
}: ArrowRightUpIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 6.569 6.568"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={ARROW_RIGHT_UP_PATH}
stroke={strokeColor}
strokeMiterlimit={10}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,26 @@
const CLOSE_PATH = "m.5.5 9.546 9.546m-9.546 0L10.046.5";
interface CloseIconProps {
size: number;
strokeColor: string;
}
export function CloseIcon({ size, strokeColor }: CloseIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 10.546 10.546"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={CLOSE_PATH}
stroke={strokeColor}
strokeMiterlimit={10}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,26 @@
const MENU_PATH = "M.5.5H14M.5 5.75H14M.5 11H14";
interface MenuIconProps {
size: number;
strokeColor: string;
}
export function MenuIcon({ size, strokeColor }: MenuIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 14.5 11.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={MENU_PATH}
stroke={strokeColor}
strokeMiterlimit={10}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,26 @@
const MINUS_PATH = "M.5 7.25H14.5";
interface MinusIconProps {
size: number;
strokeColor: string;
}
export function MinusIcon({ size, strokeColor }: MinusIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={MINUS_PATH}
stroke={strokeColor}
strokeMiterlimit={10}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,26 @@
const PLUS_PATH = "M.5 7.25H14M7.25 14V.5";
interface PlusIconProps {
size: number;
strokeColor: string;
}
export function PlusIcon({ size, strokeColor }: PlusIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={PLUS_PATH}
stroke={strokeColor}
strokeMiterlimit={10}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,18 @@
interface RectangleFillIconProps {
size: number;
fillColor: string;
}
export function RectangleFillIcon({ size, fillColor }: RectangleFillIconProps) {
return (
<svg
width={size}
height={size * (7 / 14)}
viewBox="0 0 14 7"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width={14} height={7} rx={1} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,29 @@
interface RectangleOutlineIconProps {
size: number;
strokeColor: string;
}
export function RectangleOutlineIcon({
size,
strokeColor,
}: RectangleOutlineIconProps) {
return (
<svg
width={size}
height={size * (7 / 14)}
viewBox="0 0 14 7"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x={0.5}
y={0.5}
width={13}
height={6}
rx={0.5}
stroke={strokeColor}
strokeWidth={1}
/>
</svg>
);
}
@@ -0,0 +1,15 @@
export { CLIENT_ICONS, FlexportIcon, ZapierIcon } from "./clients";
export { ArrowLeftIcon } from "./functional/ArrowLeft";
export { ArrowRightIcon } from "./functional/ArrowRight";
export { ArrowRightUpIcon } from "./functional/ArrowRightUp";
export { CloseIcon } from "./functional/Close";
export { MenuIcon } from "./functional/Menu";
export { MinusIcon } from "./functional/Minus";
export { PlusIcon } from "./functional/Plus";
export { RectangleFillIcon } from "./functional/RectangleFill";
export { RectangleOutlineIcon } from "./functional/RectangleOutline";
export { Logo } from "./logo/Logo";
export { DiscordIcon } from "./social/Discord";
export { GitHubIcon } from "./social/GitHub";
export { LinkedInIcon } from "./social/LinkedIn";
export { XIcon } from "./social/X";
@@ -0,0 +1,29 @@
const USERS_PATH =
"M5.499 9a7.52 7.52 0 0 1 4.492 1.502c.939.694 1.176 1.744.895 2.646h2.523c.28-.014.42-.06.48-.091a1 1 0 0 0-.11-.354 2.8 2.8 0 0 0-.709-.885 2 2 0 0 0-.396-.261.5.5 0 0 0-.175-.057v-2c.72 0 1.382.392 1.85.78.495.412.948.965 1.232 1.554.266.552.487 1.363.113 2.123-.405.824-1.277 1.145-2.2 1.19l-.024.001H8.427v-.153q-.06.004-.121.005H2.692a2.7 2.7 0 0 1-2.49-1.605c-.41-.952-.22-2.135.805-2.893A7.52 7.52 0 0 1 5.499 9m0 2c-1.213 0-2.347.404-3.303 1.11-.136.101-.175.19-.188.245a.4.4 0 0 0 .03.247c.09.205.32.398.654.398h5.614a.7.7 0 0 0 .654-.398.4.4 0 0 0 .03-.248c-.013-.054-.052-.143-.188-.244-.956-.706-2.09-1.11-3.303-1.11m.373-11a3.873 3.873 0 1 1 0 7.747 3.873 3.873 0 0 1 0-7.747m5.387.742a3.132 3.132 0 1 1-.648 6.195V4.799a1.133 1.133 0 0 0 1.78-.926 1.132 1.132 0 0 0-1.78-.928V.81q.315-.067.648-.068M5.872 2a1.873 1.873 0 1 0 0 3.746 1.873 1.873 0 0 0 0-3.746";
interface UsersIconProps {
size: number;
fillColor: string;
}
export function UsersIcon({
size,
fillColor,
}: UsersIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d={USERS_PATH}
fill={fillColor}
/>
</svg>
);
}
@@ -0,0 +1,27 @@
const LOGO_PATH =
"M6.822 14.174c0-2.435 1.996-4.411 4.456-4.411h8.574c.125 0 .241.075.293.19a.31.31 0 0 1-.056.344l-1.88 2.023c-.326.35-.787.552-1.27.552H11.3c-.738 0-1.338.594-1.338 1.325v3.336a.78.78 0 0 1-.783.777H7.61a.78.78 0 0 1-.783-.777v-3.36zM33.5 25.553c0 2.434-1.996 4.411-4.456 4.411h-3.642c-2.46 0-4.454-1.977-4.454-4.411v-6.315c0-.43.16-.842.456-1.16l2.124-2.285a.33.33 0 0 1 .355-.081.32.32 0 0 1 .205.295v9.527c0 .73.598 1.322 1.337 1.322h3.6a1.33 1.33 0 0 0 1.337-1.322V14.197c0-.73-.599-1.325-1.337-1.325H24.84c-.481 0-.938.201-1.265.547L11.088 26.856h7.503a.78.78 0 0 1 .784.778v1.552a.78.78 0 0 1-.784.778H8.481a1.655 1.655 0 0 1-1.662-1.644v-.824c0-.412.156-.809.44-1.114l13.999-15.06a4.9 4.9 0 0 1 3.594-1.56h4.189c2.46 0 4.454 1.977 4.454 4.412v11.379z";
interface LogoProps {
size: number;
fillColor: string;
backgroundColor: string;
}
export function Logo({
size,
fillColor,
backgroundColor,
}: LogoProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width={40} height={40} rx={4} fill={backgroundColor} />
<path d={LOGO_PATH} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,21 @@
const DISCORD_PATH =
"M10.518.785A10.3 10.3 0 0 0 7.99.001a.04.04 0 0 0-.04.019c-.11.194-.23.448-.315.647a9.5 9.5 0 0 0-2.84 0 7 7 0 0 0-.32-.647.04.04 0 0 0-.041-.02c-.888.153-1.737.421-2.53.785A.04.04 0 0 0 1.888.8C.277 3.206-.165 5.553.05 7.871a.04.04 0 0 0 .017.03A10.3 10.3 0 0 0 3.17 9.469a.04.04 0 0 0 .044-.014q.36-.49.634-1.033a.04.04 0 0 0-.021-.055 7 7 0 0 1-.97-.462.04.04 0 0 1-.003-.066q.098-.073.192-.15a.04.04 0 0 1 .04-.006c2.034.928 4.235.928 6.245 0a.04.04 0 0 1 .04.005q.095.078.193.151a.04.04 0 0 1-.003.066q-.463.27-.97.462a.04.04 0 0 0-.02.055q.28.543.633 1.032a.04.04 0 0 0 .044.015A10.3 10.3 0 0 0 12.355 7.9a.04.04 0 0 0 .016-.03c.26-2.68-.433-5.007-1.837-7.071a.03.03 0 0 0-.016-.015M4.152 6.46c-.612 0-1.117-.562-1.117-1.252s.495-1.253 1.117-1.253c.627 0 1.126.567 1.117 1.253 0 .69-.495 1.252-1.117 1.252m4.129 0c-.613 0-1.117-.562-1.117-1.252s.495-1.253 1.117-1.253c.627 0 1.126.567 1.116 1.253 0 .69-.49 1.252-1.116 1.252";
interface DiscordIconProps {
size: number;
fillColor: string;
}
export function DiscordIcon({ size, fillColor }: DiscordIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 12.4 9.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d={DISCORD_PATH} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,21 @@
const GITHUB_PATH =
"M6.979 0C3.12 0 0 3.143 0 7.031a7.025 7.025 0 0 0 4.772 6.67c.347.07.474-.151.474-.337 0-.163-.012-.722-.012-1.304-1.941.419-2.345-.838-2.345-.838-.312-.815-.775-1.025-.775-1.025-.635-.43.047-.43.047-.43.705.046 1.074.721 1.074.721.624 1.071 1.63.769 2.034.582.058-.454.243-.768.439-.943-1.548-.163-3.177-.768-3.177-3.469 0-.768.277-1.396.716-1.885-.069-.175-.312-.897.069-1.863 0 0 .59-.186 1.918.722a6.7 6.7 0 0 1 1.745-.233c.589 0 1.19.082 1.745.233 1.329-.908 1.918-.722 1.918-.722.381.966.139 1.688.069 1.863.451.489.717 1.117.717 1.885 0 2.701-1.629 3.295-3.189 3.469.254.222.473.641.473 1.304 0 .943-.011 1.7-.011 1.932 0 .187.127.408.474.338a7.025 7.025 0 0 0 4.772-6.67C13.958 3.143 10.827 0 6.979 0";
interface GitHubIconProps {
size: number;
fillColor: string;
}
export function GitHubIcon({ size, fillColor }: GitHubIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d={GITHUB_PATH} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,21 @@
const LINKEDIN_PATH =
"M.386 2.375A1.35 1.35 0 0 1 0 1.394c0-.39.129-.732.386-.997Q.772.001 1.38 0c.406 0 .725.133.982.397q.386.397.386.997c0 .399-.13.716-.386.98q-.386.398-.982.398c-.397 0-.738-.133-.995-.397m2.146 1.517V12H.216V3.892zm7.712.801Q11 5.602 11 7.19v4.667H8.8V7.519q0-.802-.375-1.245t-1.01-.444q-.633 0-1.009.444T6.031 7.52v4.338H3.818V3.869h2.213v1.06q.336-.53.906-.838t1.28-.307q1.27 0 2.026.908";
interface LinkedInIconProps {
size: number;
fillColor: string;
}
export function LinkedInIcon({ size, fillColor }: LinkedInIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 11 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d={LINKEDIN_PATH} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,21 @@
const X_PATH =
"m.03 0 4.83 6.46L0 11.713h1.094L5.35 7.115l3.439 4.598h3.723L7.41 4.89 11.935 0H10.84L6.921 4.235 3.754 0zm1.61.806h1.71l7.553 10.101h-1.71z";
interface XIconProps {
size: number;
fillColor: string;
}
export function XIcon({ size, fillColor }: XIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 12 11.8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d={X_PATH} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,46 @@
const FOOTER_SHAPE_PATH =
"M0 4a4 4 0 0 1 4-4h344.32c4.197 0 8.369.66 12.361 1.958l49.5 16.084A40 40 0 0 0 422.542 20h517.7c4.293 0 8.559-.691 12.633-2.047l47.785-15.906A40 40 0 0 1 1013.29 0H1356a4 4 0 0 1 4 4v16H0z";
interface FooterShapeProps {
fillColor: string;
}
export function FooterShape({ fillColor }: FooterShapeProps) {
return (
<div
aria-hidden
style={{
width: "100%",
height: "100%",
position: "absolute",
inset: 0,
pointerEvents: "none",
zIndex: -1,
}}
>
<svg
width="100%"
height="20"
viewBox="0 0 1360 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
style={{ display: "block" }}
>
<path d={FOOTER_SHAPE_PATH} fill={fillColor} />
</svg>
<div
style={{
position: "absolute",
top: 19, // 1px overlap to prevent visual subpixel gaps
bottom: 0,
left: 0,
right: 0,
backgroundColor: fillColor,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
}}
/>
</div>
);
}
@@ -0,0 +1,49 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const BottomGrid = styled.div`
display: grid;
font-size: ${theme.font.size(3)};
gap: ${theme.spacing(6)};
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
`;
const Copyright = styled.div`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.mono};
grid-column: 1;
grid-row: 2;
justify-self: start;
text-transform: uppercase;
@media (min-width: ${theme.breakpoints.md}px) {
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;
@media (min-width: ${theme.breakpoints.md}px) {
grid-column: 1 / -1;
}
`;
type BottomProps = { children?: ReactNode };
export function Bottom({ children }: BottomProps) {
return (
<BottomGrid>
<Copyright>© 2026 Twenty</Copyright>
{children}
<Credit>Design by Ardent.</Credit>
</BottomGrid>
);
}
@@ -0,0 +1,12 @@
import { Logo as LogoIcon } from '@/icons';
import { theme } from '@/theme';
export function Logo() {
return (
<LogoIcon
size={40}
fillColor={theme.colors.primary.background[100]}
backgroundColor={theme.colors.secondary.background[100]}
/>
);
}
@@ -0,0 +1,198 @@
import { LinkButton } from '@/design-system/components';
import { PlusIcon, RectangleFillIcon } from '@/icons';
import { FOOTER_NAV_GROUPS } from '@/sections/Footer/constants/footer-nav-groups';
import { theme } from '@/theme';
import { NavigationMenu } from '@base-ui/react/navigation-menu';
import { styled } from '@linaria/react';
import Link from 'next/link';
import React from 'react';
const FooterNav = styled.nav`
margin-top: ${theme.spacing(10)};
margin-bottom: ${theme.spacing(10)};
@media (min-width: ${theme.breakpoints.md}px) {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
}
`;
const NavDivider = styled.div`
align-items: center;
display: flex;
gap: ${theme.spacing(1.5)};
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
flex-direction: column;
height: 100%;
width: auto;
margin-left: ${theme.spacing(7)};
margin-right: ${theme.spacing(7)};
}
`;
const NavDividerLine = styled.div`
background-color: #1c1c1c1a;
flex: 1 1 0%;
height: 1px;
min-height: 1px;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
height: auto;
min-height: 0;
min-width: 1px;
width: 1px;
}
`;
const NavGroup = styled.section`
margin-top: ${theme.spacing(4)};
margin-bottom: ${theme.spacing(4)};
@media (min-width: ${theme.breakpoints.md}px) {
margin-top: 0;
margin-bottom: ${theme.spacing(7)};
}
`;
const NavGroupTitle = styled.h4`
font-size: ${theme.font.size(4)};
font-weight: ${theme.font.weight.medium};
margin-bottom: ${theme.spacing(2)};
line-height: 1.35;
`;
const NavMenuList = styled(NavigationMenu.List)`
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
list-style: none;
margin: 0;
padding: 0;
`;
const NavLinkHoverIcon = styled.span`
display: inline-flex;
flex-shrink: 0;
min-width: 0;
opacity: 0;
overflow: hidden;
transition:
width 0.3s ease-out,
opacity 0.3s ease-out;
width: 0;
`;
const NavLink = styled(NavigationMenu.Link)`
align-items: center;
color: ${theme.colors.primary.text[100]};
display: flex;
font-size: ${theme.font.size(4)};
gap: 0;
text-decoration: none;
transition: gap 0.3s ease-out;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
@media (min-width: ${theme.breakpoints.md}px) {
&:hover {
gap: ${theme.spacing(2)};
}
&:hover ${NavLinkHoverIcon} {
opacity: 1;
width: 14px;
}
}
`;
const Actions = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${theme.spacing(2)};
margin-top: ${theme.spacing(8)};
& > * {
width: 126px;
}
`;
export function Nav() {
return (
<NavigationMenu.Root render={<FooterNav />}>
{FOOTER_NAV_GROUPS.map((group, index) => (
<React.Fragment key={group.id}>
{index > 0 && (
<NavDivider role="separator">
<PlusIcon
size={12}
strokeColor={theme.colors.highlight[100]}
aria-hidden
/>
<NavDividerLine aria-hidden />
<PlusIcon
size={12}
strokeColor={theme.colors.highlight[100]}
aria-hidden
/>
</NavDivider>
)}
<NavGroup aria-labelledby={group.id}>
<NavGroupTitle id={group.id}>{group.title}</NavGroupTitle>
<NavMenuList>
{group.links.map((link) => (
<NavigationMenu.Item key={link.href + link.label}>
<NavLink
render={
link.external ? (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
/>
) : (
<Link href={link.href} />
)
}
>
<NavLinkHoverIcon aria-hidden>
<RectangleFillIcon
size={14}
fillColor={theme.colors.secondary.background[100]}
/>
</NavLinkHoverIcon>
{link.label}
</NavLink>
</NavigationMenu.Item>
))}
</NavMenuList>
{group.showActions && (
<Actions>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Talk to us"
type="anchor"
variant="contained"
/>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="outlined"
/>
</Actions>
)}
</NavGroup>
</React.Fragment>
))}
</NavigationMenu.Root>
);
}
@@ -0,0 +1,68 @@
import { Container } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import { FooterShape } from '../../FooterShape';
const FooterRoot = styled.footer`
background-color: ${theme.colors.secondary.background[100]};
width: 100%;
`;
const FooterContainer = styled(Container)`
display: flex;
flex-direction: column;
height: 1280px;
position: relative;
padding-bottom: ${theme.spacing(4)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
@media (min-width: ${theme.breakpoints.lg}px) {
padding-bottom: ${theme.spacing(10)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
}
`;
const FooterBackground = styled.div`
position: absolute;
z-index: 0;
`;
const FooterContent = styled.div`
margin-top: auto;
padding-bottom: ${theme.spacing(12)};
padding-top: ${theme.spacing(12)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
position: relative;
z-index: 1;
@media (min-width: ${theme.breakpoints.md}px) {
padding-left: ${theme.spacing(20)};
padding-right: ${theme.spacing(20)};
padding-top: ${theme.spacing(20)};
}
@media (min-width: ${theme.breakpoints.lg}px) {
padding-left: ${theme.spacing(30)};
padding-right: ${theme.spacing(30)};
}
`;
type RootProps = { children: ReactNode };
export function Root({ children }: RootProps) {
return (
<FooterRoot>
<FooterContainer>
<FooterBackground aria-hidden />
<FooterContent>
<FooterShape fillColor={theme.colors.primary.background[100]} />
{children}
</FooterContent>
</FooterContainer>
</FooterRoot>
);
}
@@ -0,0 +1,74 @@
import { ArrowRightUpIcon } from '@/icons';
import { SOCIAL_LINKS } from '@/sections/Footer/constants/social-links';
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;
gap: ${theme.spacing(6)};
grid-column: 1 / -1;
grid-row: 1;
@media (min-width: ${theme.breakpoints.md}px) {
grid-column: 2;
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;
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
gap: ${theme.spacing(3)};
line-height: 14px;
text-decoration: none;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
export function Social() {
return (
<SocialContainer aria-label="Social media">
{SOCIAL_LINKS.filter((item) => item.showInDrawer).map((item, index) => (
<React.Fragment key={item.href}>
{index > 0 && <SocialDivider orientation="vertical" />}
<SocialItem
href={item.href}
target="_blank"
rel="noopener noreferrer"
aria-label={item.ariaLabel}
>
<item.icon
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>
);
}
@@ -0,0 +1,7 @@
import { Bottom } from './Bottom/Bottom';
import { Logo } from './Logo/Logo';
import { Nav } from './Nav/Nav';
import { Root } from './Root/Root';
import { Social } from './Social/Social';
export const Footer = { Root, Logo, Nav, Social, Bottom };
@@ -0,0 +1,47 @@
export const FOOTER_NAV_GROUPS = [
{
id: "footer-sitemap",
title: "Sitemap",
showActions: false,
links: [
{ label: "Home", href: "/", external: false },
{ label: "Product", href: "/product", external: false },
{ label: "Pricing", href: "/pricing", external: false },
{ label: "Partners", href: "/partner", external: false },
{ label: "Why Twenty", href: "/why-twenty", external: false },
],
},
{
id: "footer-help",
title: "Help",
showActions: false,
links: [
{ label: "Developers", href: "#", external: false },
{ label: "FAQ", href: "#", external: false },
{ label: "Support", href: "#", external: false },
{ label: "Release Notes", href: "#", external: false },
],
},
{
id: "footer-legal",
title: "Legal",
showActions: false,
links: [
{ label: "Cookie Policy", href: "#", external: false },
{ label: "Privacy Policy", href: "#", external: false },
{ label: "Terms and Conditions", href: "#", external: false },
],
},
{
id: "footer-connect",
title: "Connect",
showActions: true,
links: [
{
label: "LinkedIn",
href: "https://www.linkedin.com/company/twenty",
external: true,
},
],
},
];
@@ -0,0 +1,6 @@
export const NAV_ITEMS = [
{ label: 'Product', href: '/product' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Partners', href: '/partner' },
{ label: 'Why Twenty', href: '/why-twenty' },
];
@@ -0,0 +1,37 @@
import { DiscordIcon, GitHubIcon, LinkedInIcon, XIcon } from "@/icons"
export const SOCIAL_LINKS = [
{
href: "https://github.com/twentyhq/twenty",
icon: GitHubIcon,
label: "39.8K",
ariaLabel: "GitHub (opens in new tab)",
showInDesktop: true,
showInDrawer: true,
},
{
href: "https://discord.gg/cx5n4Jzs57",
icon: DiscordIcon,
label: "5.6K",
ariaLabel: "Discord (opens in new tab)",
className: "discord-link",
showInDesktop: true,
showInDrawer: true,
},
{
href: "https://www.linkedin.com/company/twenty",
icon: LinkedInIcon,
label: undefined,
ariaLabel: "LinkedIn (opens in new tab)",
showInDesktop: false,
showInDrawer: true,
},
{
href: "https://x.com/twentycrm",
icon: XIcon,
label: undefined,
ariaLabel: "X (opens in new tab)",
showInDesktop: false,
showInDrawer: true,
},
]
@@ -0,0 +1,46 @@
import {
Body as BaseBody,
type BodyProps,
} from '@/design-system/components/Body/Body';
import { Pages } from '@/enums/pages';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const BodyContainer = styled.div`
color: ${theme.colors.primary.text[60]};
margin-top: calc(${theme.spacing(2)} - ${theme.spacing(6)});
width: 360px;
@media (min-width: ${theme.breakpoints.md}px) {
&[data-page=${Pages.Home}] {
width: 591px;
}
&[data-page=${Pages.Partner}] {
width: 500px;
}
&[data-page=${Pages.Pricing}] {
width: 500px;
}
&[data-page=${Pages.Product}] {
width: 591px;
}
&[data-page=${Pages.WhyTwenty}] {
width: 443px;
color: ${theme.colors.secondary.text[100]};
}
}
`;
export type HeroBodyProps = BodyProps & { page: Pages };
export function Body({ page, ...bodyProps }: HeroBodyProps) {
return (
<BodyContainer data-page={page}>
<BaseBody {...bodyProps} />
</BodyContainer>
);
}
@@ -0,0 +1,16 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { ReactNode } from 'react';
const CTAsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(4)};
justify-content: center;
`;
export type HeroCtaProps = { children: ReactNode };
export function Cta({ children }: HeroCtaProps) {
return <CTAsContainer>{children}</CTAsContainer>;
}
@@ -0,0 +1,35 @@
import {
Heading as BaseHeading,
type HeadingProps,
} from '@/design-system/components/Heading/Heading';
import { Pages } from '@/enums/pages';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const HeadingContainer = styled.div`
width: 360px;
word-wrap: break-word;
@media (min-width: ${theme.breakpoints.md}px) {
width: 672px;
&[data-page=${Pages.WhyTwenty}] {
color: ${theme.colors.secondary.text[100]};
}
}
`;
export type HeroHeadingProps = HeadingProps & { page: Pages };
export function Heading({
page,
size = 'lg',
weight = 'light',
...props
}: HeroHeadingProps) {
return (
<HeadingContainer data-page={page}>
<BaseHeading size={size} weight={weight} {...props} />
</HeadingContainer>
);
}
@@ -0,0 +1,55 @@
import { Image } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const StyledHomeVisual = styled.div`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
margin-top: ${theme.spacing(5)};
position: relative;
right: calc(${theme.spacing(2)} - ${theme.spacing(6)});
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
height: 500px;
margin-top: ${theme.spacing(17.5)};
overflow: hidden;
right: 0;
}
`;
const StyledBackgroundImage = styled(Image)`
grid-area: 1 / 1;
justify-self: end;
width: 98%;
@media (min-width: ${theme.breakpoints.md}px) {
width: 850px;
}
`;
const StyledForegroundImage = styled(Image)`
grid-area: 1 / 1;
justify-self: end;
margin-right: ${theme.spacing(6.5)};
margin-top: ${theme.spacing(20)};
width: 98%;
z-index: 1;
@media (min-width: ${theme.breakpoints.md}px) {
justify-self: center;
margin-right: ${theme.spacing(40)};
margin-top: ${theme.spacing(17.5)};
width: 800px;
}
`;
export function HomeVisual() {
return (
<StyledHomeVisual>
<StyledBackgroundImage src="/images/home/hero/background.png" alt="" />
<StyledForegroundImage src="/images/home/hero/foreground.png" alt="" />
</StyledHomeVisual>
);
}
@@ -0,0 +1,37 @@
import { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const StyledIllustrationContainer = styled.div`
margin-top: ${theme.spacing(6)};
width: 100%;
`;
type IllustrationProps = IllustrationType & {
backgroundColor: string;
};
export function Illustration({
src,
title,
backgroundColor,
}: IllustrationProps) {
return (
<StyledIllustrationContainer>
<iframe
src={src}
title={title}
allow="clipboard-write; encrypted-media; gyroscope; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
style={{
width: '100%',
height: '462px',
border: 'none',
backgroundColor: backgroundColor,
display: 'block',
}}
/>
</StyledIllustrationContainer>
);
}
@@ -0,0 +1,35 @@
import { Container } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const StyledSection = styled.section`
width: 100%;
`;
const StyledContainer = styled(Container)`
display: grid;
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
padding-top: ${theme.spacing(7.5)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
row-gap: ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-top: ${theme.spacing(12)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
}
`;
type RootProps = { backgroundColor: string; children: ReactNode };
export function Root({ backgroundColor, children }: RootProps) {
return (
<StyledSection style={{ backgroundColor }}>
<StyledContainer>{children}</StyledContainer>
</StyledSection>
);
}
@@ -0,0 +1,42 @@
import { Image } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const VisualContainer = styled.div`
border-radius: ${theme.radius(1)};
height: 462px;
overflow: hidden;
position: relative;
width: 100%;
`;
const StyledBackground = styled(Image)`
aspect-ratio: auto;
height: 100%;
`;
const StyledIframe = styled.iframe`
border: none;
height: 200%;
mix-blend-mode: lighten;
position: absolute;
top: 50%;
left: 51.5%;
transform: translate(-50%, -50%);
width: 200%;
`;
export function WhyTwentyVisual() {
return (
<VisualContainer>
<StyledBackground src="/images/why-twenty/hero/background.png" alt="" />
<StyledIframe
src="https://app.endlesstools.io/embed/479566da-0f55-44ef-99fe-90350aa88b9c"
title="Endless Tools Editor"
allow="clipboard-write; encrypted-media; gyroscope; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</VisualContainer>
);
}
@@ -0,0 +1,17 @@
import { Body } from './Body/Body';
import { Cta } from './Cta/Cta';
import { Heading } from './Heading/Heading';
import { HomeVisual } from './HomeVisual/HomeVisual';
import { Illustration } from './Illustration/Illustration';
import { Root } from './Root/Root';
import { WhyTwentyVisual } from './WhyTwentyVisual/WhyTwentyVisual';
export const Hero = {
Root,
Heading,
Body,
Cta,
Illustration,
HomeVisual,
WhyTwentyVisual,
};
@@ -0,0 +1,7 @@
import { BodyType } from '@/design-system/components/Body/types/Body';
import { HeadingType } from '@/design-system/components/Heading/types/Heading';
export type HeroDataType = {
heading: HeadingType[];
body: BodyType;
};
@@ -0,0 +1 @@
export type { HeroDataType } from './HeroData';
@@ -0,0 +1,34 @@
import { LinkButton } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const CtaContainer = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
display: flex;
gap: ${theme.spacing(2)};
}
`;
export function Cta() {
return (
<CtaContainer>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Log in"
type="anchor"
variant="outlined"
/>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
</CtaContainer>
);
}
@@ -0,0 +1,166 @@
import { LinkButton } from '@/design-system/components';
import { ArrowRightUpIcon } from '@/icons';
import { NAV_ITEMS } from '@/sections/Menu/constants/nav-items';
import { SOCIAL_LINKS } from '@/sections/Menu/constants/social-links';
import { theme } from '@/theme';
import { Drawer } from '@base-ui/react/drawer';
import { Separator } from '@base-ui/react/separator';
import { styled } from '@linaria/react';
import Link from 'next/link';
import React from 'react';
const DrawerPopup = styled(Drawer.Popup)`
background: ${theme.colors.primary.background[100]};
display: flex;
flex-direction: column;
height: 100vh;
left: 0;
overflow-y: auto;
padding-bottom: ${theme.spacing(4)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(35)};
position: fixed;
top: 0;
width: 100vw;
z-index: 90;
`;
const NavigationContainer = styled.nav`
align-items: stretch;
display: flex;
flex: 1;
flex-direction: column;
gap: ${theme.spacing(8)};
justify-content: center;
width: 100%;
`;
const NavItem = styled(Link)`
border-radius: ${theme.radius(2)};
color: ${theme.colors.primary.text[100]};
display: block;
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(8)};
font-weight: ${theme.font.weight.light};
letter-spacing: 0;
line-height: 38px;
text-decoration: none;
text-transform: uppercase;
width: 100%;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
const HorizontalSeparator = styled(Separator)`
background: repeating-linear-gradient(
90deg,
${theme.colors.primary.border[60]} 0,
${theme.colors.primary.border[60]} 1px,
transparent 2px,
transparent 4px
);
border: none;
height: 1px;
width: 100%;
`;
const CtaContainer = styled.div`
margin-bottom: ${theme.spacing(10)};
`;
const SocialContainer = styled.div`
align-items: center;
display: flex;
gap: ${theme.spacing(6)};
`;
const SocialItem = styled.a`
align-items: center;
border-radius: ${theme.radius(1)};
color: ${theme.colors.primary.text[100]};
display: flex;
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
gap: ${theme.spacing(3)};
line-height: 14px;
text-decoration: none;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
const Divider = styled(Separator)`
border-left: 1px solid ${theme.colors.primary.border[40]};
height: 10px;
width: 0px;
`;
export function MenuDrawer() {
return (
<Drawer.Portal>
<DrawerPopup aria-label="Navigation menu">
<NavigationContainer aria-label="Mobile navigation">
{NAV_ITEMS.map((item, index) => (
<React.Fragment key={item.href}>
<Drawer.Close
nativeButton={false}
render={<NavItem href={item.href} />}
>
{item.label}
</Drawer.Close>
{index < NAV_ITEMS.length - 1 && (
<HorizontalSeparator orientation="horizontal" />
)}
</React.Fragment>
))}
</NavigationContainer>
<CtaContainer>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Log in"
type="anchor"
variant="outlined"
/>
</CtaContainer>
<SocialContainer>
{SOCIAL_LINKS.filter((item) => item.showInDrawer).map(
(item, index) => (
<React.Fragment key={item.href}>
{index > 0 && <Divider orientation="vertical" />}
<SocialItem
href={item.href}
target="_blank"
rel="noopener noreferrer"
aria-label={item.ariaLabel}
>
<item.icon
size={14}
fillColor={theme.colors.secondary.background[100]}
aria-hidden="true"
/>
{item.label}
{item.label && (
<ArrowRightUpIcon
size={8}
strokeColor={theme.colors.highlight[100]}
aria-hidden="true"
/>
)}
</SocialItem>
</React.Fragment>
),
)}
</SocialContainer>
</DrawerPopup>
</Drawer.Portal>
);
}
@@ -0,0 +1,37 @@
import { Logo as LogoIcon } from '@/icons';
import { theme } from '@/theme';
import { Drawer } from '@base-ui/react/drawer';
import { styled } from '@linaria/react';
import Link from 'next/link';
const LogoContainer = styled.div`
align-items: center;
display: flex;
`;
const LogoLink = styled(Link)`
display: flex;
text-decoration: none;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
export function Logo() {
return (
<LogoContainer>
<Drawer.Close
nativeButton={false}
render={<LogoLink href="/" aria-label="Home" />}
>
<LogoIcon
size={40}
fillColor={theme.colors.primary.background[100]}
backgroundColor={theme.colors.secondary.background[100]}
/>
</Drawer.Close>
</LogoContainer>
);
}
@@ -0,0 +1,62 @@
import { NAV_ITEMS } from '@/sections/Menu/constants/nav-items';
import { theme } from '@/theme';
import { NavigationMenu } from '@base-ui/react/navigation-menu';
import { Separator } from '@base-ui/react/separator';
import { styled } from '@linaria/react';
import Link from 'next/link';
import React from 'react';
const NavList = styled(NavigationMenu.List)`
display: none;
list-style: none;
margin: 0;
padding: 0;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
display: flex;
gap: ${theme.spacing(8)};
}
`;
const NavLink = styled(NavigationMenu.Link)`
color: ${theme.colors.primary.text[100]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
letter-spacing: 0;
text-decoration: none;
text-transform: uppercase;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
const Divider = styled(Separator)`
border-left: 1px solid ${theme.colors.primary.border[40]};
height: 10px;
width: 0px;
`;
export function Nav() {
return (
<NavigationMenu.Root render={<div />}>
<NavList>
{NAV_ITEMS.map((item, index) => (
<React.Fragment key={item.href}>
<NavigationMenu.Item>
<NavLink render={<Link href={item.href} />}>
{item.label}
</NavLink>
</NavigationMenu.Item>
{index < NAV_ITEMS.length - 1 && (
<Divider orientation="vertical" />
)}
</React.Fragment>
))}
</NavList>
</NavigationMenu.Root>
);
}
@@ -0,0 +1,87 @@
'use client';
import { Container, IconButton, LinkButton } from '@/design-system/components';
import { CloseIcon, MenuIcon } from '@/icons';
import { theme } from '@/theme';
import { Drawer } from '@base-ui/react/drawer';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { CloseDrawerWhenNavigationExpandsEffect } from '../../effect-components/CloseDrawerWhenNavigationExpandsEffect';
import { MenuDrawer } from '../Drawer/Drawer';
const StyledMenuContainer = styled(Container)`
margin-top: ${theme.spacing(4)};
position: relative;
z-index: 100;
`;
const StyledNav = styled.nav`
align-items: center;
background-color: ${theme.colors.primary.background[100]};
border-radius: ${theme.radius(2)};
display: flex;
height: 48px;
justify-content: space-between;
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
width: 100%;
@media (min-width: ${theme.breakpoints.lg}px) {
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
}
`;
const MobileRightContainer = styled.div`
align-items: center;
display: flex;
gap: ${theme.spacing(1)};
@media (min-width: ${theme.breakpoints.md}px) {
display: none;
}
`;
type RootProps = { children: ReactNode };
export function Root({ children }: RootProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
return (
<Drawer.Root
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
swipeDirection="down"
>
<CloseDrawerWhenNavigationExpandsEffect
onClose={() => setIsDrawerOpen(false)}
/>
<StyledMenuContainer>
<StyledNav aria-label="Primary navigation">
{children}
<MobileRightContainer>
<LinkButton
color="secondary"
href="https://app.twenty.com/welcome"
label="Get started"
type="anchor"
variant="contained"
/>
<IconButton
icon={isDrawerOpen ? CloseIcon : MenuIcon}
ariaLabel={isDrawerOpen ? 'Close menu' : 'Open menu'}
borderColor={theme.colors.primary.border[20]}
iconFillColor="none"
iconSize={14}
iconStrokeColor={theme.colors.primary.text[100]}
size={40}
onClick={() => setIsDrawerOpen((prev) => !prev)}
/>
</MobileRightContainer>
</StyledNav>
</StyledMenuContainer>
<MenuDrawer />
</Drawer.Root>
);
}
@@ -0,0 +1,88 @@
import { ArrowRightUpIcon } from '@/icons';
import { SOCIAL_LINKS } from '@/sections/Menu/constants/social-links';
import { theme } from '@/theme';
import { Separator } from '@base-ui/react/separator';
import { styled } from '@linaria/react';
const SocialContainer = styled.nav`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
display: flex;
gap: ${theme.spacing(5)};
}
`;
const SocialLinkItem = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
display: flex;
gap: ${theme.spacing(5)};
&.discord-link {
display: none;
}
}
@media (min-width: ${theme.breakpoints.lg}px) {
&.discord-link {
display: flex;
}
}
`;
const SocialLink = styled.a`
align-items: center;
border-radius: ${theme.radius(1)};
color: ${theme.colors.primary.text[100]};
display: flex;
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
gap: ${theme.spacing(2)};
letter-spacing: 0;
text-decoration: none;
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
const Divider = styled(Separator)`
border-left: 1px solid ${theme.colors.primary.border[40]};
height: 10px;
width: 0px;
`;
export function Social() {
return (
<SocialContainer aria-label="Social media">
{SOCIAL_LINKS.filter((item) => item.showInDesktop).map((item, index) => (
<SocialLinkItem key={item.href} className={item.className}>
{index > 0 && <Divider orientation="vertical" />}
<SocialLink
href={item.href}
target="_blank"
rel="noopener noreferrer"
aria-label={item.ariaLabel}
>
<item.icon
size={14}
fillColor={theme.colors.secondary.background[100]}
aria-hidden="true"
/>
{item.label}
<ArrowRightUpIcon
size={8}
strokeColor={theme.colors.highlight[100]}
aria-hidden="true"
/>
</SocialLink>
</SocialLinkItem>
))}
</SocialContainer>
);
}
@@ -0,0 +1,7 @@
import { Cta } from './Cta/Cta';
import { Logo } from './Logo/Logo';
import { Nav } from './Nav/Nav';
import { Root } from './Root/Root';
import { Social } from './Social/Social';
export const Menu = { Root, Logo, Nav, Social, Cta };
@@ -0,0 +1,6 @@
export const NAV_ITEMS = [
{ label: 'Product', href: '/product' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Partners', href: '/partner' },
{ label: 'Why Twenty', href: '/why-twenty' },
];
@@ -0,0 +1,37 @@
import { DiscordIcon, GitHubIcon, LinkedInIcon, XIcon } from "@/icons"
export const SOCIAL_LINKS = [
{
href: "https://github.com/twentyhq/twenty",
icon: GitHubIcon,
label: "39.8K",
ariaLabel: "GitHub (opens in new tab)",
showInDesktop: true,
showInDrawer: true,
},
{
href: "https://discord.gg/cx5n4Jzs57",
icon: DiscordIcon,
label: "5.6K",
ariaLabel: "Discord (opens in new tab)",
className: "discord-link",
showInDesktop: true,
showInDrawer: true,
},
{
href: "https://www.linkedin.com/company/twenty",
icon: LinkedInIcon,
label: undefined,
ariaLabel: "LinkedIn (opens in new tab)",
showInDesktop: false,
showInDrawer: true,
},
{
href: "https://x.com/twentycrm",
icon: XIcon,
label: undefined,
ariaLabel: "X (opens in new tab)",
showInDesktop: false,
showInDrawer: true,
},
]
@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
import { theme } from "@/theme";
interface CloseDrawerWhenNavigationExpandsEffectProps {
onClose: () => void;
}
/**
* Subscribes to viewport changes and calls onClose whenever the layout
* crosses into the desktop breakpoint, so the drawer closes when the full
* navigation is shown.
*/
export function CloseDrawerWhenNavigationExpandsEffect({
onClose,
}: CloseDrawerWhenNavigationExpandsEffectProps) {
useEffect(() => {
const mediaQuery = window.matchMedia(`(min-width: ${theme.breakpoints.md}px)`);
const handleChange = () => {
if (mediaQuery.matches) onClose();
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [onClose]);
return null;
}

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