[Website] Reintroduce the product page. (#20349)
This PR re-introduces the Product page that we decided to hold back in the first release. Subsequent PRs will work on polishing the Product page to have interactive visuals, but merging the static version to record a snapshot. No production deployments are planned until product page is polished and blog has the required content - we'd push to prod next week with these two checkpoints.
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
|
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
|
||||||
|
|
||||||
<a href="https://twenty.com/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
|
|||||||
@@ -114,9 +114,14 @@ const nextConfig: LinariaConfig = {
|
|||||||
destination: 'https://docs.twenty.com/twenty-ui/:slug',
|
destination: 'https://docs.twenty.com/twenty-ui/:slug',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/why-twenty',
|
||||||
|
destination: '/resources/why-twenty',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/story',
|
source: '/story',
|
||||||
destination: '/why-twenty',
|
destination: '/resources/why-twenty',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ImageType } from '@/design-system/components/Image';
|
||||||
|
|
||||||
|
export const DEMO_DATA: { image: ImageType } = {
|
||||||
|
image: {
|
||||||
|
src: '/images/product/demo/kanban.webp',
|
||||||
|
alt: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,10 +3,11 @@ import { FAQ_DATA } from '@/sections/Faq/data';
|
|||||||
import { MENU_DATA } from '@/sections/Menu/data';
|
import { MENU_DATA } from '@/sections/Menu/data';
|
||||||
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
|
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
|
||||||
import { TalkToUsButton } from '@/lib/contact-cal';
|
import { TalkToUsButton } from '@/lib/contact-cal';
|
||||||
|
import { DEMO_DATA } from '@/app/[locale]/product/demo.data';
|
||||||
import { FEATURE_DATA } from '@/app/[locale]/product/feature.data';
|
import { FEATURE_DATA } from '@/app/[locale]/product/feature.data';
|
||||||
import { HERO_COPY } from '@/app/[locale]/product/hero.data';
|
import { HERO_COPY } from '@/app/[locale]/product/hero.data';
|
||||||
import { SIGNOFF_COPY } from '@/app/[locale]/product/signoff.data';
|
|
||||||
import { STEPPER_DATA } from '@/app/[locale]/product/stepper.data';
|
import { STEPPER_DATA } from '@/app/[locale]/product/stepper.data';
|
||||||
|
import { TABS_DATA } from '@/app/[locale]/product/tabs.data';
|
||||||
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/product/three-cards.data';
|
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/product/three-cards.data';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@@ -27,8 +28,9 @@ import { Faq } from '@/sections/Faq/components';
|
|||||||
import { Feature } from '@/sections/Feature/components';
|
import { Feature } from '@/sections/Feature/components';
|
||||||
import { Hero } from '@/sections/Hero/components';
|
import { Hero } from '@/sections/Hero/components';
|
||||||
import { Menu } from '@/sections/Menu/components';
|
import { Menu } from '@/sections/Menu/components';
|
||||||
|
import { Demo } from '@/sections/Demo/components';
|
||||||
import { ProductStepper } from '@/sections/ProductStepper/components';
|
import { ProductStepper } from '@/sections/ProductStepper/components';
|
||||||
import { Signoff } from '@/sections/Signoff/components';
|
import { Tabs } from '@/sections/Tabs/components';
|
||||||
import { ThreeCards } from '@/sections/ThreeCards/components';
|
import { ThreeCards } from '@/sections/ThreeCards/components';
|
||||||
import { TrustedBy } from '@/sections/TrustedBy/components';
|
import { TrustedBy } from '@/sections/TrustedBy/components';
|
||||||
import { theme } from '@/theme';
|
import { theme } from '@/theme';
|
||||||
@@ -113,6 +115,32 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
/>
|
/>
|
||||||
</TrustedBy.Root>
|
</TrustedBy.Root>
|
||||||
|
|
||||||
|
<Tabs.Root>
|
||||||
|
<Eyebrow
|
||||||
|
colorScheme="secondary"
|
||||||
|
heading={TABS_DATA.eyebrow.heading}
|
||||||
|
renderText={renderText}
|
||||||
|
/>
|
||||||
|
<Heading size="lg" weight="light">
|
||||||
|
<HeadingPart fontFamily="serif">
|
||||||
|
{renderText(msg`AI that actually`)}
|
||||||
|
</HeadingPart>
|
||||||
|
<br />
|
||||||
|
<HeadingPart fontFamily="serif">
|
||||||
|
{renderText(msg`helps you`)}
|
||||||
|
</HeadingPart>{' '}
|
||||||
|
<HeadingPart fontFamily="sans">
|
||||||
|
{renderText(msg`work faster`)}
|
||||||
|
</HeadingPart>
|
||||||
|
</Heading>
|
||||||
|
<Body
|
||||||
|
body={{ text: msg`The AI understands your CRM and takes action.` }}
|
||||||
|
renderText={renderText}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Tabs.TabGroup tabs={TABS_DATA.tabs} />
|
||||||
|
</Tabs.Root>
|
||||||
|
|
||||||
<Feature.Root backgroundColor={theme.colors.primary.background[100]}>
|
<Feature.Root backgroundColor={theme.colors.primary.background[100]}>
|
||||||
<Feature.Intro align="center" page={Pages.Product}>
|
<Feature.Intro align="center" page={Pages.Product}>
|
||||||
<Eyebrow
|
<Eyebrow
|
||||||
@@ -177,42 +205,34 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</HeadingPart>
|
</HeadingPart>
|
||||||
</ProductStepper.Flow>
|
</ProductStepper.Flow>
|
||||||
|
|
||||||
<Signoff.Root
|
<Demo.Root>
|
||||||
backgroundColor={theme.colors.secondary.background[5]}
|
<Eyebrow
|
||||||
color={theme.colors.primary.text[100]}
|
colorScheme="primary"
|
||||||
page={Pages.Product}
|
heading={{
|
||||||
>
|
text: msg`Try it live`,
|
||||||
<Signoff.Heading page={Pages.Product}>
|
fontFamily: 'sans',
|
||||||
<HeadingPart fontFamily="serif">
|
}}
|
||||||
{renderText(msg`Ready to grow`)}
|
|
||||||
</HeadingPart>
|
|
||||||
<br />
|
|
||||||
<HeadingPart fontFamily="serif">
|
|
||||||
{renderText(msg`with`)}
|
|
||||||
</HeadingPart>{' '}
|
|
||||||
<HeadingPart fontFamily="sans">
|
|
||||||
{renderText(msg`Twenty?`)}
|
|
||||||
</HeadingPart>
|
|
||||||
</Signoff.Heading>
|
|
||||||
<Signoff.Body
|
|
||||||
body={{ text: SIGNOFF_COPY.body }}
|
|
||||||
page={Pages.Product}
|
|
||||||
renderText={renderText}
|
renderText={renderText}
|
||||||
/>
|
/>
|
||||||
<Signoff.Cta>
|
<Heading size="lg" weight="light">
|
||||||
|
<HeadingPart fontFamily="serif">
|
||||||
|
{renderText(msg`A demo worth a`)}
|
||||||
|
</HeadingPart>
|
||||||
|
<br />
|
||||||
|
<HeadingPart fontFamily="sans">
|
||||||
|
{renderText(msg`thousand words`)}
|
||||||
|
</HeadingPart>
|
||||||
|
</Heading>
|
||||||
|
<Demo.Cta>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
color="secondary"
|
color="secondary"
|
||||||
href="https://app.twenty.com/welcome"
|
href="https://app.twenty.com/welcome"
|
||||||
label={renderText(msg`Get started`)}
|
label={renderText(msg`Try Twenty Cloud`)}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
/>
|
/>
|
||||||
<TalkToUsButton
|
</Demo.Cta>
|
||||||
color="secondary"
|
<Demo.Screenshot image={DEMO_DATA.image} />
|
||||||
label={msg`Talk to us`}
|
</Demo.Root>
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Signoff.Cta>
|
|
||||||
</Signoff.Root>
|
|
||||||
|
|
||||||
<Faq.Root>
|
<Faq.Root>
|
||||||
<Faq.Intro>
|
<Faq.Intro>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
|
import type { TabType } from '@/sections/Tabs/types';
|
||||||
|
|
||||||
|
export const TABS_DATA: {
|
||||||
|
eyebrow: { heading: { text: ReturnType<typeof msg>; fontFamily: 'sans' } };
|
||||||
|
tabs: TabType[];
|
||||||
|
} = {
|
||||||
|
eyebrow: {
|
||||||
|
heading: {
|
||||||
|
text: msg`AI & Automation`,
|
||||||
|
fontFamily: 'sans',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
body: msg`Show me all deals closing this month`,
|
||||||
|
icon: 'search',
|
||||||
|
image: {
|
||||||
|
src: '/images/product/tabs/deals.webp',
|
||||||
|
alt: 'Deals view',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: msg`Create follow-up tasks for my top 10 accounts`,
|
||||||
|
icon: 'eye',
|
||||||
|
image: {
|
||||||
|
src: '/images/product/tabs/tasks.webp',
|
||||||
|
alt: 'Tasks view',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: msg`Summarize this customer's history`,
|
||||||
|
icon: 'edit',
|
||||||
|
image: {
|
||||||
|
src: '/images/product/tabs/history.webp',
|
||||||
|
alt: 'History view',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: msg`Create a workflow that send an email sequence`,
|
||||||
|
icon: 'check',
|
||||||
|
image: {
|
||||||
|
src: '/images/product/tabs/workflow.webp',
|
||||||
|
alt: 'Workflow view',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
+6
-6
@@ -1,11 +1,11 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { MENU_DATA } from '@/sections/Menu/data';
|
import { MENU_DATA } from '@/sections/Menu/data';
|
||||||
import { EDITORIAL_FOUR } from '@/app/[locale]/why-twenty/editorial-four.data';
|
import { EDITORIAL_FOUR } from '@/app/[locale]/resources/why-twenty/editorial-four.data';
|
||||||
import { EDITORIAL_ONE } from '@/app/[locale]/why-twenty/editorial-one.data';
|
import { EDITORIAL_ONE } from '@/app/[locale]/resources/why-twenty/editorial-one.data';
|
||||||
import { EDITORIAL_THREE } from '@/app/[locale]/why-twenty/editorial-three.data';
|
import { EDITORIAL_THREE } from '@/app/[locale]/resources/why-twenty/editorial-three.data';
|
||||||
import { HERO_COPY } from '@/app/[locale]/why-twenty/hero.data';
|
import { HERO_COPY } from '@/app/[locale]/resources/why-twenty/hero.data';
|
||||||
import { MARQUEE_DATA } from '@/app/[locale]/why-twenty/marquee.data';
|
import { MARQUEE_DATA } from '@/app/[locale]/resources/why-twenty/marquee.data';
|
||||||
import { SIGNOFF_COPY } from '@/app/[locale]/why-twenty/signoff.data';
|
import { SIGNOFF_COPY } from '@/app/[locale]/resources/why-twenty/signoff.data';
|
||||||
import { HeadingPart, LinkButton } from '@/design-system/components';
|
import { HeadingPart, LinkButton } from '@/design-system/components';
|
||||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||||
@@ -34,12 +34,16 @@ describe('localizeHref', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('strips a redundant /en prefix when targeting the default locale', () => {
|
it('strips a redundant /en prefix when targeting the default locale', () => {
|
||||||
expect(localizeHref('en', '/en/why-twenty')).toBe('/why-twenty');
|
expect(localizeHref('en', '/en/resources/why-twenty')).toBe(
|
||||||
|
'/resources/why-twenty',
|
||||||
|
);
|
||||||
expect(localizeHref('en', '/en')).toBe('/');
|
expect(localizeHref('en', '/en')).toBe('/');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips an /en-prefixed path when targeting an unpublished locale', () => {
|
it('strips an /en-prefixed path when targeting an unpublished locale', () => {
|
||||||
expect(localizeHref('de-DE', '/en/why-twenty')).toBe('/why-twenty');
|
expect(localizeHref('de-DE', '/en/resources/why-twenty')).toBe(
|
||||||
|
'/resources/why-twenty',
|
||||||
|
);
|
||||||
expect(localizeHref('de-DE', '/en')).toBe('/');
|
expect(localizeHref('de-DE', '/en')).toBe('/');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +85,9 @@ describe('localizeHref', () => {
|
|||||||
|
|
||||||
describe('stripLocale', () => {
|
describe('stripLocale', () => {
|
||||||
it('removes a published-locale URL-segment prefix from the pathname', () => {
|
it('removes a published-locale URL-segment prefix from the pathname', () => {
|
||||||
expect(stripLocale('/en/why-twenty')).toBe('/why-twenty');
|
expect(stripLocale('/en/resources/why-twenty')).toBe(
|
||||||
|
'/resources/why-twenty',
|
||||||
|
);
|
||||||
expect(stripLocale('/fr/customers/9dots')).toBe('/customers/9dots');
|
expect(stripLocale('/fr/customers/9dots')).toBe('/customers/9dots');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ describe('stripLocale', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns the pathname unchanged when no published locale prefix is present', () => {
|
it('returns the pathname unchanged when no published locale prefix is present', () => {
|
||||||
expect(stripLocale('/why-twenty')).toBe('/why-twenty');
|
expect(stripLocale('/resources/why-twenty')).toBe('/resources/why-twenty');
|
||||||
expect(stripLocale('/')).toBe('/');
|
expect(stripLocale('/')).toBe('/');
|
||||||
expect(stripLocale('/fr-FR/foo')).toBe('/fr-FR/foo');
|
expect(stripLocale('/fr-FR/foo')).toBe('/fr-FR/foo');
|
||||||
expect(stripLocale('/zh-CN')).toBe('/zh-CN');
|
expect(stripLocale('/zh-CN')).toBe('/zh-CN');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const STATIC_WEBSITE_ROUTES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'whyTwenty',
|
id: 'whyTwenty',
|
||||||
path: '/why-twenty',
|
path: '/resources/why-twenty',
|
||||||
title: msg`Why Twenty — The Open Source CRM Built to Be Customised`,
|
title: msg`Why Twenty — The Open Source CRM Built to Be Customised`,
|
||||||
description: msg`Packaged CRMs make every company look the same. Twenty is the open source CRM teams shape around their workflow, with a modern UI and a developer-first platform.`,
|
description: msg`Packaged CRMs make every company look the same. Twenty is the open source CRM teams shape around their workflow, with a modern UI and a developer-first platform.`,
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { theme } from '@/theme';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
const CTAsContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: ${theme.spacing(4)};
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type CtaProps = { children: ReactNode };
|
||||||
|
|
||||||
|
export function Cta({ children }: CtaProps) {
|
||||||
|
return <CTAsContainer>{children}</CTAsContainer>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Container } from '@/design-system/components';
|
||||||
|
import { theme } from '@/theme';
|
||||||
|
import { css } from '@linaria/core';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
import NextImage from 'next/image';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
const RootWrapper = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BackgroundLayer = styled.div`
|
||||||
|
align-self: stretch;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PatternLayer = styled.div`
|
||||||
|
bottom: 0;
|
||||||
|
height: 60%;
|
||||||
|
left: 50%;
|
||||||
|
position: absolute;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const patternImageClassName = css`
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center top;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledSection = styled.section`
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledContainer = styled(Container)`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
padding-bottom: ${theme.spacing(16)};
|
||||||
|
padding-left: ${theme.spacing(4)};
|
||||||
|
padding-right: ${theme.spacing(4)};
|
||||||
|
padding-top: ${theme.spacing(16)};
|
||||||
|
row-gap: ${theme.spacing(6)};
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
|
padding-bottom: ${theme.spacing(24)};
|
||||||
|
padding-left: ${theme.spacing(10)};
|
||||||
|
padding-right: ${theme.spacing(10)};
|
||||||
|
padding-top: ${theme.spacing(24)};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type RootProps = { children: ReactNode };
|
||||||
|
|
||||||
|
export function Root({ children }: RootProps) {
|
||||||
|
return (
|
||||||
|
<RootWrapper>
|
||||||
|
<BackgroundLayer aria-hidden>
|
||||||
|
<PatternLayer>
|
||||||
|
<NextImage
|
||||||
|
alt=""
|
||||||
|
className={patternImageClassName}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 921px) 100vw"
|
||||||
|
src="/images/product/demo/background.webp"
|
||||||
|
/>
|
||||||
|
</PatternLayer>
|
||||||
|
</BackgroundLayer>
|
||||||
|
<StyledSection>
|
||||||
|
<StyledContainer>{children}</StyledContainer>
|
||||||
|
</StyledSection>
|
||||||
|
</RootWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ImageType } from '@/design-system/components/Image';
|
||||||
|
import { theme } from '@/theme';
|
||||||
|
import { css } from '@linaria/core';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
import NextImage from 'next/image';
|
||||||
|
|
||||||
|
const ScreenshotRoot = styled.div`
|
||||||
|
aspect-ratio: 136 / 69;
|
||||||
|
border-radius: ${theme.radius(4)};
|
||||||
|
margin-bottom: ${theme.spacing(11)};
|
||||||
|
margin-top: ${theme.spacing(12)};
|
||||||
|
max-width: 800px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
|
border-radius: ${theme.radius(6)};
|
||||||
|
margin-top: ${theme.spacing(19)};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const screenshotImageClassName = css`
|
||||||
|
object-fit: cover;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type ScreenshotProps = { image: ImageType };
|
||||||
|
|
||||||
|
export function Screenshot({ image }: ScreenshotProps) {
|
||||||
|
return (
|
||||||
|
<ScreenshotRoot>
|
||||||
|
<NextImage
|
||||||
|
alt={image.alt}
|
||||||
|
className={screenshotImageClassName}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 921px) 800px, 100vw"
|
||||||
|
src={image.src}
|
||||||
|
/>
|
||||||
|
</ScreenshotRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Cta } from './Cta';
|
||||||
|
import { Root } from './Root';
|
||||||
|
import { Screenshot } from './Screenshot';
|
||||||
|
|
||||||
|
export const Demo = { Cta, Root, Screenshot };
|
||||||
@@ -12,9 +12,14 @@ export const FOOTER_DATA: FooterDataType = {
|
|||||||
ctas: [],
|
ctas: [],
|
||||||
links: [
|
links: [
|
||||||
{ label: msg`Home`, href: '/', external: false },
|
{ label: msg`Home`, href: '/', external: false },
|
||||||
|
{ label: msg`Product`, href: '/product', external: false },
|
||||||
{ label: msg`Pricing`, href: '/pricing', external: false },
|
{ label: msg`Pricing`, href: '/pricing', external: false },
|
||||||
{ label: msg`Partners`, href: '/partners', external: false },
|
{ label: msg`Partners`, href: '/partners', external: false },
|
||||||
{ label: msg`Why Twenty`, href: '/why-twenty', external: false },
|
{
|
||||||
|
label: msg`Why Twenty`,
|
||||||
|
href: '/resources/why-twenty',
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
{ label: msg`Articles`, href: '/articles', external: false },
|
{ label: msg`Articles`, href: '/articles', external: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function buildNavItems(): MenuNavItemType[] {
|
|||||||
getLatestReleasePreview() ?? FALLBACK_RELEASES_PREVIEW;
|
getLatestReleasePreview() ?? FALLBACK_RELEASES_PREVIEW;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: msg`Why`, href: '/why-twenty' },
|
{ label: msg`Product`, href: '/product' },
|
||||||
{
|
{
|
||||||
label: msg`Resources`,
|
label: msg`Resources`,
|
||||||
children: [
|
children: [
|
||||||
@@ -55,6 +55,17 @@ function buildNavItems(): MenuNavItemType[] {
|
|||||||
description: msg`APIs, SDKs and webhooks to extend Twenty and ship apps on top of your CRM data.`,
|
description: msg`APIs, SDKs and webhooks to extend Twenty and ship apps on top of your CRM data.`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: msg`Why Twenty`,
|
||||||
|
description: msg`The open source CRM built to be customised`,
|
||||||
|
href: '/resources/why-twenty',
|
||||||
|
preview: {
|
||||||
|
image: '/images/why-twenty/hero/background.webp',
|
||||||
|
imageAlt: 'Why Twenty',
|
||||||
|
title: msg`Why Twenty`,
|
||||||
|
description: msg`Our story: building a CRM teams can truly own.`,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: msg`Partners`,
|
label: msg`Partners`,
|
||||||
description: msg`Find a Twenty partner`,
|
description: msg`Find a Twenty partner`,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Container } from '@/design-system/components';
|
||||||
|
import { theme } from '@/theme';
|
||||||
|
import { css } from '@linaria/core';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
import NextImage from 'next/image';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
const RootWrapper = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabBackgrounds = styled.div`
|
||||||
|
align-self: stretch;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
min-height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BackgroundShape = styled.div`
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ShapeClip = styled.div`
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const shapeFillClassName = css`
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center top;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PatternLayer = styled.div`
|
||||||
|
bottom: -81px;
|
||||||
|
height: 575px;
|
||||||
|
left: 50%;
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const patternImageClassName = css`
|
||||||
|
object-fit: cover;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledSection = styled.section`
|
||||||
|
align-self: start;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledContainer = styled(Container)`
|
||||||
|
color: ${theme.colors.secondary.text[100]};
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
padding-bottom: ${theme.spacing(16)};
|
||||||
|
padding-left: ${theme.spacing(4)};
|
||||||
|
padding-right: ${theme.spacing(4)};
|
||||||
|
padding-top: ${theme.spacing(16)};
|
||||||
|
row-gap: ${theme.spacing(6)};
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
|
padding-bottom: ${theme.spacing(40)};
|
||||||
|
padding-left: ${theme.spacing(10)};
|
||||||
|
padding-right: ${theme.spacing(10)};
|
||||||
|
padding-top: ${theme.spacing(40)};
|
||||||
|
row-gap: ${theme.spacing(6)};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type RootProps = { children: ReactNode };
|
||||||
|
|
||||||
|
export function Root({ children }: RootProps) {
|
||||||
|
return (
|
||||||
|
<RootWrapper>
|
||||||
|
<TabBackgrounds aria-hidden>
|
||||||
|
<BackgroundShape>
|
||||||
|
<ShapeClip>
|
||||||
|
<NextImage
|
||||||
|
alt=""
|
||||||
|
className={shapeFillClassName}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="100vw"
|
||||||
|
src="/images/product/tabs/background-shape.webp"
|
||||||
|
/>
|
||||||
|
</ShapeClip>
|
||||||
|
<PatternLayer>
|
||||||
|
<NextImage
|
||||||
|
alt=""
|
||||||
|
className={patternImageClassName}
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
src="/images/product/tabs/background.webp"
|
||||||
|
/>
|
||||||
|
</PatternLayer>
|
||||||
|
</BackgroundShape>
|
||||||
|
</TabBackgrounds>
|
||||||
|
<StyledSection>
|
||||||
|
<StyledContainer>{children}</StyledContainer>
|
||||||
|
</StyledSection>
|
||||||
|
</RootWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRenderMessage } from '@/lib/i18n/use-render-message';
|
||||||
|
import { INFORMATIVE_ICONS } from '@/icons';
|
||||||
|
import type { TabType } from '@/sections/Tabs/types';
|
||||||
|
import { theme } from '@/theme';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
|
||||||
|
const Label = styled.span`
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledButton = styled.button`
|
||||||
|
align-items: center;
|
||||||
|
border-radius: ${theme.radius(2)};
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
font-family: ${theme.font.family.sans};
|
||||||
|
font-size: ${theme.font.size(3.5)};
|
||||||
|
font-weight: ${theme.font.weight.regular};
|
||||||
|
gap: ${theme.spacing(2)};
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
line-height: ${theme.lineHeight(3.5)};
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: ${theme.spacing(2)};
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
background-image 0.2s ease,
|
||||||
|
border-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
|
||||||
|
&[data-active='true'] {
|
||||||
|
background-color: ${theme.colors.primary.background[100]};
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: ${theme.colors.primary.text[100]};
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active='false'] {
|
||||||
|
background-color: ${theme.colors.secondary.background[100]};
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0.1) 0%,
|
||||||
|
rgba(255, 255, 255, 0.1) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid ${theme.colors.secondary.border[10]};
|
||||||
|
color: ${theme.colors.secondary.text[100]};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 1px solid ${theme.colors.highlight[100]};
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabIconBox = styled.span`
|
||||||
|
border-radius: ${theme.radius(1)};
|
||||||
|
display: grid;
|
||||||
|
height: ${theme.spacing(6)};
|
||||||
|
overflow: hidden;
|
||||||
|
place-items: center;
|
||||||
|
width: ${theme.spacing(6)};
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
display: block;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active='true'] {
|
||||||
|
background-color: ${theme.colors.primary.text[10]};
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active='false'] {
|
||||||
|
background-color: ${theme.colors.highlight[100]};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type TabButtonProps = {
|
||||||
|
controls: string;
|
||||||
|
id: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
tab: TabType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TabButton({
|
||||||
|
controls,
|
||||||
|
id,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
tab,
|
||||||
|
}: TabButtonProps) {
|
||||||
|
const renderText = useRenderMessage();
|
||||||
|
|
||||||
|
const iconColor = isActive
|
||||||
|
? theme.colors.highlight[100]
|
||||||
|
: theme.colors.secondary.text[100];
|
||||||
|
|
||||||
|
const Icon = INFORMATIVE_ICONS[tab.icon as keyof typeof INFORMATIVE_ICONS];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledButton
|
||||||
|
aria-controls={controls}
|
||||||
|
aria-selected={isActive}
|
||||||
|
data-active={String(isActive)}
|
||||||
|
id={id}
|
||||||
|
onClick={onSelect}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Label>{renderText(tab.body)}</Label>
|
||||||
|
<TabIconBox data-active={String(isActive)}>
|
||||||
|
{Icon ? <Icon color={iconColor} size={16} /> : null}
|
||||||
|
</TabIconBox>
|
||||||
|
</StyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { TabType } from '@/sections/Tabs/types';
|
||||||
|
import { theme } from '@/theme';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
|
||||||
|
import { TabButton } from './TabButton';
|
||||||
|
|
||||||
|
const TabButtonsGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
gap: ${theme.spacing(2)};
|
||||||
|
grid-template-columns: minmax(0, max-content);
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, max-content));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: ${theme.breakpoints.lg}px) {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, max-content));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type TabButtonsProps = {
|
||||||
|
activeIndex: number;
|
||||||
|
idPrefix: string;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
tabs: TabType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TabButtons({
|
||||||
|
activeIndex,
|
||||||
|
idPrefix,
|
||||||
|
onSelect,
|
||||||
|
tabs,
|
||||||
|
}: TabButtonsProps) {
|
||||||
|
return (
|
||||||
|
<TabButtonsGrid role="tablist">
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<TabButton
|
||||||
|
controls={`${idPrefix}-panel`}
|
||||||
|
id={`${idPrefix}-tab-${index}`}
|
||||||
|
isActive={index === activeIndex}
|
||||||
|
key={index}
|
||||||
|
onSelect={() => onSelect(index)}
|
||||||
|
tab={tab}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TabButtonsGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useId, useState } from 'react';
|
||||||
|
|
||||||
|
import { theme } from '@/theme';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
|
||||||
|
import type { TabType } from '@/sections/Tabs/types';
|
||||||
|
|
||||||
|
import { TabButtons } from './TabButtons';
|
||||||
|
import { TabVisuals } from './TabVisuals';
|
||||||
|
|
||||||
|
const StyledTabGroup = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
row-gap: ${theme.spacing(18)};
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type TabGroupProps = {
|
||||||
|
tabs: TabType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TabGroup({ tabs }: TabGroupProps) {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const idPrefix = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledTabGroup>
|
||||||
|
<TabButtons
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
idPrefix={idPrefix}
|
||||||
|
onSelect={setActiveIndex}
|
||||||
|
tabs={tabs}
|
||||||
|
/>
|
||||||
|
<TabVisuals
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
panelId={`${idPrefix}-panel`}
|
||||||
|
tabId={`${idPrefix}-tab-${activeIndex}`}
|
||||||
|
tabs={tabs}
|
||||||
|
/>
|
||||||
|
</StyledTabGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Image } from '@/design-system/components';
|
||||||
|
import type { TabType } from '@/sections/Tabs/types';
|
||||||
|
import { theme } from '@/theme';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
|
||||||
|
const VisualRoot = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: ${theme.breakpoints.md}px) {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 995px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const VisualImage = styled(Image)`
|
||||||
|
border-radius: ${theme.radius(1)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
type TabVisualsProps = {
|
||||||
|
activeIndex: number;
|
||||||
|
panelId: string;
|
||||||
|
tabId: string;
|
||||||
|
tabs: TabType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TabVisuals({
|
||||||
|
activeIndex,
|
||||||
|
panelId,
|
||||||
|
tabId,
|
||||||
|
tabs,
|
||||||
|
}: TabVisualsProps) {
|
||||||
|
const tab = tabs[activeIndex];
|
||||||
|
|
||||||
|
if (!tab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VisualRoot aria-labelledby={tabId} id={panelId} role="tabpanel">
|
||||||
|
<VisualImage alt={tab.image.alt} src={tab.image.src} />
|
||||||
|
</VisualRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Root } from './Root';
|
||||||
|
import { TabGroup } from './TabGroup';
|
||||||
|
|
||||||
|
export const Tabs = {
|
||||||
|
Root,
|
||||||
|
TabGroup,
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
|
||||||
|
import type { ImageType } from '@/design-system/components/Image';
|
||||||
|
|
||||||
|
export type TabType = {
|
||||||
|
body: MessageDescriptor;
|
||||||
|
icon: string;
|
||||||
|
image: ImageType;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user