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:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+3
-3
@@ -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
-1
@@ -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' } },
|
||||
+2
-2
@@ -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
-1
@@ -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: {
|
||||
+7
-7
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-4
@@ -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.',
|
||||
},
|
||||
},
|
||||
{
|
||||
+4
-16
@@ -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';
|
||||
-2
@@ -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
-1
@@ -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' },
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import { SignoffDataType } from '@/sections/Signoff/types';
|
||||
import type { SignoffDataType } from '@/sections/Signoff/types';
|
||||
|
||||
export const SIGNOFF_DATA: SignoffDataType = {
|
||||
heading: [
|
||||
+2
-2
@@ -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',
|
||||
},
|
||||
};
|
||||
+7
-7
@@ -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
-1
@@ -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';
|
||||
+4
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -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';
|
||||
+7
-7
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -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
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import { SignoffDataType } from '@/sections/Signoff/types';
|
||||
import type { SignoffDataType } from '@/sections/Signoff/types';
|
||||
|
||||
export const SIGNOFF_DATA: SignoffDataType = {
|
||||
heading: [
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
|
||||
+13
@@ -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%;
|
||||
}
|
||||
`;
|
||||
+142
@@ -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>
|
||||
);
|
||||
}
|
||||
+132
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -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
Reference in New Issue
Block a user