Complete sections of the new website. (#19239)
This PR completes the markup for all the sections of all pages of the new website. There are a lot of improvements to come, but the entire structure is in place now.
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 938 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,107 @@
|
||||
import type { FooterDataType } from '@/sections/Footer/types';
|
||||
|
||||
export const FOOTER_DATA: FooterDataType = {
|
||||
illustration: {
|
||||
src: 'https://app.endlesstools.io/embed/73beb0a6-bcda-410f-8e4c-a3bcd70224b0',
|
||||
title: 'Footer background',
|
||||
},
|
||||
bottom: {
|
||||
copyright: '© 2026 – Twenty',
|
||||
credit: 'Design by Ardent.',
|
||||
},
|
||||
navGroups: [
|
||||
{
|
||||
id: 'footer-sitemap',
|
||||
title: 'Sitemap',
|
||||
ctas: [],
|
||||
links: [
|
||||
{ label: 'Home', href: '/', external: false },
|
||||
{ label: 'Product', href: '/product', external: false },
|
||||
{ label: 'Pricing', href: '/pricing', external: false },
|
||||
{ label: 'Partners', href: '/partner', external: false },
|
||||
{ label: 'Why Twenty', href: '/why-twenty', external: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'footer-help',
|
||||
title: 'Help',
|
||||
ctas: [],
|
||||
links: [
|
||||
{ label: 'Developers', href: '#', external: false },
|
||||
{ label: 'FAQ', href: '#', external: false },
|
||||
{ label: 'Support', href: '#', external: false },
|
||||
{ label: 'Release Notes', href: '#', external: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'footer-legal',
|
||||
title: 'Legal',
|
||||
ctas: [],
|
||||
links: [
|
||||
{ label: 'Cookie Policy', href: '#', external: false },
|
||||
{ label: 'Privacy Policy', href: '#', external: false },
|
||||
{ label: 'Terms and Conditions', href: '#', external: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'footer-connect',
|
||||
title: 'Connect',
|
||||
ctas: [
|
||||
{
|
||||
color: 'secondary',
|
||||
href: 'https://app.twenty.com/welcome',
|
||||
label: 'Talk to us',
|
||||
type: 'anchor',
|
||||
variant: 'contained',
|
||||
},
|
||||
{
|
||||
color: 'secondary',
|
||||
href: 'https://app.twenty.com/welcome',
|
||||
label: 'Get started',
|
||||
type: 'anchor',
|
||||
variant: 'outlined',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
href: 'https://www.linkedin.com/company/twenty',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
socialLinks: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FOOTER_DATA } from '@/app/(home)/constants/footer';
|
||||
import { Footer } from '@/sections/Footer/components';
|
||||
import { theme } from '@/theme';
|
||||
import { cssVariables } from '@/theme/css-variables';
|
||||
@@ -69,11 +70,14 @@ export default function RootLayout({
|
||||
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable}`}
|
||||
>
|
||||
<StyledMain>{children}</StyledMain>
|
||||
<Footer.Root>
|
||||
<Footer.Root illustration={FOOTER_DATA.illustration}>
|
||||
<Footer.Logo />
|
||||
<Footer.Nav />
|
||||
<Footer.Bottom>
|
||||
<Footer.Social />
|
||||
<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>
|
||||
</Footer.Root>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SignoffDataType } from '@/sections/Signoff/types';
|
||||
|
||||
export const SIGNOFF_DATA: SignoffDataType = {
|
||||
heading: [
|
||||
{ text: 'Ready to grow', fontFamily: 'serif' },
|
||||
{ text: ' with Twenty?', fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: 'Join our partner ecosystem and help businesses take control of their CRM.',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ 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 { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/enums/pages';
|
||||
@@ -11,6 +12,7 @@ import { EngagementBand } from '@/sections/EngagementBand/components';
|
||||
import { Faq } from '@/sections/Faq/components';
|
||||
import { Hero } from '@/sections/Hero/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
import { Signoff } from '@/sections/Signoff/components';
|
||||
import { Testimonials } from '@/sections/Testimonials/components';
|
||||
import { ThreeCards } from '@/sections/ThreeCards/components';
|
||||
import { TrustedBy } from '@/sections/TrustedBy/components';
|
||||
@@ -117,6 +119,30 @@ export default function PartnerPage() {
|
||||
/>
|
||||
</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} />
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { PlanTableDataType } from '@/sections/PlanTable/types';
|
||||
|
||||
const EMAIL_OUTREACH_TASK = {
|
||||
label: 'Tasks',
|
||||
options: ['email outreach'],
|
||||
value: 'email outreach',
|
||||
} as const;
|
||||
|
||||
export const PLAN_TABLE_DATA: PlanTableDataType = {
|
||||
featureColumnLabel: 'Feature',
|
||||
rows: [
|
||||
{
|
||||
featureLabel: 'Price',
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: '$19' },
|
||||
pro: { kind: 'text', text: '$9' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Self Hosting discount',
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Free for up to 20 seats' },
|
||||
pro: { kind: 'text', text: 'Free for up to 20 seats' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Seats limit',
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Reports',
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Number of dashboards',
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
title: 'Automations',
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Workflows',
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'AI Agent',
|
||||
tiers: {
|
||||
organization: { kind: 'yes', label: 'Yes' },
|
||||
pro: { kind: 'yes', label: 'Yes' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Automation credits',
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: '1,000' },
|
||||
pro: { kind: 'text', text: '1,000' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Extra credits',
|
||||
tiers: {
|
||||
organization: { kind: 'dash' },
|
||||
pro: {
|
||||
kind: 'text',
|
||||
text: '1,000 for $10 - 5,000 for $48',
|
||||
},
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
{
|
||||
calculator: {
|
||||
priceLine: {
|
||||
amount: '700€',
|
||||
label: 'Price',
|
||||
periodSuffix: '/month',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'intelligence-ai',
|
||||
modelField: {
|
||||
label: 'Model',
|
||||
options: ['GPT 5'],
|
||||
value: 'GPT 5',
|
||||
},
|
||||
requestField: { label: 'Requests', value: 100 },
|
||||
tasksField: {
|
||||
label: EMAIL_OUTREACH_TASK.label,
|
||||
options: [...EMAIL_OUTREACH_TASK.options],
|
||||
value: EMAIL_OUTREACH_TASK.value,
|
||||
},
|
||||
title: 'Intelligence AI',
|
||||
},
|
||||
{
|
||||
id: 'compute',
|
||||
requestField: { label: 'Requests', value: 100 },
|
||||
tasksField: {
|
||||
label: EMAIL_OUTREACH_TASK.label,
|
||||
options: [...EMAIL_OUTREACH_TASK.options],
|
||||
value: EMAIL_OUTREACH_TASK.value,
|
||||
},
|
||||
title: 'Compute',
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
requestField: { label: 'Requests', value: 100 },
|
||||
tasksField: {
|
||||
label: EMAIL_OUTREACH_TASK.label,
|
||||
options: [...EMAIL_OUTREACH_TASK.options],
|
||||
value: EMAIL_OUTREACH_TASK.value,
|
||||
},
|
||||
title: 'Storage',
|
||||
},
|
||||
],
|
||||
visual: {
|
||||
body: {
|
||||
text: 'Estimate your costs effortlessly with our price calculator customize your workspace in just a few clicks!',
|
||||
},
|
||||
heading: [
|
||||
{ fontFamily: 'serif', text: 'Get a glimpse of ' },
|
||||
{ fontFamily: 'sans', text: "what it'll cost" },
|
||||
],
|
||||
},
|
||||
},
|
||||
type: 'calculator',
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
type: 'category',
|
||||
},
|
||||
{
|
||||
featureLabel: 'Custom objects',
|
||||
tiers: {
|
||||
organization: { kind: 'text', text: 'Unlimited' },
|
||||
pro: { kind: 'text', text: 'Unlimited' },
|
||||
},
|
||||
type: 'row',
|
||||
},
|
||||
],
|
||||
seeMoreFeaturesCta: {
|
||||
href: '/pricing',
|
||||
label: 'See more features',
|
||||
},
|
||||
tierColumns: [
|
||||
{ id: 'pro', label: 'Pro' },
|
||||
{ id: 'organization', label: 'Organization' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { SalesforceDataType } from '@/sections/Salesforce/types';
|
||||
|
||||
export const SALESFORCE_DATA: SalesforceDataType = {
|
||||
body: {
|
||||
text: 'Aenean lacinia bibendum nulla sed consectetur. Integer posuere erat a ante venenatis dapibus.',
|
||||
},
|
||||
heading: [
|
||||
{ text: 'Trust the n°1 CRM,', fontFamily: 'serif' },
|
||||
{ text: ' or not!', fontFamily: 'sans' },
|
||||
],
|
||||
pricing: {
|
||||
addons: [
|
||||
{
|
||||
id: 'api-access',
|
||||
label: 'API access',
|
||||
popupKey: 'wrongChoiceDefault',
|
||||
rightLabel: '+$35/user per month',
|
||||
},
|
||||
{
|
||||
id: 'webhooks',
|
||||
label: 'Webhooks (Change data capture)',
|
||||
popupKey: 'wrongChoiceLorem',
|
||||
rightLabel: '+$7000/org per month',
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
id: 'live-updates',
|
||||
label: 'Live updates',
|
||||
popupKey: 'wrongChoiceDefault',
|
||||
rightLabel: 'Unavailable',
|
||||
},
|
||||
{
|
||||
defaultChecked: true,
|
||||
id: 'ui-theme',
|
||||
label: 'UI theme',
|
||||
popupKey: 'wrongChoiceDefault',
|
||||
rightLabel: 'Retro 2015',
|
||||
},
|
||||
{
|
||||
id: 'sso',
|
||||
label: 'SSO',
|
||||
popupKey: 'wrongChoiceDefault',
|
||||
rightLabel: '+$5/user per month',
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
label: '11 permissions groups',
|
||||
popupKey: 'wrongChoiceDefault',
|
||||
rightLabel: '+$75/user per month (Switch to enterprise!)',
|
||||
},
|
||||
{
|
||||
id: 'maps',
|
||||
label: 'Maps view',
|
||||
popupKey: 'wrongChoiceDefault',
|
||||
rightLabel: '+$105/user per month',
|
||||
},
|
||||
],
|
||||
featureSectionHeading: 'Best for Salesforce',
|
||||
priceAmount: '$100 USD',
|
||||
priceSuffix: ' / seat / month - billed yearly',
|
||||
primaryCtaLabel: 'Do you really want to click and ask your first quote ???',
|
||||
productTitle: 'Salesforce Pro',
|
||||
secondaryCtaLabel: "More options available, BUT DON'T CLICK !!",
|
||||
windowTitle: 'Salesforce Pro pricing',
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,8 @@ 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 { Eyebrow, LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/enums/pages';
|
||||
import { EngagementBand } from '@/sections/EngagementBand/components';
|
||||
@@ -9,6 +11,8 @@ import { Faq } from '@/sections/Faq/components';
|
||||
import { Hero } from '@/sections/Hero/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
import { Plans } from '@/sections/Plans/components';
|
||||
import { PlanTable } from '@/sections/PlanTable/components';
|
||||
import { Salesforce } from '@/sections/Salesforce/components';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
export default function PricingPage() {
|
||||
@@ -58,6 +62,17 @@ export default function PricingPage() {
|
||||
</EngagementBand.Strip>
|
||||
</EngagementBand.Root>
|
||||
|
||||
<PlanTable.Root backgroundColor={theme.colors.secondary.background[100]}>
|
||||
<PlanTable.Content data={PLAN_TABLE_DATA} />
|
||||
</PlanTable.Root>
|
||||
|
||||
<Salesforce.Flow
|
||||
backgroundColor={theme.colors.secondary.background[5]}
|
||||
body={SALESFORCE_DATA.body}
|
||||
heading={SALESFORCE_DATA.heading}
|
||||
pricing={SALESFORCE_DATA.pricing}
|
||||
/>
|
||||
|
||||
<Faq.Root illustration={FAQ_DATA.illustration}>
|
||||
<Faq.Intro>
|
||||
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FeatureDataType } from '@/sections/Feature/types';
|
||||
|
||||
export const FEATURE_DATA: FeatureDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
text: 'Core Features',
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{ text: 'Everything you need,', fontFamily: 'serif' },
|
||||
{ text: ' out of the box', fontFamily: 'sans' },
|
||||
],
|
||||
mask: { src: '/images/product/feature/mask.png', alt: '' },
|
||||
tiles: [
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/dashboards.png', alt: '' },
|
||||
heading: { text: 'Reports & Dashboards', fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Build custom dashboards' },
|
||||
{ text: 'Aggregate, bar, line, pie, and gauge widgets' },
|
||||
{ text: 'Filtered metrics from live CRM data' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/tasks.png', alt: '' },
|
||||
heading: { text: 'Tasks & Activities', fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Create tasks from records' },
|
||||
{ text: 'Assign owners and due dates' },
|
||||
{ text: 'Rich notes attached to records' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/emails.png', alt: '' },
|
||||
heading: { text: 'Email & Calendar', fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Connect Google or Microsoft accounts' },
|
||||
{ text: 'Emails and events linked to CRM records' },
|
||||
{ text: 'Full communication history in one place' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/contacts.png', alt: '' },
|
||||
heading: { text: 'Contacts & Companies', fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Custom fields and relationships' },
|
||||
{ text: 'Unified timeline (emails, events, tasks, notes, files)' },
|
||||
{ text: 'Email/calendar activity on each record' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/pipeline.png', alt: '' },
|
||||
heading: { text: 'Pipeline Management', fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Custom deal stages for your process' },
|
||||
{ text: 'Drag-and-drop deals between stages' },
|
||||
{ text: 'Track amount and close date' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/files.png', alt: '' },
|
||||
heading: { text: 'Files', fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'Multi-file upload on records' },
|
||||
{ text: 'Rename, download, and delete attachments' },
|
||||
{ text: 'In-app preview for supported file types (when enabled)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'check',
|
||||
image: { src: '/images/product/feature/data.png', alt: '' },
|
||||
heading: { text: 'Data import', fontFamily: 'sans' },
|
||||
bullets: [
|
||||
{ text: 'CSV import flow' },
|
||||
{ text: 'Column-to-field mapping (including relations)' },
|
||||
{ text: 'CSV export anytime' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2,15 +2,19 @@ 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 { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/enums/pages';
|
||||
import { Demo } from '@/sections/Demo/components';
|
||||
import { Feature } from '@/sections/Feature/components';
|
||||
import { Faq } from '@/sections/Faq/components';
|
||||
import { Hero } from '@/sections/Hero/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
import { ProductStepper } from '@/sections/ProductStepper/components';
|
||||
import { Tabs } from '@/sections/Tabs/components';
|
||||
import { ThreeCards } from '@/sections/ThreeCards/components';
|
||||
import { TrustedBy } from '@/sections/TrustedBy/components';
|
||||
@@ -64,6 +68,17 @@ export default function ProductPage() {
|
||||
<Tabs.TabGroup tabs={TABS_DATA.tabs} />
|
||||
</Tabs.Root>
|
||||
|
||||
<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>
|
||||
|
||||
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
|
||||
<ThreeCards.Intro page={Pages.Product} align="left">
|
||||
<Eyebrow
|
||||
@@ -82,12 +97,12 @@ export default function ProductPage() {
|
||||
/>
|
||||
</ThreeCards.Root>
|
||||
|
||||
{/* <ProductStepper.Flow
|
||||
<ProductStepper.Flow
|
||||
body={STEPPER_DATA.body}
|
||||
eyebrow={STEPPER_DATA.eyebrow}
|
||||
heading={STEPPER_DATA.heading}
|
||||
steps={STEPPER_DATA.steps}
|
||||
/> */}
|
||||
/>
|
||||
|
||||
<Demo.Root>
|
||||
<Eyebrow colorScheme="primary" heading={DEMO_DATA.eyebrow.heading} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
|
||||
|
||||
export const EDITORIAL_FOUR: EditorialDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
fontFamily: 'sans',
|
||||
text: 'What must our CRM do that nobody else can?',
|
||||
},
|
||||
},
|
||||
heading: {
|
||||
fontFamily: 'serif',
|
||||
text: 'CRM becomes an execution system',
|
||||
},
|
||||
body: [
|
||||
{
|
||||
text: 'For years, CRM was treated like a ledger. A place to log what happened. AI flips that. When agents draft emails, qualify leads, propose next steps, update records, and trigger follow-ups, the CRM becomes where work begins.',
|
||||
},
|
||||
{
|
||||
text: 'It becomes the interface between humans, agents, and reality. The customer system is the map that directs action. If the map is generic, the actions will be generic. If the map is wrong, the actions will be wrong at scale. If they matter, you must own them.',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
|
||||
|
||||
export const EDITORIAL_ONE: EditorialDataType = {
|
||||
eyebrow: {
|
||||
heading: {
|
||||
fontFamily: 'sans',
|
||||
text: 'What must our CRM do that nobody else can?',
|
||||
},
|
||||
},
|
||||
heading: [
|
||||
{ fontFamily: 'serif', text: 'Packaged CRMs' },
|
||||
{ fontFamily: 'sans', text: ' optimize for sameness' },
|
||||
],
|
||||
body: [
|
||||
{
|
||||
text: 'Off-the-shelf CRMs are built for the median workflow. They standardize what an account is, what a pipeline looks like, what stages matter, and what "good" reporting means. They help you keep up with the market, this is beta. But when it is time to generate differentiated value, alpha, they fall short.',
|
||||
},
|
||||
{
|
||||
text: 'When every company runs the same objects, the same lifecycle stages, the same routing logic, and the same prepackaged "AI insights," the CRM stops being an advantage. It becomes a cost of entry. You do not just buy software. You inherit assumptions, assumptions that over time become constraints.',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
|
||||
|
||||
export const EDITORIAL_THREE: EditorialDataType = {
|
||||
eyebrow: {
|
||||
heading: { fontFamily: 'sans', text: 'Lorem ipsum dolor' },
|
||||
},
|
||||
heading: [
|
||||
{ fontFamily: 'serif', text: 'Lorem ipsum dolor sit amet,' },
|
||||
{ fontFamily: 'sans', text: ' consectetur' },
|
||||
],
|
||||
body: {
|
||||
text: 'AI compresses the time between idea and implementation. Feature parity arrives faster. "Best practices" expire faster. Competitive imitation accelerates because everyone has access to the same models, the same libraries, and the same distribution channels. When the surface area of software becomes cheap, differentiation moves underneath it.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { EditorialDataType } from '@/sections/Editorial/types/EditorialData';
|
||||
|
||||
export const EDITORIAL_TWO: EditorialDataType = {
|
||||
body: {
|
||||
text: 'AI compresses the time between idea and implementation. Feature parity arrives faster. "Best practices" expire faster. Competitive imitation accelerates because everyone has access to the same models, the same libraries, and the same distribution channels. When the surface area of software becomes cheap, differentiation moves underneath it.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MarqueeDataType } from '@/sections/Marquee/types';
|
||||
|
||||
export const MARQUEE_DATA: MarqueeDataType = {
|
||||
heading: [
|
||||
{ fontFamily: 'serif', text: 'AI raises' },
|
||||
{ fontFamily: 'sans', text: 'the cost of' },
|
||||
{ fontFamily: 'sans', text: 'being wrong' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SignoffDataType } from '@/sections/Signoff/types';
|
||||
|
||||
export const SIGNOFF_DATA: SignoffDataType = {
|
||||
heading: [
|
||||
{ text: 'The future of CRM is built,', fontFamily: 'serif' },
|
||||
{ text: ' not bought.', fontFamily: 'sans' },
|
||||
],
|
||||
body: {
|
||||
text: 'This is the world we are building toward at Twenty. ',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WhyTwentyStepperDataType } from '@/sections/WhyTwentyStepper/types';
|
||||
|
||||
export const STEPPER_DATA: WhyTwentyStepperDataType = {
|
||||
heading: { text: 'Our vision', fontFamily: 'serif' },
|
||||
body: [
|
||||
{
|
||||
text: 'We believe every serious company will need a malleable system of record for customers. Not a collection of disconnected tools.',
|
||||
},
|
||||
{
|
||||
text: 'Not a frozen monolith that only specialists can change. A living system that can evolve as fast as strategy evolves, while remaining trustworthy enough for humans, agents, and regulators to rely on.',
|
||||
},
|
||||
{
|
||||
text: 'Because in the AI age, the winners will not be the companies with the most dashboards. They will be the companies whose systems turn information into decisions, and decisions into action, faster than everyone else.',
|
||||
},
|
||||
],
|
||||
illustration: {
|
||||
src: 'https://app.endlesstools.io/embed/ddc485f9-cc8e-422b-a076-5430eb84e21a',
|
||||
title: 'Endless Tools Editor',
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,25 @@
|
||||
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 { LinkButton } from '@/design-system/components';
|
||||
import { Pages } from '@/enums/pages';
|
||||
import { Editorial } from '@/sections/Editorial/components';
|
||||
import { Hero } from '@/sections/Hero/components';
|
||||
import { Marquee } from '@/sections/Marquee/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
import { Quote } from '@/sections/Quote/components';
|
||||
import { Signoff } from '@/sections/Signoff/components';
|
||||
import { Statement } from '@/sections/Statement/components';
|
||||
import { WhyTwentyStepper } from '@/sections/WhyTwentyStepper/components';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
export default function WhyTwentyPage() {
|
||||
@@ -38,11 +50,55 @@ export default function WhyTwentyPage() {
|
||||
/>
|
||||
</Hero.Root>
|
||||
|
||||
<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>
|
||||
|
||||
<Quote.Root backgroundColor={theme.colors.secondary.background[100]}>
|
||||
<Quote.Visual illustration={QUOTE_DATA.illustration} />
|
||||
<Quote.Heading segments={QUOTE_DATA.heading} />
|
||||
</Quote.Root>
|
||||
|
||||
<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>
|
||||
|
||||
<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]}
|
||||
@@ -56,6 +112,46 @@ export default function WhyTwentyPage() {
|
||||
>
|
||||
<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>
|
||||
|
||||
<WhyTwentyStepper.Flow
|
||||
body={STEPPER_DATA.body}
|
||||
heading={STEPPER_DATA.heading}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
const DEFAULT_LOAD_ROOT_MARGIN = '1200px 0px';
|
||||
const DEFAULT_UNLOAD_ROOT_MARGIN = '1800px 0px';
|
||||
const DEFAULT_UNLOAD_DELAY_MS = 1500;
|
||||
|
||||
type LazyEmbedProps = Omit<ComponentPropsWithoutRef<'iframe'>, 'loading' | 'src'> & {
|
||||
eager?: boolean;
|
||||
rootMargin?: string;
|
||||
src: string;
|
||||
unloadDelayMs?: number;
|
||||
unloadWhenHidden?: boolean;
|
||||
unloadRootMargin?: string;
|
||||
};
|
||||
|
||||
export function LazyEmbed({
|
||||
eager = false,
|
||||
rootMargin = DEFAULT_LOAD_ROOT_MARGIN,
|
||||
unloadDelayMs = DEFAULT_UNLOAD_DELAY_MS,
|
||||
unloadWhenHidden = true,
|
||||
unloadRootMargin = DEFAULT_UNLOAD_ROOT_MARGIN,
|
||||
src,
|
||||
className,
|
||||
style,
|
||||
role,
|
||||
tabIndex,
|
||||
'aria-hidden': ariaHidden,
|
||||
...iframeProps
|
||||
}: LazyEmbedProps) {
|
||||
const [hostNode, setHostNode] = useState<HTMLDivElement | null>(null);
|
||||
const [shouldRender, setShouldRender] = useState(eager);
|
||||
const unloadTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const wrapperProps = useMemo(
|
||||
() => ({
|
||||
'aria-hidden': ariaHidden,
|
||||
className,
|
||||
role,
|
||||
style,
|
||||
tabIndex,
|
||||
}),
|
||||
[ariaHidden, className, role, style, tabIndex],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
setShouldRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const clearPendingUnload = () => {
|
||||
if (unloadTimeoutRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(unloadTimeoutRef.current);
|
||||
unloadTimeoutRef.current = null;
|
||||
};
|
||||
|
||||
const scheduleUnload = () => {
|
||||
if (!unloadWhenHidden || unloadTimeoutRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
unloadTimeoutRef.current = window.setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
unloadTimeoutRef.current = null;
|
||||
}, unloadDelayMs);
|
||||
};
|
||||
|
||||
if (eager) {
|
||||
setShouldRender(true);
|
||||
}
|
||||
|
||||
const loadObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const isNearViewport = entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (isNearViewport) {
|
||||
clearPendingUnload();
|
||||
setShouldRender(true);
|
||||
}
|
||||
},
|
||||
{ rootMargin },
|
||||
);
|
||||
|
||||
loadObserver.observe(hostNode);
|
||||
|
||||
let unloadObserver: IntersectionObserver | null = null;
|
||||
|
||||
if (unloadWhenHidden) {
|
||||
unloadObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const isStillClose = entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (isStillClose) {
|
||||
clearPendingUnload();
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleUnload();
|
||||
},
|
||||
{ rootMargin: unloadRootMargin },
|
||||
);
|
||||
|
||||
unloadObserver.observe(hostNode);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearPendingUnload();
|
||||
loadObserver.disconnect();
|
||||
unloadObserver?.disconnect();
|
||||
};
|
||||
}, [
|
||||
eager,
|
||||
hostNode,
|
||||
rootMargin,
|
||||
unloadDelayMs,
|
||||
unloadWhenHidden,
|
||||
unloadRootMargin,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...wrapperProps}
|
||||
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}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export { Eyebrow } from './Eyebrow/Eyebrow';
|
||||
export { Heading } from './Heading/Heading';
|
||||
export { IconButton } from './IconButton/IconButton';
|
||||
export { Image } from './Image/Image';
|
||||
export { LazyEmbed } from './LazyEmbed/LazyEmbed';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body as BaseBody } from '@/design-system/components';
|
||||
import type { BodyType } from '@/design-system/components/Body/types/Body';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const BodyParagraph = styled.div`
|
||||
color: var(--editorial-body-color);
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const TwoColumnGrid = styled.div`
|
||||
column-gap: ${theme.spacing(6)};
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 902px;
|
||||
row-gap: ${theme.spacing(6)};
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
// 1440 artboard: 384 + 556 + 500 — only two-column uses horizontal centering.
|
||||
const SingleColumnBody = styled.div`
|
||||
max-width: 556px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
margin-left: 384px;
|
||||
margin-right: 500px;
|
||||
}
|
||||
`;
|
||||
|
||||
type EditorialBodyProps = {
|
||||
body: BodyType | BodyType[];
|
||||
layout: 'centered' | 'indented' | 'two-column';
|
||||
};
|
||||
|
||||
export function Body({ body, layout }: EditorialBodyProps) {
|
||||
const paragraphs = Array.isArray(body) ? (
|
||||
body.map((item, index) => (
|
||||
<BodyParagraph key={index}>
|
||||
<BaseBody as="p" body={item} family="sans" size="md" weight="regular" />
|
||||
</BodyParagraph>
|
||||
))
|
||||
) : (
|
||||
<BodyParagraph>
|
||||
<BaseBody as="p" body={body} family="sans" size="md" weight="regular" />
|
||||
</BodyParagraph>
|
||||
);
|
||||
|
||||
if (layout === 'two-column') {
|
||||
return <TwoColumnGrid>{paragraphs}</TwoColumnGrid>;
|
||||
}
|
||||
|
||||
return <SingleColumnBody>{paragraphs}</SingleColumnBody>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Eyebrow as BaseEyebrow } from '@/design-system/components';
|
||||
import type { EyebrowType } from '@/design-system/components/Eyebrow/types/Eyebrow';
|
||||
|
||||
type EditorialEyebrowProps = {
|
||||
colorScheme: 'primary' | 'secondary';
|
||||
eyebrow: EyebrowType;
|
||||
};
|
||||
|
||||
export function Eyebrow({ colorScheme, eyebrow }: EditorialEyebrowProps) {
|
||||
return <BaseEyebrow colorScheme={colorScheme} heading={eyebrow.heading} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Heading as BaseHeading } from '@/design-system/components';
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const HeadingWrap = styled.div`
|
||||
max-width: 617px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type EditorialHeadingProps = {
|
||||
segments: HeadingType | HeadingType[];
|
||||
};
|
||||
|
||||
export function Heading({ segments }: EditorialHeadingProps) {
|
||||
return (
|
||||
<HeadingWrap>
|
||||
<BaseHeading as="h2" segments={segments} size="xl" weight="light" />
|
||||
</HeadingWrap>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const IntroRoot = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(6)};
|
||||
max-width: 556px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type IntroProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Intro({ children }: IntroProps) {
|
||||
return <IntroRoot>{children}</IntroRoot>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Container } from '@/design-system/components';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
const StyledSection = styled.section`
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Inner = styled.div`
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(10)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled(Container)`
|
||||
padding-bottom: ${theme.spacing(28)};
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
padding-top: ${theme.spacing(28)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
padding-left: ${theme.spacing(10)};
|
||||
padding-right: ${theme.spacing(10)};
|
||||
}
|
||||
`;
|
||||
|
||||
type RootProps = {
|
||||
backgroundColor: string;
|
||||
children: ReactNode;
|
||||
color: string;
|
||||
mutedColor: string;
|
||||
};
|
||||
|
||||
export function Root({
|
||||
backgroundColor,
|
||||
children,
|
||||
color,
|
||||
mutedColor,
|
||||
}: RootProps) {
|
||||
const cssVariables = {
|
||||
'--editorial-body-color': mutedColor,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<StyledSection
|
||||
style={{
|
||||
...cssVariables,
|
||||
backgroundColor,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
<StyledContainer>
|
||||
<Inner>{children}</Inner>
|
||||
</StyledContainer>
|
||||
</StyledSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Body } from './Body/Body';
|
||||
import { Eyebrow } from './Eyebrow/Eyebrow';
|
||||
import { Heading } from './Heading/Heading';
|
||||
import { Intro } from './Intro/Intro';
|
||||
import { Root } from './Root/Root';
|
||||
|
||||
export const Editorial = { Body, Eyebrow, Heading, Intro, Root };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { BodyType } from '@/design-system/components/Body/types/Body';
|
||||
import { EyebrowType } from '@/design-system/components/Eyebrow/types/Eyebrow';
|
||||
import { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
|
||||
export type EditorialDataType = {
|
||||
eyebrow?: EyebrowType;
|
||||
heading?: HeadingType | HeadingType[];
|
||||
body: BodyType | BodyType[];
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type { EditorialDataType } from './EditorialData';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Pages } from '@/enums/pages';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const StyledIntro = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: ${theme.spacing(6)};
|
||||
text-align: start;
|
||||
|
||||
&[data-align='center'] {
|
||||
justify-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
max-width: 688px;
|
||||
|
||||
&[data-page=${Pages.Home}] {
|
||||
max-width: 900px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type IntroProps = {
|
||||
align: 'center' | 'left';
|
||||
children: ReactNode;
|
||||
page: Pages;
|
||||
};
|
||||
|
||||
export function Intro({ align, children, page }: IntroProps) {
|
||||
return (
|
||||
<StyledIntro data-align={align} data-page={page}>
|
||||
{children}
|
||||
</StyledIntro>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Container } from '@/design-system/components';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const StyledSection = styled.section`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled(Container)`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: ${theme.spacing(6)};
|
||||
padding-bottom: ${theme.spacing(12)};
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
padding-top: ${theme.spacing(12)};
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
gap: ${theme.spacing(10)};
|
||||
padding-bottom: ${theme.spacing(20)};
|
||||
padding-left: ${theme.spacing(10)};
|
||||
padding-right: ${theme.spacing(10)};
|
||||
padding-top: ${theme.spacing(20)};
|
||||
}
|
||||
`;
|
||||
|
||||
type RootProps = {
|
||||
backgroundColor: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Root({ backgroundColor, children }: RootProps) {
|
||||
return (
|
||||
<StyledSection style={{ backgroundColor }}>
|
||||
<StyledContainer>{children}</StyledContainer>
|
||||
</StyledSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { ImageType } from '@/design-system/components/Image/types/Image';
|
||||
import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
import { TileContent } from './TileContent';
|
||||
import { TileVisual } from './TileVisual';
|
||||
|
||||
const TileRoot = styled.article`
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
border: 1px solid ${theme.colors.primary.border[20]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
type TileProps = {
|
||||
index: number;
|
||||
mask: ImageType;
|
||||
tile: FeatureTileType;
|
||||
};
|
||||
|
||||
export function Tile({ index, mask, tile }: TileProps) {
|
||||
return (
|
||||
<TileRoot>
|
||||
<TileVisual image={tile.image} index={index} mask={mask} />
|
||||
<TileContent tile={tile} />
|
||||
</TileRoot>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { Body, Heading } from '@/design-system/components';
|
||||
import { INFORMATIVE_ICONS } from '@/icons';
|
||||
import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
const Content = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
padding: ${theme.spacing(4)};
|
||||
row-gap: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const TitleRow = styled.div`
|
||||
align-items: center;
|
||||
column-gap: ${theme.spacing(2)};
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
`;
|
||||
|
||||
const IconSlot = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${theme.colors.highlight[100]};
|
||||
border-radius: ${theme.radius(1)};
|
||||
display: flex;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
`;
|
||||
|
||||
const HeadingWrap = styled.div`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Bullets = styled.ul`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
list-style-type: disc;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-left: ${theme.spacing(5)};
|
||||
row-gap: ${theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const BulletItem = styled.li`
|
||||
margin: 0;
|
||||
|
||||
&::marker {
|
||||
color: ${theme.colors.primary.text[40]};
|
||||
}
|
||||
`;
|
||||
|
||||
type TileContentProps = {
|
||||
tile: FeatureTileType;
|
||||
};
|
||||
|
||||
export function TileContent({ tile }: TileContentProps) {
|
||||
const Icon = INFORMATIVE_ICONS[tile.icon];
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<TitleRow>
|
||||
<IconSlot>
|
||||
{Icon ? (
|
||||
<Icon color={theme.colors.primary.background[100]} size={16} />
|
||||
) : null}
|
||||
</IconSlot>
|
||||
<HeadingWrap>
|
||||
<Heading as="h3" segments={tile.heading} size="xs" weight="medium" />
|
||||
</HeadingWrap>
|
||||
</TitleRow>
|
||||
<Bullets>
|
||||
{tile.bullets.map((bullet, bulletIndex) => (
|
||||
<BulletItem key={bulletIndex}>
|
||||
<Body as="span" body={bullet} size="sm" weight="regular" />
|
||||
</BulletItem>
|
||||
))}
|
||||
</Bullets>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
|
||||
import type { ImageType } from '@/design-system/components/Image/types/Image';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
const VisualArea = styled.div`
|
||||
background-color: ${theme.colors.primary.border[10]};
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&[data-index='0'] {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
height: 240px;
|
||||
|
||||
&[data-index='0'] {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageInset = styled.div`
|
||||
bottom: 0;
|
||||
left: ${theme.spacing(4)};
|
||||
position: absolute;
|
||||
right: ${theme.spacing(4)};
|
||||
top: ${theme.spacing(4)};
|
||||
z-index: 1;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
left: ${theme.spacing(8)};
|
||||
right: ${theme.spacing(8)};
|
||||
top: ${theme.spacing(8)};
|
||||
|
||||
&[data-index='0'] {
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
top: ${theme.spacing(6)};
|
||||
}
|
||||
&[data-index='3'] {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
object-position: bottom center;
|
||||
}
|
||||
|
||||
&[data-index='3'] img {
|
||||
object-position: bottom left;
|
||||
}
|
||||
`;
|
||||
|
||||
const MaskOverlay = styled.div`
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&[data-index='0'] img {
|
||||
object-position: center 20%;
|
||||
}
|
||||
&[data-index='1'] img {
|
||||
object-position: left 40%;
|
||||
}
|
||||
&[data-index='2'] img {
|
||||
object-position: right 40%;
|
||||
}
|
||||
&[data-index='3'] img {
|
||||
object-position: left 60%;
|
||||
}
|
||||
&[data-index='4'] img {
|
||||
object-position: right 60%;
|
||||
}
|
||||
&[data-index='5'] img {
|
||||
object-position: left 80%;
|
||||
}
|
||||
&[data-index='6'] img {
|
||||
object-position: right 80%;
|
||||
}
|
||||
`;
|
||||
|
||||
type TileVisualProps = {
|
||||
image: ImageType;
|
||||
index: number;
|
||||
mask: ImageType;
|
||||
};
|
||||
|
||||
export function TileVisual({ image, index, mask }: TileVisualProps) {
|
||||
return (
|
||||
<VisualArea data-index={index}>
|
||||
<ImageInset data-index={index}>
|
||||
<NextImage
|
||||
alt={image.alt}
|
||||
fill
|
||||
sizes="(min-width: 921px) 50vw, 100vw"
|
||||
src={image.src}
|
||||
/>
|
||||
</ImageInset>
|
||||
<MaskOverlay aria-hidden data-index={index}>
|
||||
<NextImage
|
||||
alt={mask.alt}
|
||||
fill
|
||||
sizes="(min-width: 921px) 50vw, 100vw"
|
||||
src={mask.src}
|
||||
/>
|
||||
</MaskOverlay>
|
||||
</VisualArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ImageType } from '@/design-system/components/Image/types/Image';
|
||||
import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { Tile } from '../Tile/Tile';
|
||||
|
||||
const TilesGrid = styled.div`
|
||||
display: grid;
|
||||
gap: ${theme.spacing(4)};
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
gap: ${theme.spacing(4)};
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
}
|
||||
`;
|
||||
|
||||
const TileCell = styled.div`
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
&[data-index='0'] {
|
||||
grid-column: span 12;
|
||||
}
|
||||
&[data-index='1'],
|
||||
&[data-index='2'],
|
||||
&[data-index='5'],
|
||||
&[data-index='6'] {
|
||||
grid-column: span 6;
|
||||
}
|
||||
&[data-index='3'] {
|
||||
grid-column: span 4;
|
||||
}
|
||||
&[data-index='4'] {
|
||||
grid-column: span 8;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type TilesProps = {
|
||||
mask: ImageType;
|
||||
tiles: FeatureTileType[];
|
||||
};
|
||||
|
||||
export function Tiles({ mask, tiles }: TilesProps) {
|
||||
return (
|
||||
<TilesGrid>
|
||||
{tiles.map((tile, index) => (
|
||||
<TileCell data-index={index} key={`${tile.heading.text}-${index}`}>
|
||||
<Tile index={index} mask={mask} tile={tile} />
|
||||
</TileCell>
|
||||
))}
|
||||
</TilesGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Intro } from './Intro/Intro';
|
||||
import { Root } from './Root/Root';
|
||||
import { Tile } from './Tile/Tile';
|
||||
import { Tiles } from './Tiles/Tiles';
|
||||
|
||||
export const Feature = { Intro, Root, Tile, Tiles };
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { EyebrowType } from '@/design-system/components/Eyebrow/types/Eyebrow';
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import type { ImageType } from '@/design-system/components/Image/types/Image';
|
||||
import type { FeatureTileType } from '@/sections/Feature/types/FeatureTile';
|
||||
|
||||
export type FeatureDataType = {
|
||||
eyebrow: EyebrowType;
|
||||
heading: HeadingType[];
|
||||
mask: ImageType;
|
||||
tiles: FeatureTileType[];
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { BodyType } from '@/design-system/components/Body/types/Body';
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import type { ImageType } from '@/design-system/components/Image/types/Image';
|
||||
|
||||
export type FeatureTileType = {
|
||||
bullets: BodyType[];
|
||||
heading: HeadingType;
|
||||
icon: string;
|
||||
image: ImageType;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { FeatureDataType } from './FeatureData';
|
||||
export type { FeatureTileType } from './FeatureTile';
|
||||
@@ -38,14 +38,18 @@ const Credit = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
type BottomProps = { children?: ReactNode };
|
||||
type BottomProps = {
|
||||
children?: ReactNode;
|
||||
copyright: string;
|
||||
credit: string;
|
||||
};
|
||||
|
||||
export function Bottom({ children }: BottomProps) {
|
||||
export function Bottom({ children, copyright, credit }: BottomProps) {
|
||||
return (
|
||||
<BottomGrid>
|
||||
<Copyright>© 2026 – Twenty</Copyright>
|
||||
<Copyright>{copyright}</Copyright>
|
||||
{children}
|
||||
<Credit>Design by Ardent.</Credit>
|
||||
<Credit>{credit}</Credit>
|
||||
</BottomGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
const FooterBackgroundCanvas = dynamic(
|
||||
() =>
|
||||
import('./FooterBackgroundCanvas').then((mod) => ({
|
||||
default: mod.FooterBackgroundCanvas,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
type FooterBackgroundProps = ComponentPropsWithoutRef<'div'> & {
|
||||
illustration: IllustrationType;
|
||||
};
|
||||
|
||||
export function FooterBackground({
|
||||
illustration,
|
||||
...rest
|
||||
}: FooterBackgroundProps) {
|
||||
return (
|
||||
<FooterBackgroundCanvas {...rest} illustration={illustration} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { LazyEmbed } from '@/design-system/components';
|
||||
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { FooterBackgroundCanvasRoot } from './FooterBackgroundCanvasRoot';
|
||||
|
||||
const FooterBackgroundEmbed = styled(LazyEmbed)`
|
||||
border: none;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type FooterBackgroundCanvasProps = ComponentPropsWithoutRef<'div'> & {
|
||||
illustration: IllustrationType;
|
||||
};
|
||||
|
||||
export function FooterBackgroundCanvas({
|
||||
illustration,
|
||||
...rest
|
||||
}: FooterBackgroundCanvasProps) {
|
||||
return (
|
||||
<FooterBackgroundCanvasRoot {...rest}>
|
||||
<FooterBackgroundEmbed
|
||||
allow="clipboard-write; encrypted-media; gyroscope; web-share"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
rootMargin="200px 0px"
|
||||
src={illustration.src}
|
||||
title={illustration.title}
|
||||
/>
|
||||
</FooterBackgroundCanvasRoot>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
export const FooterBackgroundCanvasRoot = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
& iframe {
|
||||
display: block;
|
||||
height: 100%;
|
||||
mix-blend-mode: color-dodge;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LinkButton } from '@/design-system/components';
|
||||
import { PlusIcon, RectangleFillIcon } from '@/icons';
|
||||
import { FOOTER_NAV_GROUPS } from '@/sections/Footer/constants/footer-nav-groups';
|
||||
import type { FooterNavGroupType } from '@/sections/Footer/types';
|
||||
import { theme } from '@/theme';
|
||||
import { NavigationMenu } from '@base-ui/react/navigation-menu';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -123,10 +123,14 @@ const Actions = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export function Nav() {
|
||||
type NavProps = {
|
||||
groups: FooterNavGroupType[];
|
||||
};
|
||||
|
||||
export function Nav({ groups }: NavProps) {
|
||||
return (
|
||||
<NavigationMenu.Root render={<FooterNav />}>
|
||||
{FOOTER_NAV_GROUPS.map((group, index) => (
|
||||
{groups.map((group, index) => (
|
||||
<React.Fragment key={group.id}>
|
||||
{index > 0 && (
|
||||
<NavDivider role="separator">
|
||||
@@ -172,22 +176,18 @@ export function Nav() {
|
||||
</NavigationMenu.Item>
|
||||
))}
|
||||
</NavMenuList>
|
||||
{group.showActions && (
|
||||
{group.ctas.length > 0 && (
|
||||
<Actions>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Talk to us"
|
||||
type="anchor"
|
||||
variant="contained"
|
||||
/>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href="https://app.twenty.com/welcome"
|
||||
label="Get started"
|
||||
type="anchor"
|
||||
variant="outlined"
|
||||
/>
|
||||
{group.ctas.map((cta) => (
|
||||
<LinkButton
|
||||
key={`${cta.label}-${cta.href}`}
|
||||
color={cta.color}
|
||||
href={cta.href}
|
||||
label={cta.label}
|
||||
type={cta.type}
|
||||
variant={cta.variant}
|
||||
/>
|
||||
))}
|
||||
</Actions>
|
||||
)}
|
||||
</NavGroup>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Container } from '@/design-system/components';
|
||||
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { FooterBackground } from '../FooterBackground/FooterBackground';
|
||||
import { FooterShape } from '../../FooterShape';
|
||||
|
||||
const FooterRoot = styled.footer`
|
||||
@@ -25,11 +27,6 @@ const FooterContainer = styled(Container)`
|
||||
}
|
||||
`;
|
||||
|
||||
const FooterBackground = styled.div`
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
`;
|
||||
|
||||
const FooterContent = styled.div`
|
||||
margin-top: auto;
|
||||
padding-bottom: ${theme.spacing(12)};
|
||||
@@ -51,13 +48,16 @@ const FooterContent = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
type RootProps = { children: ReactNode };
|
||||
type RootProps = {
|
||||
children: ReactNode;
|
||||
illustration: IllustrationType;
|
||||
};
|
||||
|
||||
export function Root({ children }: RootProps) {
|
||||
export function Root({ children, illustration }: RootProps) {
|
||||
return (
|
||||
<FooterRoot>
|
||||
<FooterContainer>
|
||||
<FooterBackground aria-hidden />
|
||||
<FooterBackground aria-hidden illustration={illustration} />
|
||||
<FooterContent>
|
||||
<FooterShape fillColor={theme.colors.primary.background[100]} />
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowRightUpIcon } from '@/icons';
|
||||
import { SOCIAL_LINKS } from '@/sections/Footer/constants/social-links';
|
||||
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';
|
||||
@@ -49,34 +49,46 @@ const SocialItem = styled.a`
|
||||
}
|
||||
`;
|
||||
|
||||
export function Social() {
|
||||
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">
|
||||
{SOCIAL_LINKS.filter((item) => item.showInDrawer).map((item, index) => (
|
||||
<React.Fragment key={item.href}>
|
||||
{index > 0 && <SocialDivider orientation="vertical" />}
|
||||
<SocialItem
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={item.ariaLabel}
|
||||
>
|
||||
<item.icon
|
||||
size={14}
|
||||
fillColor={theme.colors.secondary.background[100]}
|
||||
aria-hidden
|
||||
/>
|
||||
{item.label}
|
||||
{item.label != null && (
|
||||
<ArrowRightUpIcon
|
||||
size={8}
|
||||
strokeColor={theme.colors.highlight[100]}
|
||||
{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
|
||||
/>
|
||||
)}
|
||||
</SocialItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{item.label}
|
||||
{item.label != null && (
|
||||
<ArrowRightUpIcon
|
||||
size={8}
|
||||
strokeColor={theme.colors.highlight[100]}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</SocialItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</SocialContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
export const FOOTER_NAV_GROUPS = [
|
||||
{
|
||||
id: "footer-sitemap",
|
||||
title: "Sitemap",
|
||||
showActions: false,
|
||||
links: [
|
||||
{ label: "Home", href: "/", external: false },
|
||||
{ label: "Product", href: "/product", external: false },
|
||||
{ label: "Pricing", href: "/pricing", external: false },
|
||||
{ label: "Partners", href: "/partner", external: false },
|
||||
{ label: "Why Twenty", href: "/why-twenty", external: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "footer-help",
|
||||
title: "Help",
|
||||
showActions: false,
|
||||
links: [
|
||||
{ label: "Developers", href: "#", external: false },
|
||||
{ label: "FAQ", href: "#", external: false },
|
||||
{ label: "Support", href: "#", external: false },
|
||||
{ label: "Release Notes", href: "#", external: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "footer-legal",
|
||||
title: "Legal",
|
||||
showActions: false,
|
||||
links: [
|
||||
{ label: "Cookie Policy", href: "#", external: false },
|
||||
{ label: "Privacy Policy", href: "#", external: false },
|
||||
{ label: "Terms and Conditions", href: "#", external: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "footer-connect",
|
||||
title: "Connect",
|
||||
showActions: true,
|
||||
links: [
|
||||
{
|
||||
label: "LinkedIn",
|
||||
href: "https://www.linkedin.com/company/twenty",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,6 +0,0 @@
|
||||
export const NAV_ITEMS = [
|
||||
{ label: 'Product', href: '/product' },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Partners', href: '/partner' },
|
||||
{ label: 'Why Twenty', href: '/why-twenty' },
|
||||
];
|
||||
@@ -1,37 +0,0 @@
|
||||
import { DiscordIcon, GitHubIcon, LinkedInIcon, XIcon } from "@/icons"
|
||||
|
||||
export const SOCIAL_LINKS = [
|
||||
{
|
||||
href: "https://github.com/twentyhq/twenty",
|
||||
icon: GitHubIcon,
|
||||
label: "39.8K",
|
||||
ariaLabel: "GitHub (opens in new tab)",
|
||||
showInDesktop: true,
|
||||
showInDrawer: true,
|
||||
},
|
||||
{
|
||||
href: "https://discord.gg/cx5n4Jzs57",
|
||||
icon: DiscordIcon,
|
||||
label: "5.6K",
|
||||
ariaLabel: "Discord (opens in new tab)",
|
||||
className: "discord-link",
|
||||
showInDesktop: true,
|
||||
showInDrawer: true,
|
||||
},
|
||||
{
|
||||
href: "https://www.linkedin.com/company/twenty",
|
||||
icon: LinkedInIcon,
|
||||
label: undefined,
|
||||
ariaLabel: "LinkedIn (opens in new tab)",
|
||||
showInDesktop: false,
|
||||
showInDrawer: true,
|
||||
},
|
||||
{
|
||||
href: "https://x.com/twentycrm",
|
||||
icon: XIcon,
|
||||
label: undefined,
|
||||
ariaLabel: "X (opens in new tab)",
|
||||
showInDesktop: false,
|
||||
showInDrawer: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
export type FooterBottomType = {
|
||||
copyright: string;
|
||||
credit: string;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type FooterCtaType = {
|
||||
color: 'secondary';
|
||||
href: string;
|
||||
label: string;
|
||||
type: 'anchor';
|
||||
variant: 'contained' | 'outlined';
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
|
||||
import type { FooterBottomType } from './FooterBottom';
|
||||
import type { FooterNavGroupType } from './FooterNavGroup';
|
||||
import type { FooterSocialLinkType } from './FooterSocialLink';
|
||||
|
||||
export type FooterDataType = {
|
||||
bottom: FooterBottomType;
|
||||
illustration: IllustrationType;
|
||||
navGroups: FooterNavGroupType[];
|
||||
socialLinks: FooterSocialLinkType[];
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FooterCtaType } from './FooterCta';
|
||||
import type { FooterNavLinkType } from './FooterNavLink';
|
||||
|
||||
export type FooterNavGroupType = {
|
||||
ctas: FooterCtaType[];
|
||||
id: string;
|
||||
links: FooterNavLinkType[];
|
||||
title: string;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type FooterNavLinkType = {
|
||||
external: boolean;
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export type FooterSocialLinkType = {
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
label?: string;
|
||||
showInDesktop: boolean;
|
||||
showInDrawer: boolean;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { FooterBottomType } from './FooterBottom';
|
||||
export type { FooterCtaType } from './FooterCta';
|
||||
export type { FooterDataType } from './FooterData';
|
||||
export type { FooterNavGroupType } from './FooterNavGroup';
|
||||
export type { FooterNavLinkType } from './FooterNavLink';
|
||||
export type { FooterSocialLinkType } from './FooterSocialLink';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Heading, LinkButton } from '@/design-system/components';
|
||||
import { Body, Heading, LazyEmbed, LinkButton } from '@/design-system/components';
|
||||
import { CLIENT_ICONS } from '@/icons';
|
||||
import { HelpedCardShape } from '@/sections/Helped/HelpedCardShape';
|
||||
import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
|
||||
@@ -43,7 +43,7 @@ const VisualShell = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledIframe = styled.iframe`
|
||||
const StyledIframe = styled(LazyEmbed)`
|
||||
border: none;
|
||||
height: 200%;
|
||||
left: 51.5%;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LazyEmbed } from '@/design-system/components';
|
||||
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -10,6 +11,15 @@ const StyledIllustrationContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledIllustrationEmbed = styled(LazyEmbed)`
|
||||
border: none;
|
||||
display: block;
|
||||
height: 462px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type IllustrationProps = {
|
||||
illustration: IllustrationType;
|
||||
backgroundColor: string;
|
||||
@@ -21,20 +31,16 @@ export function Illustration({
|
||||
}: IllustrationProps) {
|
||||
return (
|
||||
<StyledIllustrationContainer>
|
||||
<iframe
|
||||
<StyledIllustrationEmbed
|
||||
eager
|
||||
src={illustration.src}
|
||||
title={illustration.title}
|
||||
unloadWhenHidden={false}
|
||||
allow="clipboard-write; encrypted-media; gyroscope; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
height: '462px',
|
||||
border: 'none',
|
||||
backgroundColor: backgroundColor,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</StyledIllustrationContainer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Image } from '@/design-system/components';
|
||||
import { Image, LazyEmbed } from '@/design-system/components';
|
||||
import { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
|
||||
import { ImageType } from '@/design-system/components/Image/types/Image';
|
||||
import { theme } from '@/theme';
|
||||
@@ -17,7 +17,7 @@ const StyledBackground = styled(Image)`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledIframe = styled.iframe`
|
||||
const StyledIframe = styled(LazyEmbed)`
|
||||
border: none;
|
||||
height: 200%;
|
||||
mix-blend-mode: lighten;
|
||||
@@ -38,8 +38,10 @@ export function WhyTwentyVisual({ image, illustration }: WhyTwentyVisualProps) {
|
||||
<VisualContainer>
|
||||
<StyledBackground src={image.src} alt={image.alt} />
|
||||
<StyledIframe
|
||||
eager
|
||||
src={illustration.src}
|
||||
title={illustration.title}
|
||||
unloadWhenHidden={false}
|
||||
allow="clipboard-write; encrypted-media; gyroscope; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
const PhraseGroup = styled.div`
|
||||
align-items: center;
|
||||
column-gap: ${theme.spacing(3)};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Segment = styled.span`
|
||||
flex-shrink: 0;
|
||||
font-weight: ${theme.font.weight.light};
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-segment-index='0'] {
|
||||
color: inherit;
|
||||
font-family: ${theme.font.family.serif};
|
||||
font-size: clamp(3rem, 9.6vw, 8.625rem);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&[data-segment-index='1'] {
|
||||
color: inherit;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: clamp(1.5rem, 4.9vw, 4.4375rem);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
&[data-segment-index='2'] {
|
||||
color: ${theme.colors.highlight[100]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: clamp(3rem, 9.6vw, 8.625rem);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
`;
|
||||
|
||||
type PhraseProps = {
|
||||
segments: HeadingType[];
|
||||
};
|
||||
|
||||
export function Phrase({ segments }: PhraseProps) {
|
||||
return (
|
||||
<PhraseGroup>
|
||||
{segments.map((segment, segmentIndex) => (
|
||||
<Segment
|
||||
key={`${segment.text}-${segmentIndex}`}
|
||||
data-segment-index={segmentIndex}
|
||||
>
|
||||
{segment.text}
|
||||
</Segment>
|
||||
))}
|
||||
</PhraseGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
import { Track } from '../Track/Track';
|
||||
|
||||
const StyledSection = styled.section`
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
padding-bottom: ${theme.spacing(20)};
|
||||
padding-top: ${theme.spacing(20)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Viewport = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 280px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type RootProps = {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
heading: HeadingType[];
|
||||
};
|
||||
|
||||
export function Root({ backgroundColor, color, heading }: RootProps) {
|
||||
return (
|
||||
<StyledSection style={{ backgroundColor, color }}>
|
||||
<Viewport>
|
||||
<Row>
|
||||
<Track heading={heading} reversed={false} />
|
||||
</Row>
|
||||
<Row>
|
||||
<Track heading={heading} reversed />
|
||||
</Row>
|
||||
</Viewport>
|
||||
</StyledSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
import { Phrase } from '../Phrase/Phrase';
|
||||
|
||||
const MARQUEE_REPEAT = 6;
|
||||
|
||||
const AnimatedTrack = styled.div`
|
||||
@keyframes marqueeMarqueeSlide {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
animation: marqueeMarqueeSlide 55s linear infinite;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: max-content;
|
||||
|
||||
&[data-reversed] {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
`;
|
||||
|
||||
const TrackHalf = styled.div`
|
||||
column-gap: ${theme.spacing(6)};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
type TrackProps = {
|
||||
heading: HeadingType[];
|
||||
reversed: boolean;
|
||||
};
|
||||
|
||||
export function Track({ heading, reversed }: TrackProps) {
|
||||
const phrases = Array.from({ length: MARQUEE_REPEAT }, (_, index) => (
|
||||
<Phrase key={index} segments={heading} />
|
||||
));
|
||||
|
||||
return (
|
||||
<AnimatedTrack data-reversed={reversed ? '' : undefined}>
|
||||
<TrackHalf>{phrases}</TrackHalf>
|
||||
<TrackHalf aria-hidden>{phrases}</TrackHalf>
|
||||
</AnimatedTrack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Root } from './Root/Root';
|
||||
|
||||
export const Marquee = { Root };
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
|
||||
export type MarqueeDataType = {
|
||||
heading: HeadingType[];
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type { MarqueeDataType } from './MarqueeData';
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Body, Heading } from '@/design-system/components';
|
||||
import type { PlanTableCalculatorDataType } from '@/sections/PlanTable/types';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
|
||||
const CALCULATOR_BORDER = '#feffb7';
|
||||
|
||||
const Shell = styled.div`
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid ${CALCULATOR_BORDER};
|
||||
border-radius: ${theme.radius(1)};
|
||||
display: grid;
|
||||
gap: ${theme.spacing(15)};
|
||||
overflow: clip;
|
||||
padding: ${theme.spacing(4)} ${theme.spacing(10)};
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
align-items: center;
|
||||
gap: ${theme.spacing(30)};
|
||||
grid-template-columns: minmax(0, 400px) minmax(0, 1fr);
|
||||
padding: ${theme.spacing(4)} ${theme.spacing(10)};
|
||||
}
|
||||
`;
|
||||
|
||||
const VisualColumn = styled.div`
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(2)};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const VisualFrame = styled.div`
|
||||
aspect-ratio: 400 / 227;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const BodyOnDark = styled.div`
|
||||
& p {
|
||||
color: ${theme.colors.secondary.text[80]};
|
||||
}
|
||||
`;
|
||||
|
||||
const ControlsColumn = styled.div`
|
||||
border-radius: ${theme.radius(1)};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SectionBlock = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(4)};
|
||||
padding: ${theme.spacing(4)} ${theme.spacing(8)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SectionTitleRow = styled.div`
|
||||
align-items: center;
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
display: flex;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(4)};
|
||||
justify-content: space-between;
|
||||
line-height: ${theme.spacing(5.5)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const FieldsRow = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.spacing(4)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Field = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 140px;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(1)};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const FieldLabel = styled.span`
|
||||
color: ${theme.colors.secondary.text[60]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(3)};
|
||||
line-height: ${theme.spacing(3.5)};
|
||||
`;
|
||||
|
||||
const FakeInput = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${theme.colors.secondary.background[100]};
|
||||
border: 1px solid ${theme.colors.secondary.border[20]};
|
||||
border-radius: ${theme.radius(1)};
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
display: flex;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(4)};
|
||||
justify-content: space-between;
|
||||
line-height: ${theme.spacing(5.5)};
|
||||
min-height: ${theme.spacing(11)};
|
||||
padding: ${theme.spacing(2)} ${theme.spacing(4)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StepperInner = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StepperBtn = styled.span`
|
||||
background-color: ${theme.colors.secondary.border[40]};
|
||||
border-radius: ${theme.radius(0.5)};
|
||||
display: grid;
|
||||
height: ${theme.spacing(7)};
|
||||
place-items: center;
|
||||
width: ${theme.spacing(7)};
|
||||
`;
|
||||
|
||||
const PriceFooter = styled.div`
|
||||
border-top: 1px solid ${theme.colors.secondary.border[10]};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(4)};
|
||||
padding: ${theme.spacing(5)} ${theme.spacing(8)} ${theme.spacing(8)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const PriceRow = styled.div`
|
||||
align-items: baseline;
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
display: flex;
|
||||
font-family: ${theme.font.family.sans};
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const PriceAmount = styled.span`
|
||||
font-size: ${theme.font.size(8)};
|
||||
line-height: ${theme.spacing(10)};
|
||||
`;
|
||||
|
||||
const PricePeriod = styled.span`
|
||||
color: ${theme.colors.secondary.text[60]};
|
||||
font-size: ${theme.font.size(4)};
|
||||
line-height: ${theme.spacing(5.5)};
|
||||
`;
|
||||
|
||||
type CalculatorEmbedProps = {
|
||||
calculator: PlanTableCalculatorDataType;
|
||||
};
|
||||
|
||||
export function CalculatorEmbed({ calculator }: CalculatorEmbedProps) {
|
||||
const { priceLine, sections, visual } = calculator;
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<VisualColumn>
|
||||
{visual.imageSrc ? (
|
||||
<VisualFrame>
|
||||
<NextImage
|
||||
alt={visual.imageAlt ?? ''}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 400px"
|
||||
src={visual.imageSrc}
|
||||
style={{ objectFit: 'contain' }}
|
||||
/>
|
||||
</VisualFrame>
|
||||
) : null}
|
||||
<Heading as="h3" segments={visual.heading} size="lg" weight="light" />
|
||||
<BodyOnDark>
|
||||
<Body
|
||||
body={visual.body}
|
||||
family="sans"
|
||||
size="md"
|
||||
weight="regular"
|
||||
/>
|
||||
</BodyOnDark>
|
||||
</VisualColumn>
|
||||
<ControlsColumn>
|
||||
{sections.map((section) => (
|
||||
<SectionBlock key={section.id}>
|
||||
<SectionTitleRow>
|
||||
<span>{section.title}</span>
|
||||
<span aria-hidden="true">☑</span>
|
||||
</SectionTitleRow>
|
||||
<FieldsRow>
|
||||
<Field>
|
||||
<FieldLabel>{section.requestField.label}</FieldLabel>
|
||||
<FakeInput>
|
||||
<StepperInner>
|
||||
<StepperBtn aria-hidden="true">‹</StepperBtn>
|
||||
<StepperBtn aria-hidden="true">›</StepperBtn>
|
||||
</StepperInner>
|
||||
<span>{section.requestField.value}</span>
|
||||
</FakeInput>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{section.tasksField.label}</FieldLabel>
|
||||
<FakeInput>
|
||||
<span>{section.tasksField.value}</span>
|
||||
<span aria-hidden="true">▾</span>
|
||||
</FakeInput>
|
||||
</Field>
|
||||
</FieldsRow>
|
||||
{section.modelField ? (
|
||||
<Field>
|
||||
<FieldLabel>{section.modelField.label}</FieldLabel>
|
||||
<FakeInput>
|
||||
<span>{section.modelField.value}</span>
|
||||
<span aria-hidden="true">▾</span>
|
||||
</FakeInput>
|
||||
</Field>
|
||||
) : null}
|
||||
</SectionBlock>
|
||||
))}
|
||||
<PriceFooter>
|
||||
<PriceRow>
|
||||
<span>{priceLine.label}</span>
|
||||
<span>
|
||||
<PriceAmount>{priceLine.amount}</PriceAmount>
|
||||
<PricePeriod> {priceLine.periodSuffix}</PricePeriod>
|
||||
</span>
|
||||
</PriceRow>
|
||||
</PriceFooter>
|
||||
</ControlsColumn>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { LinkButton } from '@/design-system/components';
|
||||
import { CheckIcon } from '@/icons';
|
||||
import type {
|
||||
PlanTableCellType,
|
||||
PlanTableDataType,
|
||||
PlanTableFeatureRowDataType,
|
||||
PlanTableTierColumnType,
|
||||
} from '@/sections/PlanTable/types';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { CalculatorEmbed } from '../CalculatorEmbed/CalculatorEmbed';
|
||||
|
||||
const TableScope = styled.div`
|
||||
align-items: center;
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(10)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const GridRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const HeadFeature = styled.div`
|
||||
border-bottom: 1px solid ${theme.colors.secondary.border[10]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(5.5)};
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: ${theme.spacing(7)};
|
||||
padding: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const HeadTier = styled.div`
|
||||
border-bottom: 1px solid ${theme.colors.secondary.border[10]};
|
||||
border-left: 1px solid ${theme.colors.secondary.border[10]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(5.5)};
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: ${theme.spacing(7)};
|
||||
padding: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const FeatureLabel = styled.div`
|
||||
border-bottom: 1px solid ${theme.colors.secondary.border[10]};
|
||||
color: ${theme.colors.secondary.text[60]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(4)};
|
||||
line-height: ${theme.spacing(8)};
|
||||
min-height: ${theme.spacing(16)};
|
||||
padding: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const TierCell = styled.div`
|
||||
border-bottom: 1px solid ${theme.colors.secondary.border[10]};
|
||||
border-left: 1px solid ${theme.colors.secondary.border[10]};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(4)};
|
||||
justify-content: center;
|
||||
line-height: ${theme.spacing(5.5)};
|
||||
min-height: ${theme.spacing(16)};
|
||||
padding: ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const TierText = styled.span`
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
`;
|
||||
|
||||
const YesRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${theme.spacing(4)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CategoryBand = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${theme.colors.secondary.border[10]};
|
||||
display: flex;
|
||||
min-height: ${theme.spacing(16)};
|
||||
padding: ${theme.spacing(2)} ${theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const CategoryTitle = styled.span`
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(5.5)};
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
line-height: ${theme.spacing(7)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CtaRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type CellValueProps = {
|
||||
cell: PlanTableCellType;
|
||||
};
|
||||
|
||||
function CellValue({ cell }: CellValueProps) {
|
||||
if (cell.kind === 'dash') {
|
||||
return <TierText>—</TierText>;
|
||||
}
|
||||
|
||||
if (cell.kind === 'text') {
|
||||
return <TierText>{cell.text}</TierText>;
|
||||
}
|
||||
|
||||
const label = cell.label ?? 'Yes';
|
||||
|
||||
return (
|
||||
<YesRow>
|
||||
<CheckIcon color={theme.colors.highlight[100]} size={16} />
|
||||
<TierText>{label}</TierText>
|
||||
</YesRow>
|
||||
);
|
||||
}
|
||||
|
||||
type FeatureRowProps = {
|
||||
row: PlanTableFeatureRowDataType;
|
||||
tierColumns: PlanTableTierColumnType[];
|
||||
};
|
||||
|
||||
function FeatureRow({ row, tierColumns }: FeatureRowProps) {
|
||||
return (
|
||||
<GridRow>
|
||||
<FeatureLabel>{row.featureLabel}</FeatureLabel>
|
||||
{tierColumns.map((column) => (
|
||||
<TierCell key={column.id}>
|
||||
<CellValue cell={row.tiers[column.id]} />
|
||||
</TierCell>
|
||||
))}
|
||||
</GridRow>
|
||||
);
|
||||
}
|
||||
|
||||
type CategoryRowProps = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
function CategoryRow({ title }: CategoryRowProps) {
|
||||
return (
|
||||
<GridRow>
|
||||
<CategoryBand>
|
||||
<CategoryTitle>{title}</CategoryTitle>
|
||||
</CategoryBand>
|
||||
<CategoryBand aria-hidden="true" />
|
||||
<CategoryBand aria-hidden="true" />
|
||||
</GridRow>
|
||||
);
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
data: PlanTableDataType;
|
||||
};
|
||||
|
||||
export function Content({ data }: ContentProps) {
|
||||
return (
|
||||
<TableScope>
|
||||
<GridRow>
|
||||
<HeadFeature>{data.featureColumnLabel}</HeadFeature>
|
||||
{data.tierColumns.map((column) => (
|
||||
<HeadTier key={column.id}>{column.label}</HeadTier>
|
||||
))}
|
||||
</GridRow>
|
||||
|
||||
{data.rows.map((row, rowIndex) => {
|
||||
if (row.type === 'category') {
|
||||
return (
|
||||
<CategoryRow
|
||||
key={`${row.title}-${rowIndex}`}
|
||||
title={row.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'calculator') {
|
||||
return (
|
||||
<CalculatorEmbed
|
||||
calculator={row.calculator}
|
||||
key={`calculator-${rowIndex}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureRow
|
||||
key={`${row.featureLabel}-${rowIndex}`}
|
||||
row={row}
|
||||
tierColumns={data.tierColumns}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<CtaRow>
|
||||
<LinkButton
|
||||
color="secondary"
|
||||
href={data.seeMoreFeaturesCta.href}
|
||||
label={data.seeMoreFeaturesCta.label}
|
||||
type="link"
|
||||
variant="outlined"
|
||||
/>
|
||||
</CtaRow>
|
||||
</TableScope>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Container } from '@/design-system/components';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const StyledSection = styled.section`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled(Container)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(10)};
|
||||
min-width: 0;
|
||||
padding-bottom: ${theme.spacing(22)};
|
||||
padding-left: ${theme.spacing(4)};
|
||||
padding-right: ${theme.spacing(4)};
|
||||
padding-top: ${theme.spacing(22)};
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
gap: ${theme.spacing(10)};
|
||||
padding-left: ${theme.spacing(10)};
|
||||
padding-right: ${theme.spacing(10)};
|
||||
}
|
||||
`;
|
||||
|
||||
type RootProps = {
|
||||
backgroundColor: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Root({ backgroundColor, children }: RootProps) {
|
||||
return (
|
||||
<StyledSection style={{ backgroundColor }}>
|
||||
<StyledContainer>{children}</StyledContainer>
|
||||
</StyledSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Content } from './Content/Content';
|
||||
import { Root } from './Root/Root';
|
||||
|
||||
export const PlanTable = {
|
||||
Content,
|
||||
Root,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BodyType } from '@/design-system/components/Body/types/Body';
|
||||
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
|
||||
import type { PlansTierId } from '@/sections/Plans/types';
|
||||
|
||||
export type PlanTableTierColumnType = {
|
||||
id: PlansTierId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type PlanTableCellType =
|
||||
| { kind: 'dash' }
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'yes'; label?: string };
|
||||
|
||||
export type PlanTableCategoryRowDataType = {
|
||||
title: string;
|
||||
type: 'category';
|
||||
};
|
||||
|
||||
export type PlanTableFeatureRowDataType = {
|
||||
featureLabel: string;
|
||||
tiers: Record<PlansTierId, PlanTableCellType>;
|
||||
type: 'row';
|
||||
};
|
||||
|
||||
export type PlanTableCalculatorSectionDataType = {
|
||||
id: string;
|
||||
modelField?: {
|
||||
label: string;
|
||||
options: string[];
|
||||
value: string;
|
||||
};
|
||||
requestField: {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
tasksField: {
|
||||
label: string;
|
||||
options: string[];
|
||||
value: string;
|
||||
};
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type PlanTableCalculatorDataType = {
|
||||
priceLine: {
|
||||
amount: string;
|
||||
label: string;
|
||||
periodSuffix: string;
|
||||
};
|
||||
sections: PlanTableCalculatorSectionDataType[];
|
||||
visual: {
|
||||
body: BodyType;
|
||||
heading: HeadingType[];
|
||||
imageAlt?: string;
|
||||
imageSrc?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PlanTableCalculatorEmbedDataType = {
|
||||
calculator: PlanTableCalculatorDataType;
|
||||
type: 'calculator';
|
||||
};
|
||||
|
||||
export type PlanTableBodyRowDataType =
|
||||
| PlanTableCalculatorEmbedDataType
|
||||
| PlanTableCategoryRowDataType
|
||||
| PlanTableFeatureRowDataType;
|
||||
|
||||
export type PlanTableDataType = {
|
||||
featureColumnLabel: string;
|
||||
rows: PlanTableBodyRowDataType[];
|
||||
seeMoreFeaturesCta: {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
tierColumns: PlanTableTierColumnType[];
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
PlanTableBodyRowDataType,
|
||||
PlanTableCalculatorDataType,
|
||||
PlanTableCalculatorEmbedDataType,
|
||||
PlanTableCalculatorSectionDataType,
|
||||
PlanTableCategoryRowDataType,
|
||||
PlanTableCellType,
|
||||
PlanTableDataType,
|
||||
PlanTableFeatureRowDataType,
|
||||
PlanTableTierColumnType,
|
||||
} from './PlanTableData';
|
||||