Complete the structure for homepage in new website. (#19162)

This PR completes the sections we need for the Homepage. Assets, such as
images, are still placeholder, and will be replaced as they become
available. We're still waiting on Lottie and 3d asset files from the
design team.
This commit is contained in:
Abdullah.
2026-03-31 22:19:50 +05:00
committed by GitHub
parent c23961fa81
commit 94e019f012
77 changed files with 2209 additions and 479 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,56 @@
import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
export const HELPED_DATA: HelpedDataType = {
eyebrow: {
heading: {
text: 'Twenty helped them',
fontFamily: 'sans',
},
},
heading: [
{
text: 'Make your GTM team happy with a',
fontFamily: 'serif',
},
{
text: " CRM they'll love",
fontFamily: 'sans',
},
],
cards: [
{
icon: 'realytics',
heading: { text: 'Increase lead qualification', fontFamily: 'sans' },
body: {
text: 'Realytics built lead scoring into their CRM and increased qualified outbound by 40%.',
},
illustration: {
src: 'https://app.endlesstools.io/embed/54cb2a73-baef-4eff-9957-17d262b5bbaf',
title: '',
},
},
{
icon: 'beagle',
heading: { text: 'Build performing onboarding', fontFamily: 'sans' },
body: {
text: 'Beagle launched a custom onboarding pipeline and shortened their sales cycle by 30%.',
},
illustration: {
src: 'https://app.endlesstools.io/embed/0988b17d-7648-4b95-bd6b-8fab1e798da9',
title: '',
},
},
{
icon: 'evergreen',
heading: { text: 'Unify workflow', fontFamily: 'sans' },
body: {
text: 'Evergreen unified sales and CS workflows, boosting expansion revenue by 25%.',
},
illustration: {
src: 'https://app.endlesstools.io/embed/18fc3259-eb0d-4254-961c-0fc26aa7b205',
title: '',
},
},
],
};
@@ -0,0 +1,51 @@
import type { HomeStepperDataType } from '@/sections/HomeStepper/types';
export const HOME_STEPPER_DATA: HomeStepperDataType = {
steps: [
{
heading: [
{
text: 'Begin with production-grade',
fontFamily: 'serif',
},
{
text: ' building blocks',
fontFamily: 'sans',
},
],
body: {
text: 'Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.',
},
},
{
heading: [
{
text: 'Continue iteration',
fontFamily: 'serif',
},
{
text: ' without friction',
fontFamily: 'sans',
},
],
body: {
text: 'Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.',
},
},
{
heading: [
{
text: 'Stay in control with our',
fontFamily: 'serif',
},
{
text: ' open-source software',
fontFamily: 'sans',
},
],
body: {
text: "Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.",
},
},
],
};
@@ -1,101 +0,0 @@
import type { HeroStepperDataType } from '@/sections/HeroStepper/types';
export const STEPPER_DATA: HeroStepperDataType = {
steps: [
{
heading: [
{
text: 'Begin with production-grade',
fontFamily: 'serif',
},
{
text: ' building blocks',
fontFamily: 'sans',
},
],
body: {
text: 'Compose your CRM and internal apps with a single extensibility toolkit. Data model, layout, and automation.',
},
images: [
{
src: '/images/home/stepper/step-one-one.png',
alt: 'Twenty modules for logic, data model, and layout',
},
{
src: '/images/home/stepper/step-one-two.png',
alt: 'Twenty data model and layout building blocks',
},
{
src: '/images/home/stepper/step-one-three.png',
alt: 'Twenty automation and extensibility toolkit',
},
],
},
{
heading: [
{
text: 'Continue iteration',
fontFamily: 'serif',
},
{
text: ' without friction',
fontFamily: 'sans',
},
],
body: {
text: 'Enjoy unlimited customization using the AI coding tools you already love. Adapt your CRM to fit the way your business grows and wins.',
},
images: [
{
src: '/images/home/stepper/step-two-one.png',
alt: 'Twenty CRM adapted with familiar development tools',
},
{
src: '/images/home/stepper/step-two-two.png',
alt: 'Customizing Twenty with AI-assisted coding workflows',
},
{
src: '/images/home/stepper/step-two-three.png',
alt: 'Iterating on Twenty as your business grows',
},
{
src: '/images/home/stepper/step-two-four.png',
alt: 'Twenty workspace tailored to your team',
},
],
},
{
heading: [
{
text: 'Stay in control with our',
fontFamily: 'serif',
},
{
text: ' open-source software',
fontFamily: 'sans',
},
],
body: {
text: "Don't get locked into someone else's ecosystem. Twenty's developer experience looks like normal software, with local setup, real data, live testing, and no proprietary tooling.",
},
images: [
{
src: '/images/home/stepper/step-three-one.png',
alt: 'Twenty open-source CRM in a normal local development setup',
},
{
src: '/images/home/stepper/step-three-two.png',
alt: 'Twenty developer experience with real data and live testing',
},
{
src: '/images/home/stepper/step-three-three.png',
alt: 'Twenty without proprietary vendor lock-in',
},
{
src: '/images/home/stepper/step-three-four.png',
alt: 'Twenty as transparent, self-hosted software',
},
],
},
],
};
@@ -0,0 +1,58 @@
import type { TestimonialsDataType } from '@/sections/Testimonials/types';
export const TESTIMONIALS_DATA: TestimonialsDataType = {
eyebrow: {
heading: { text: 'They are the real sales', fontFamily: 'sans' },
},
testimonials: [
{
heading: {
text: "YES @twentycrm has json object fields. Such a simple feature and yet so many CRMs don't. It allows me to add data without caring too much about the data model, and eventually make it cleaner if the model gets stable.",
fontFamily: 'sans',
},
author: {
avatar: {
src: '/images/home/testimonials/avatar-one.png',
alt: '',
},
name: { text: 'Wayne Hamadi' },
handle: { text: '@wayne_hamadi' },
date: new Date('2024-05-23'),
},
},
{
heading: {
text: 'We switched from a rigid CRM where every custom field felt like a ticket. Twenty lets our team model how we actually sell without waiting on consultants.',
fontFamily: 'sans',
},
author: {
avatar: {
src: '/images/home/testimonials/avatar-two.png',
alt: '',
},
name: { text: 'Priya Nandakumar' },
handle: { text: '@priya_builds' },
date: new Date('2024-07-11'),
},
},
{
heading: {
text: 'Open source was the hook; the product is what kept us. Pipelines, people, and reporting finally live in one place our engineers are happy to extend.',
fontFamily: 'sans',
},
author: {
avatar: {
src: '/images/home/testimonials/avatar-three.png',
alt: '',
},
name: { text: 'Marcus Chen' },
handle: { text: '@marcus_ops' },
date: new Date('2024-09-02'),
},
},
],
illustration: {
src: 'https://app.endlesstools.io/embed/61c7255f-cf10-46f6-ac99-91047fd76d75',
title: 'Endless Tools Editor',
},
};
@@ -1,16 +1,22 @@
import { FAQ_DATA } from '@/app/(home)/constants/faq';
import { HELPED_DATA } from '@/app/(home)/constants/helped';
import { HERO_DATA } from '@/app/(home)/constants/hero';
import { HOME_STEPPER_DATA } from '@/app/(home)/constants/home-stepper';
import { MENU_DATA } from '@/app/(home)/constants/menu';
import { PROBLEM_DATA } from '@/app/(home)/constants/problem';
import { TESTIMONIALS_DATA } from '@/app/(home)/constants/testimonials';
import { THREE_CARDS_FEATURE_DATA } from '@/app/(home)/constants/three-cards-feature';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/(home)/constants/three-cards-illustration';
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { Faq } from '@/sections/Faq/components';
import { Helped } from '@/sections/Helped/components';
import { Hero } from '@/sections/Hero/components';
import { HomeStepper } from '@/sections/HomeStepper/components';
import { Menu } from '@/sections/Menu/components';
import { Problem } from '@/sections/Problem/components';
import { Testimonials } from '@/sections/Testimonials/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
@@ -86,7 +92,7 @@ export default function HomePage() {
/>
</ThreeCards.Root>
{/* <HeroStepper.Flow steps={STEPPER_DATA.steps} /> */}
<HomeStepper.ScrollSection steps={HOME_STEPPER_DATA.steps} />
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Home} align="center">
@@ -105,12 +111,25 @@ export default function HomePage() {
/>
</ThreeCards.Root>
<Helped.Root backgroundColor={theme.colors.secondary.background[5]}>
<Helped.Scene data={HELPED_DATA} />
</Helped.Root>
<Testimonials.Root
backgroundColor={theme.colors.secondary.background[5]}
color={theme.colors.primary.text[100]}
shapeFillColor={theme.colors.primary.background[100]}
>
<Testimonials.Carousel
eyebrow={TESTIMONIALS_DATA.eyebrow}
illustration={TESTIMONIALS_DATA.illustration}
testimonials={TESTIMONIALS_DATA.testimonials}
/>
</Testimonials.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow
colorScheme="secondary"
heading={FAQ_DATA.eyebrow.heading}
/>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
<Faq.Heading segments={FAQ_DATA.heading} />
<Faq.Cta>
<LinkButton
@@ -0,0 +1,58 @@
import type { TestimonialsDataType } from '@/sections/Testimonials/types';
export const TESTIMONIALS_DATA: TestimonialsDataType = {
eyebrow: {
heading: { text: 'Join our growing partner ecosystem', fontFamily: 'sans' },
},
testimonials: [
{
heading: {
text: 'Our clients ask for a CRM they can trust and shape. Twenty gives us a credible stack to implement fast, without hiding the data model behind opaque APIs.',
fontFamily: 'sans',
},
author: {
avatar: {
src: '/images/partner/testimonials/avatar-four.png',
alt: '',
},
name: { text: 'Elena Vasquez' },
handle: { text: '@elena_integrations' },
date: new Date('2024-04-18'),
},
},
{
heading: {
text: 'Partnering around an open codebase changed how we scope projects. We ship integrations with clear ownership and fewer surprises at go-live.',
fontFamily: 'sans',
},
author: {
avatar: {
src: '/images/partner/testimonials/avatar-five.png',
alt: '',
},
name: { text: 'Jordan Okonkwo' },
handle: { text: '@jordan_solutions' },
date: new Date('2024-06-27'),
},
},
{
heading: {
text: 'We needed something modern for mid-market teams that outgrow spreadsheets but dread enterprise lock-in. Twenty hits that gap for the customers we serve.',
fontFamily: 'sans',
},
author: {
avatar: {
src: '/images/partner/testimonials/avatar-two.png',
alt: '',
},
name: { text: 'Samira Haddad' },
handle: { text: '@samira_revenue' },
date: new Date('2024-08-14'),
},
},
],
illustration: {
src: 'https://app.endlesstools.io/embed/48586814-c307-4abd-8eec-0270ef3e91ce',
title: 'Endless Tools Editor',
},
};
@@ -3,6 +3,7 @@ import { MENU_DATA } from '@/app/(home)/constants/menu';
import { TRUSTED_BY_DATA } from '@/app/(home)/constants/trusted-by';
import { ENGAGEMENT_BAND_DATA } from '@/app/partner/constants/engagement-band';
import { HERO_DATA } from '@/app/partner/constants/hero';
import { TESTIMONIALS_DATA } from '@/app/partner/constants/testimonials';
import { 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';
@@ -10,6 +11,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 { Testimonials } from '@/sections/Testimonials/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
@@ -104,6 +106,17 @@ export default function PartnerPage() {
/>
</ThreeCards.Root>
<Testimonials.Root
backgroundColor={theme.colors.secondary.background[5]}
color={theme.colors.primary.text[100]}
>
<Testimonials.Carousel
eyebrow={TESTIMONIALS_DATA.eyebrow}
illustration={TESTIMONIALS_DATA.illustration}
testimonials={TESTIMONIALS_DATA.testimonials}
/>
</Testimonials.Root>
<Faq.Root illustration={FAQ_DATA.illustration}>
<Faq.Intro>
<Eyebrow colorScheme="secondary" heading={FAQ_DATA.eyebrow.heading} />
@@ -0,0 +1,12 @@
import type { QuoteDataType } from '@/sections/Quote/types/QuoteData';
export const QUOTE_DATA: QuoteDataType = {
illustration: {
src: 'https://app.endlesstools.io/embed/48586814-c307-4abd-8eec-0270ef3e91ce',
title: 'Endless Tools Editor',
},
heading: [
{ text: '“In the AI age,\n', fontFamily: 'serif' },
{ text: ' convergence accelerates”', fontFamily: 'sans' },
],
};
@@ -0,0 +1,8 @@
import type { StatementDataType } from '@/sections/Statement/types';
export const STATEMENT_ONE: StatementDataType = {
heading: {
text: 'Lorem ipsum dolor sit amet, consecteturLorem ipsum dolor sit amet, consectetur oremipsum dolor sit amet, conse.',
fontFamily: 'serif',
},
};
@@ -0,0 +1,8 @@
import type { StatementDataType } from '@/sections/Statement/types';
export const STATEMENT_TWO: StatementDataType = {
heading: {
text: 'Lorem ipsum dolor sit amet, consectetur. Lorem ipsum dolor sit amet, consectetur oremipsum dolor sit amet, conse.',
fontFamily: 'serif',
},
};
@@ -1,8 +1,13 @@
import { MENU_DATA } from '@/app/(home)/constants/menu';
import { HERO_DATA } from '@/app/why-twenty/constants/hero';
import { QUOTE_DATA } from '@/app/why-twenty/constants/quote';
import { STATEMENT_ONE } from '@/app/why-twenty/constants/statement-one';
import { STATEMENT_TWO } from '@/app/why-twenty/constants/statement-two';
import { Pages } from '@/enums/pages';
import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { Quote } from '@/sections/Quote/components';
import { Statement } from '@/sections/Statement/components';
import { theme } from '@/theme';
export default function WhyTwentyPage() {
@@ -32,6 +37,25 @@ export default function WhyTwentyPage() {
illustration={HERO_DATA.illustration}
/>
</Hero.Root>
<Quote.Root backgroundColor={theme.colors.secondary.background[100]}>
<Quote.Visual illustration={QUOTE_DATA.illustration} />
<Quote.Heading segments={QUOTE_DATA.heading} />
</Quote.Root>
<Statement.Root
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
>
<Statement.Heading segments={STATEMENT_ONE.heading} />
</Statement.Root>
<Statement.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.primary.text[100]}
>
<Statement.Heading segments={STATEMENT_TWO.heading} />
</Statement.Root>
</>
);
}
@@ -0,0 +1,30 @@
const BEAGLE_VIEWBOX_WIDTH = 104;
const BEAGLE_VIEWBOX_HEIGHT = 25;
const BEAGLE_PATH_1 =
'M9.812 24.614h-.649l-1.43-1.982 1.394-4.23c-.184-.802-.777-1.859-1.548-2.844L6.056 22 0 22.19V0h19.257c1.34-.006 2.612.116 3.774.31l-.08 21.751-3.645-.054-1.946-6.218c-1.376.243-2.196-.018-3.4-.395-1.09.56-2.576 1.842-3.549 2.826l1.004 4.565-1.61 1.817zm.183-8.618c1.395-.255 2.557-.802 3.506-1.3-1.322-4.735 1.43-4.145.159-8.892 3.12 1.222-1.75 7.834 1.602 9.165.459.182 1.615.097 2.098-.17 1.958-1.088 2.148-3.44 1.56-5.488-1.278-4.473-5.303-6.99-9.928-5.567-1.59.486-3.138 1.89-2.832 3.55l.801-.736c.147-.134.747.012.74.212l-.018.645-3.756.923c.863.34 1.56.584 2.062 1.046l-1.652 1.319c-.067 1.209 1.205 2.218 2.239 2.522 1.162.34 2.808.23 3.432-.687.012-.292-.172-1.185.08-1.124l1.296.328c-.801 1.5-2.11 1.969-3.835 2.735.489.522 1.761 1.653 2.44 1.531zM4.84 13.03 3.383 9.967c-.44.644-.44 1.41-.147 2.09.19.444.911.864 1.597.973zm79.937-9.34L88.711 2c.165-.073.728-.025.85.073.16.127.416.59.416.88v12.569c0 1.604 1.395 1.629 1.199 2.443-.055.219-.68.492-.96.492l-5.01.019c-.019 5.7-13.55 5.038-14.033 1.653-.16-1.1.997-1.18 1.321-2.249.57-1.872-3.542 2.838-6.356-.486-2.697 1.677-6.404 2.212-8.13-1.124-2.33 2.364-5.737 3.214-8.826 1.963-2.08-.845-3.04-2.632-3.444-4.978-.483 2.474-1.72 4.394-4.148 5.202-1.92.645-4.025.31-5.945-.486-.887-.37-1.573.772-2.882.614-.25-.03-.501-.669-.501-.954l.012-10.994c0-1.653-1.334-1.155-1.566-2.2-.043-.195.343-.748.55-.839l3.72-1.556c.195-.079.862-.073 1.04.037.226.14.397.686.403.978l.104 5.956c1.946-1.635 3.677-2.157 5.744-1.568 2.068.59 2.973 2.425 3.512 4.88.862-3.786 4.367-5.725 8.062-4.935 2.355.505 4.172 2.407 3.762 4.923l-6.533 2.079c1.095 1.98 3.487 2.23 5.426 1.045.349-.213 1.034-.462 1.554-.68.948-3.216 3.798-3.125 6.85-2.936.209-1.216-1.063-2.437-2.477-2.115-1.712.395-3.034 1.234-3.364.535-.703-1.459 2.508-3.367 6.472-2.942 1.853.2 3.315 1.404 3.578 3.27l.606 4.224c.08.559 1.438 1.5 1.756 1.063l1.553-2.17c-.88-.875-1.535-1.604-1.553-2.594-.05-3.282 4.66-3.805 7.12-4.103.6-1.215.936-3.166 2.539-3.561 1.174-.292 2.104.54 3.125 1.495l.52-1.24zM74.672 14.561c3.548 1.841 6.551-.499 10.112 2.4.232.195.984-.522.984-.93l-.036-9.942c0-.42-.985-1.355-1.09-1.082-.079.213-.378 1.015-.61 1.07l-3.683.82c.287 1.203 3.321.796 3.413 3.586.043 1.282-.795 2.291-1.988 2.953-2.208 1.234-4.533.547-7.102 1.125m4.967-3.786a2.017 2.017 0 0 0-2.025-2.012c-1.12 0-2.025.9-2.025 2.011 0 1.113.906 2.012 2.025 2.012 1.12 0 2.025-.9 2.025-2.012m-26.236.589c.287-.42-.227-1.489-.618-1.811-.343-.28-1.517-.383-1.866-.073-.838.73-1.107 2.291-.544 3.075.984-.146 2.422-.601 3.028-1.191m-16.199 4.364c.508.79 1.597 1.209 2.362 1.033 1.86-.426 2.208-4.157.428-6.078-.373-.4-1.322-.753-1.774-.632-2 .541-2.245 3.75-1.016 5.677m28.36-.809a1.56 1.56 0 0 0-1.566-1.556c-.863 0-1.566.7-1.566 1.556s.703 1.556 1.566 1.556c.862 0 1.566-.699 1.566-1.556m15.53 5.039c-.146-.11-.647-.493-.862-.517l-5.254-.602c-.325-.036-.863.766-.716 1.033 1.224 2.213 8.044 1.01 6.827.085zm15.576-5.59c.88 2.243 3.773 2.146 5.468.936.269-.188.948-.273 1.211-.23.263.042.196.947.006 1.172-2.159 2.595-6.386 3.403-9.402 1.61-2.123-1.257-2.881-3.603-2.477-5.864s2.183-4.023 4.606-4.576c2.006-.462 4.117-.183 5.67 1.19 1.083.961 1.683 2.353 1.267 3.739l-6.35 2.03zm2.415-2.814c-.152-.93-.501-1.786-.899-2.048-.434-.285-1.584-.291-1.908.08a2.82 2.82 0 0 0-.416 3.05l3.223-1.075z';
const BEAGLE_PATH_2 =
'm11.977 8.187-.868-.62c-.19-.134-.618.048-1.187.152 0-.134.202-.766.373-1.015.202-.286 1.18-.22 1.389.066.208.286.312.96.293 1.417';
type BeagleIconProps = {
fillColor: string;
size: number;
};
export function BeagleIcon({ fillColor, size }: BeagleIconProps) {
const height = size * (BEAGLE_VIEWBOX_HEIGHT / BEAGLE_VIEWBOX_WIDTH);
return (
<svg
fill="none"
height={height}
viewBox={`0 0 ${BEAGLE_VIEWBOX_WIDTH} ${BEAGLE_VIEWBOX_HEIGHT}`}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<path d={BEAGLE_PATH_1} fill={fillColor} />
<path d={BEAGLE_PATH_2} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,26 @@
const EVERGREEN_VIEWBOX_WIDTH = 134;
const EVERGREEN_VIEWBOX_HEIGHT = 16;
const EVERGREEN_PATH =
'M75.483.012v15.632h4.756V10.21h2.598c.688 0 1.305.561 1.305 1.276v4.157h4.76V9.647c0-.946-.498-1.166-.874-1.196.376-.006.874-.272.874-1.227V2.253c0-1.83-.798-2.242-2.041-2.242zM80.24 3.88h3.183c.481 0 .72.294.72.794v1.29c0 .498-.24.73-.72.73H80.24zM105.135.012h13.081v3.867h-8.324v2.11h8.324v3.483h-8.324v2.322h8.324v3.85h-13.081zm-14.601 0h13.08v3.867h-8.323v2.11h8.323v3.483h-8.323v2.322h8.323v3.85h-13.08zM0 .012h13.08v3.867H4.757v2.11h8.323v3.483H4.757v2.322h8.323v3.85H0zm119.687 0h4.9l4.438 8.352V.012h4.931v15.632h-4.931l-4.438-8.51v8.51h-4.9zM61.176 15.36c-.32-.172-.74-.535-.934-.808-.34-.48-.352-.703-.352-6.716 0-8.465-.568-7.825 6.965-7.825 4.514 0 5.163.033 5.685.306.96.502 1.276 1.274 1.278 3.122l.002 1.583-4.725.02V4.55c0-.327-.374-.67-.69-.67h-2.987c-.344 0-.74.39-.74.747V11c0 .368.387.793.742.793h2.972c.347 0 .703-.439.703-.8V9.86h-2.548V6.62l7.273-.014v3.605c0 3.936-.071 4.293-.99 4.998-.458.351-.707.39-5.775.435-4.79.043-5.352-.002-5.879-.284M44.857.012v15.632h4.756V10.21h2.598c.688 0 1.305.561 1.305 1.276v4.157h4.76V9.647c0-.946-.498-1.166-.874-1.196.376-.006.874-.272.874-1.227V2.253c0-1.83-.798-2.242-2.041-2.242zm4.757 3.868h3.183c.481 0 .72.294.72.794v1.29c0 .498-.24.73-.72.73h-3.183zM30.248.012h13.08v3.867h-8.324v2.11h8.324v3.483h-8.324v2.322h8.324v3.85h-13.08zm-16.344 0h5.08l2.574 10.023L24.144.012h5.1l-5.006 15.632h-5.361z';
type EvergreenIconProps = {
fillColor: string;
size: number;
};
export function EvergreenIcon({ fillColor, size }: EvergreenIconProps) {
const height = size * (EVERGREEN_VIEWBOX_HEIGHT / EVERGREEN_VIEWBOX_WIDTH);
return (
<svg
fill="none"
height={height}
viewBox={`0 0 ${EVERGREEN_VIEWBOX_WIDTH} ${EVERGREEN_VIEWBOX_HEIGHT}`}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<path d={EVERGREEN_PATH} fill={fillColor} />
</svg>
);
}
@@ -0,0 +1,26 @@
const REALYTICS_VIEWBOX_WIDTH = 115;
const REALYTICS_VIEWBOX_HEIGHT = 27;
const REALYTICS_PATH =
'm50.998 21.111-2.184-4.264h-1.32v4.264H44.17V8.397h4.803c2.731 0 5.074.939 5.074 4.106a3.91 3.91 0 0 1-2.052 3.676l2.75 4.926zm-3.47-6.922h1.207c1.32 0 2.032-.589 2.032-1.68s-.7-1.447-2.032-1.447h-1.208zm12.29-3.009c2.751 0 4.117 1.984 4.117 4.629a5.7 5.7 0 0 1-.099 1.243h-5.792a2.34 2.34 0 0 0 2.52 2.116 3.9 3.9 0 0 0 2.091-.662l.957 1.74a6.3 6.3 0 0 1-3.437 1.097c-2.771 0-4.896-1.878-4.896-5.085s2.237-5.077 4.54-5.077m1.676 4.087c0-1.118-.468-1.898-1.616-1.898-.898 0-1.676.608-1.874 1.898zm8.846-.391a1.325 1.325 0 0 0-.934-1.369 1.3 1.3 0 0 0-.59-.04c-.92.06-1.808.363-2.573.88l-1.016-1.924a8 8 0 0 1 4.137-1.27c2.48 0 3.846 1.408 3.846 4.384v5.554h-2.342l-.218-.999h-.06a4.25 4.25 0 0 1-2.856 1.256 2.78 2.78 0 0 1-2.078-.836 2.8 2.8 0 0 1-.792-2.1c.027-2.022 1.65-3.146 5.476-3.536m-1.603 4.244a2.15 2.15 0 0 0 1.603-.82v-1.699c-2.052.271-2.711.86-2.711 1.6-.007.628.422.92 1.108.92m6.307-11.618h3.358v10.288c0 .661.31.86.548.86q.177.005.35-.04l.389 2.466a4.1 4.1 0 0 1-1.637.271c-2.263 0-3.001-1.487-3.001-3.676zm5.885 16.371c1.075 0 1.755-.86 2.144-2.036l.218-.701-3.807-9.528h1.656l1.933 5.29c.29.82.627 1.798.917 2.678h.08c.27-.86.547-1.839.797-2.678l1.703-5.29h1.563l-3.576 10.288c-.66 1.878-1.642 3.306-3.549 3.306a3.1 3.1 0 0 1-1.115-.198l.31-1.27q.354.118.726.14m8.807-10.95h-1.405v-1.21l1.485-.1.197-2.644h1.32v2.645h2.553v1.322h-2.533v5.29c0 1.177.37 1.838 1.464 1.838a3.4 3.4 0 0 0 1.076-.252l.31 1.21a5.8 5.8 0 0 1-1.755.35c-2.032 0-2.712-1.322-2.712-3.166zm5.543-4.304a1.12 1.12 0 0 1 1.11-1.032 1.114 1.114 0 0 1 1.112 1.032 1.12 1.12 0 0 1-1.111 1.031 1.114 1.114 0 0 1-1.112-1.031m.29 2.995h1.603v9.508h-1.604zm8.181-.231a3.9 3.9 0 0 1 2.771 1.137l-.798 1.038a2.87 2.87 0 0 0-1.913-.84c-1.716 0-2.969 1.488-2.969 3.676s1.187 3.637 2.929 3.637a3.38 3.38 0 0 0 2.23-.966l.719 1.058a4.7 4.7 0 0 1-3.081 1.23c-2.52 0-4.447-1.818-4.447-4.965 0-3.148 2.118-5.005 4.559-5.005m4.288 7.59a4.26 4.26 0 0 0 2.791 1.137c1.247 0 1.874-.661 1.874-1.488 0-.978-1.135-1.408-2.164-1.798-1.32-.49-2.83-1.137-2.83-2.737s1.207-2.698 3.259-2.698a4.73 4.73 0 0 1 2.949 1.071l-.759 1.018a3.52 3.52 0 0 0-2.164-.84c-1.187 0-1.735.662-1.735 1.37 0 .879 1.036 1.23 2.105 1.646 1.365.509 2.889 1.077 2.889 2.876 0 1.547-1.227 2.836-3.477 2.836a5.7 5.7 0 0 1-3.529-1.322zM9.401 12.562H.33a.33.33 0 0 0-.33.331v13.085c0 .182.148.33.33.33H9.4a.33.33 0 0 0 .33-.33V12.893a.33.33 0 0 0-.33-.33m11.824 5.758h-9.019a.33.33 0 0 0-.33.33v7.327c0 .182.148.33.33.33h9.019a.33.33 0 0 0 .33-.33v-7.326a.33.33 0 0 0-.33-.33M33.211 0h-9.21a.33.33 0 0 0-.33.33v25.648c0 .182.148.33.33.33h9.21a.33.33 0 0 0 .33-.33V.33a.33.33 0 0 0-.33-.331';
type RealyticsIconProps = {
fillColor: string;
size: number;
};
export function RealyticsIcon({ fillColor, size }: RealyticsIconProps) {
const height = size * (REALYTICS_VIEWBOX_HEIGHT / REALYTICS_VIEWBOX_WIDTH);
return (
<svg
fill="none"
height={height}
viewBox={`0 0 ${REALYTICS_VIEWBOX_WIDTH} ${REALYTICS_VIEWBOX_HEIGHT}`}
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<path d={REALYTICS_PATH} fill={fillColor} />
</svg>
);
}
@@ -1,13 +1,22 @@
import type { ComponentType } from "react";
import { FlexportIcon } from "./Flexport";
import { ZapierIcon } from "./Zapier";
import type { ComponentType } from 'react';
import { BeagleIcon } from './Beagle';
import { EvergreenIcon } from './Evergreen';
import { FlexportIcon } from './Flexport';
import { RealyticsIcon } from './Realytics';
import { ZapierIcon } from './Zapier';
export { FlexportIcon } from "./Flexport";
export { ZapierIcon } from "./Zapier";
export { BeagleIcon } from './Beagle';
export { EvergreenIcon } from './Evergreen';
export { FlexportIcon } from './Flexport';
export { RealyticsIcon } from './Realytics';
export { ZapierIcon } from './Zapier';
export type ClientIconProps = { size: number; fillColor: string };
export type ClientIconProps = { fillColor: string; size: number };
export const CLIENT_ICONS: Record<string, ComponentType<ClientIconProps>> = {
beagle: BeagleIcon,
evergreen: EvergreenIcon,
flexport: FlexportIcon,
realytics: RealyticsIcon,
zapier: ZapierIcon,
};
@@ -0,0 +1,47 @@
const HELPED_CARD_SHAPE_FILL_PATH =
'M0 490V4a4 4 0 0 1 4-4h288.23c.932 0 1.856.163 2.731.48l60.814 22.09c.875.318 1.8.48 2.731.48H439a4 4 0 0 1 4 4V490a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4';
const HELPED_CARD_SHAPE_STROKE_PATH =
'M4 .5h288.23c.874 0 1.74.152 2.561.45l60.813 22.09c.931.338 1.912.51 2.902.51H439a3.5 3.5 0 0 1 3.5 3.5V490a3.5 3.5 0 0 1-3.5 3.5H4A3.5 3.5 0 0 1 .5 490V4A3.5 3.5 0 0 1 4 .5Z';
type HelpedCardShapeProps = {
fillColor: string;
strokeColor: string;
};
export function HelpedCardShape({
fillColor,
strokeColor,
}: HelpedCardShapeProps) {
return (
<div
aria-hidden
style={{
width: '100%',
height: '100%',
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: -1,
}}
>
<svg
width="100%"
height="100%"
viewBox="0 0 443 494"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
style={{ display: 'block' }}
>
<path d={HELPED_CARD_SHAPE_FILL_PATH} fill={fillColor} />
<path
d={HELPED_CARD_SHAPE_STROKE_PATH}
fill="none"
stroke={strokeColor}
strokeOpacity={0.2}
/>
</svg>
</div>
);
}
@@ -0,0 +1,139 @@
import { Body, Heading, LinkButton } from '@/design-system/components';
import { CLIENT_ICONS } from '@/icons';
import { HelpedCardShape } from '@/sections/Helped/HelpedCardShape';
import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const CardRoot = styled.article`
display: grid;
grid-template-columns: 1fr;
isolation: isolate;
max-width: 443px;
min-height: 0;
min-width: 0;
padding-bottom: ${theme.spacing(4)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(2.5)};
position: relative;
row-gap: ${theme.spacing(2.5)};
width: 100%;
`;
const LogoRow = styled.div`
align-items: center;
display: grid;
grid-template-columns: 1fr;
min-height: ${theme.spacing(8)};
`;
const Rule = styled.div`
background-color: ${theme.colors.secondary.border[40]};
height: 1px;
width: 100%;
`;
const VisualShell = styled.div`
background-color: ${theme.colors.secondary.background[100]};
border-radius: ${theme.radius(2)};
height: 240px;
overflow: hidden;
position: relative;
width: 100%;
`;
const StyledIframe = styled.iframe`
border: none;
height: 200%;
left: 51.5%;
mix-blend-mode: lighten;
pointer-events: none;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 200%;
`;
const HueOverlay = styled.div`
background-color: ${theme.colors.highlight[100]};
inset: 0;
mix-blend-mode: hue;
pointer-events: none;
position: absolute;
`;
const CopyBlock = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(2)};
`;
const CardTitleWrap = styled.div`
color: ${theme.colors.secondary.text[100]};
`;
const CardBodyWrap = styled.div`
color: ${theme.colors.secondary.text[80]};
`;
const CtaRow = styled.div`
display: grid;
grid-template-columns: max-content;
justify-items: start;
padding-top: ${theme.spacing(1)};
`;
const LOGO_FILL = theme.colors.secondary.text[100];
type CardProps = {
card: HeadingCardType;
};
export function Card({ card }: CardProps) {
const IconComponent = CLIENT_ICONS[card.icon];
const logoWidth = card.icon === 'evergreen' ? 96 : 104;
return (
<CardRoot>
<HelpedCardShape
fillColor={theme.colors.secondary.background[100]}
strokeColor={theme.colors.secondary.border[40]}
/>
<LogoRow>
{IconComponent ? (
<IconComponent fillColor={LOGO_FILL} size={logoWidth} />
) : null}
</LogoRow>
<Rule aria-hidden="true" />
<VisualShell>
<StyledIframe
allow="clipboard-write; encrypted-media; gyroscope; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
src={card.illustration.src}
title={card.illustration.title || 'Case illustration'}
/>
<HueOverlay aria-hidden="true" />
</VisualShell>
<Rule aria-hidden="true" />
<CopyBlock>
<CardTitleWrap>
<Heading as="h3" segments={card.heading} size="xs" weight="medium" />
</CardTitleWrap>
<CardBodyWrap>
<Body as="p" body={card.body} size="sm" />
</CardBodyWrap>
</CopyBlock>
<CtaRow>
<LinkButton
color="primary"
href="https://twenty.com"
label="Read the case"
type="anchor"
variant="outlined"
/>
</CtaRow>
</CardRoot>
);
}
@@ -0,0 +1,15 @@
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const StyledSection = styled.section`
width: 100%;
`;
type RootProps = {
backgroundColor: string;
children: ReactNode;
};
export function Root({ backgroundColor, children }: RootProps) {
return <StyledSection style={{ backgroundColor }}>{children}</StyledSection>;
}
@@ -0,0 +1,104 @@
'use client';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { Eyebrow, Heading } from '@/design-system/components';
import { HelpedSceneScrollLayoutEffect } from '@/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect';
import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
import { CARD_WIDTH_DESKTOP } from '@/sections/Helped/utils/helped-scene-layout';
import { theme } from '@/theme';
import { Card } from '../Card/Card';
const SCROLL_HEIGHT_VH = 420;
const ScrollStage = styled.section`
position: relative;
width: 100%;
`;
const StickyInner = styled.div`
align-items: center;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
justify-items: center;
min-height: 100vh;
overflow: hidden;
position: sticky;
top: 0;
`;
const HeadlineBlock = styled.div`
display: grid;
grid-template-columns: 1fr;
justify-items: center;
max-width: 688px;
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
row-gap: ${theme.spacing(6)};
text-align: center;
width: 100%;
z-index: 1;
`;
const CardsLayer = styled.div`
inset: 0;
pointer-events: none;
position: absolute;
z-index: 2;
& article {
pointer-events: auto;
}
`;
const CardPositioner = styled.div`
position: absolute;
width: min(${CARD_WIDTH_DESKTOP}px, calc(100% - ${theme.spacing(8)}));
will-change: transform, opacity;
`;
type SceneProps = {
data: HelpedDataType;
};
export function Scene({ data }: SceneProps) {
const sectionRef = useRef<HTMLElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const headlineRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<ScrollStage
aria-label="Customer stories"
ref={sectionRef}
style={{ height: `${SCROLL_HEIGHT_VH}vh` }}
>
<HelpedSceneScrollLayoutEffect
cardRefs={cardRefs}
cards={data.cards}
headlineRef={headlineRef}
innerRef={innerRef}
sectionRef={sectionRef}
/>
<StickyInner ref={innerRef}>
<HeadlineBlock ref={headlineRef}>
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />
<Heading as="h2" segments={data.heading} size="xl" weight="light" />
</HeadlineBlock>
<CardsLayer>
{data.cards.map((card, index) => (
<CardPositioner
key={`${card.heading.text}-${index}`}
ref={(element) => {
cardRefs.current[index] = element;
}}
>
<Card card={card} />
</CardPositioner>
))}
</CardsLayer>
</StickyInner>
</ScrollStage>
);
}
@@ -0,0 +1,4 @@
import { Root } from './Root/Root';
import { Scene } from './Scene/Scene';
export const Helped = { Root, Scene };
@@ -0,0 +1,57 @@
'use client';
import { useEffect } from 'react';
import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
import {
applyHelpedSceneLayout,
type HelpedSceneLayoutRefs,
} from '@/sections/Helped/utils/helped-scene-layout';
type HelpedSceneScrollLayoutEffectProps = HelpedSceneLayoutRefs & {
cards: HeadingCardType[];
};
export function HelpedSceneScrollLayoutEffect({
cardRefs,
cards,
headlineRef,
innerRef,
sectionRef,
}: HelpedSceneScrollLayoutEffectProps) {
useEffect(() => {
const refs: HelpedSceneLayoutRefs = {
cardRefs,
headlineRef,
innerRef,
sectionRef,
};
let rafId: number | null = null;
const flushLayout = () => {
rafId = null;
applyHelpedSceneLayout(refs, cards);
};
const scheduleLayout = () => {
if (rafId !== null) {
return;
}
rafId = window.requestAnimationFrame(flushLayout);
};
applyHelpedSceneLayout(refs, cards);
window.addEventListener('scroll', scheduleLayout, { passive: true });
window.addEventListener('resize', scheduleLayout);
return () => {
window.removeEventListener('scroll', scheduleLayout);
window.removeEventListener('resize', scheduleLayout);
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
};
}, [cardRefs, cards, headlineRef, innerRef, sectionRef]);
return null;
}
@@ -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 { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
export type HeadingCardType = {
icon: string;
illustration: IllustrationType;
heading: HeadingType;
body: BodyType;
};
@@ -0,0 +1,9 @@
import type { EyebrowType } from '@/design-system/components/Eyebrow/types/Eyebrow';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
export type HelpedDataType = {
eyebrow: EyebrowType;
heading: HeadingType[];
cards: HeadingCardType[];
};
@@ -0,0 +1,139 @@
import type { RefObject } from 'react';
import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
import { theme } from '@/theme';
export const CARD_WIDTH_DESKTOP = 443;
const CARD_WIDTH_MOBILE_MAX = 360;
const HORIZONTAL_PAD = 32;
// Scroll progress 0..1 through the tall section
const HEADLINE_FADE_START = 0.76;
const HEADLINE_FADE_END = 0.92;
const HEADLINE_LIFT_PX = 48;
// Each card fades/slides in over the same window, shifted along the scroll
const CARD_WINDOW = 0.2;
const CARD_STAGGER = 0.12;
const CARD_FIRST_START = 0.06;
const ENTER_OFFSET_Y_RATIO = 0.34;
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
function cardRestPosition(
index: number,
innerWidth: number,
innerHeight: number,
cardWidth: number,
isDesktop: boolean,
): { left: number; top: number } {
if (isDesktop) {
if (index === 0) {
return {
left: innerWidth - cardWidth - innerWidth * 0.035,
top: innerHeight * 0.08,
};
}
if (index === 1) {
return {
left: innerWidth * 0.03,
top: innerHeight * 0.3,
};
}
return {
left: (innerWidth - cardWidth) / 2,
top: innerHeight * 0.52,
};
}
return {
left: (innerWidth - cardWidth) / 2,
top: innerHeight * 0.36 + index * 0.06 * innerHeight,
};
}
export type HelpedSceneLayoutRefs = {
cardRefs: RefObject<(HTMLDivElement | null)[]>;
headlineRef: RefObject<HTMLDivElement | null>;
innerRef: RefObject<HTMLDivElement | null>;
sectionRef: RefObject<HTMLElement | null>;
};
export function applyHelpedSceneLayout(
refs: HelpedSceneLayoutRefs,
cards: readonly HeadingCardType[],
): void {
const { sectionRef, innerRef, headlineRef, cardRefs } = refs;
const section = sectionRef.current;
const inner = innerRef.current;
if (!section || !inner) {
return;
}
const reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
const isDesktop = window.matchMedia(
`(min-width: ${theme.breakpoints.md}px)`,
).matches;
const rect = section.getBoundingClientRect();
const scrollRange = Math.max(1, section.offsetHeight - window.innerHeight);
const progress = clamp01(-rect.top / scrollRange);
const innerWidth = inner.offsetWidth;
const innerHeight = inner.offsetHeight;
const cardWidth = Math.min(
isDesktop ? CARD_WIDTH_DESKTOP : CARD_WIDTH_MOBILE_MAX,
innerWidth - HORIZONTAL_PAD * 2,
);
if (headlineRef.current) {
const headlineT = clamp01(
(progress - HEADLINE_FADE_START) /
(HEADLINE_FADE_END - HEADLINE_FADE_START),
);
headlineRef.current.style.opacity = String(1 - headlineT);
headlineRef.current.style.transform = `translate3d(0, ${-headlineT * HEADLINE_LIFT_PX}px, 0)`;
}
const enterOffsetY = innerHeight * ENTER_OFFSET_Y_RATIO;
cards.forEach((_, index) => {
const node = cardRefs.current[index];
if (!node) {
return;
}
const { left: restLeft, top: restTop } = cardRestPosition(
index,
innerWidth,
innerHeight,
cardWidth,
isDesktop,
);
node.style.width = `${cardWidth}px`;
node.style.zIndex = String(10 + index);
if (reducedMotion) {
node.style.opacity = '1';
node.style.transform = 'none';
node.style.left = `${restLeft}px`;
node.style.top = `${restTop}px`;
return;
}
const windowStart = CARD_FIRST_START + index * CARD_STAGGER;
const windowEnd = windowStart + CARD_WINDOW;
const cardT = clamp01(
(progress - windowStart) / (windowEnd - windowStart),
);
node.style.left = `${restLeft}px`;
node.style.top = `${restTop + enterOffsetY * (1 - cardT)}px`;
node.style.opacity = String(cardT);
});
}
@@ -1,217 +0,0 @@
'use client';
import { Body, Heading } from '@/design-system/components';
import type { HeroStepperContentProps } from '@/sections/HeroStepper/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useRef, type ReactNode } from 'react';
const ContentRoot = styled.div`
display: grid;
grid-template-columns: auto 1fr;
gap: ${theme.spacing(6)};
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(10)};
}
`;
const ProgressRail = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
display: flex;
flex-direction: column;
align-items: center;
gap: ${theme.spacing(2)};
position: sticky;
top: calc(50vh - ${theme.spacing(10)});
height: max-content;
}
`;
const StepIndicatorRow = styled.div`
display: flex;
align-items: flex-start;
gap: ${theme.spacing(4)};
`;
const PillBackground = styled.div`
background-color: ${theme.colors.primary.border[80]};
border-radius: ${theme.radius(8)};
display: flex;
height: ${theme.spacing(20)};
overflow: hidden;
width: ${theme.spacing(1)};
`;
const PillFill = styled.div`
background-color: ${theme.colors.highlight[100]};
border-radius: ${theme.radius(10)};
transition: height 0.4s ease;
width: 100%;
`;
const ActiveLabel = styled.p`
color: ${theme.colors.highlight[100]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
margin: 0;
text-transform: uppercase;
line-height: ${theme.spacing(4)};
`;
const InactiveDotWrapper = styled.div`
display: flex;
align-items: center;
gap: ${theme.spacing(4)};
`;
const InactiveDot = styled.div`
background-color: ${theme.colors.primary.border[80]};
border-radius: 50%;
height: ${theme.spacing(1)};
width: ${theme.spacing(1)};
`;
const StepsColumn = styled.div`
display: grid;
grid-template-columns: 1fr;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 454px;
}
`;
const StepBlock = styled.div`
align-content: center;
display: grid;
grid-template-columns: 1fr;
min-height: 80vh;
opacity: 0.3;
row-gap: ${theme.spacing(4)};
transition: opacity 0.4s ease;
@media (min-width: ${theme.breakpoints.md}px) {
min-height: 100vh;
row-gap: ${theme.spacing(6)};
}
&[data-active='true'] {
opacity: 1;
}
`;
type ProgressRailProps = {
activeImageIndex: number;
activeStepIndex: number;
steps: HeroStepperContentProps['steps'];
};
function HeroProgressRail({
activeImageIndex,
activeStepIndex,
steps,
}: ProgressRailProps) {
const nodes: ReactNode[] = [];
const stepCount = steps.length;
for (let index = 0; index < stepCount; index += 1) {
if (index === activeStepIndex) {
const stepImagesCount = steps[index]?.images.length || 1;
const fillPercentage = ((activeImageIndex + 1) / stepImagesCount) * 100;
nodes.push(
<StepIndicatorRow key={`step-${index}`}>
<PillBackground>
<PillFill style={{ height: `${fillPercentage}%` }} />
</PillBackground>
<ActiveLabel>{String(index + 1).padStart(2, '0')}</ActiveLabel>
</StepIndicatorRow>,
);
} else {
nodes.push(
<InactiveDotWrapper key={`step-${index}`}>
<InactiveDot />
</InactiveDotWrapper>,
);
}
}
return <ProgressRail>{nodes}</ProgressRail>;
}
export function Content({
activeImageIndex,
activeStepIndex,
onActiveStepChange,
steps,
}: HeroStepperContentProps) {
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
stepRefs.current = stepRefs.current.slice(0, steps.length);
}, [steps.length]);
useEffect(() => {
const elements = stepRefs.current.filter(Boolean) as HTMLDivElement[];
if (elements.length === 0) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const candidates = entries.filter(
(entry) => entry.isIntersecting && entry.intersectionRatio > 0.15,
);
if (candidates.length === 0) {
return;
}
const best = candidates.reduce((previous, current) =>
current.intersectionRatio > previous.intersectionRatio
? current
: previous,
);
const indexAttribute = best.target.getAttribute('data-step-index');
const index = indexAttribute === null ? NaN : Number(indexAttribute);
if (!Number.isNaN(index)) {
onActiveStepChange(index);
}
},
{ root: null, threshold: [0, 0.15, 0.3, 0.5, 0.75, 1] },
);
elements.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [onActiveStepChange, steps]);
return (
<ContentRoot>
<HeroProgressRail
activeImageIndex={activeImageIndex}
activeStepIndex={activeStepIndex}
steps={steps}
/>
<StepsColumn>
{steps.map((step, index) => (
<StepBlock
data-active={String(index === activeStepIndex)}
data-step-index={index}
key={index}
ref={(element) => {
stepRefs.current[index] = element;
}}
>
<Heading segments={step.heading} size="lg" weight="light" />
<Body body={step.body} size="sm" />
</StepBlock>
))}
</StepsColumn>
</ContentRoot>
);
}
@@ -1,45 +0,0 @@
'use client';
import type { HeroStepperStepType } from '@/sections/HeroStepper/types/HeroStepperStep';
import { useEffect, useState } from 'react';
import { Content } from '../Content/Content';
import { Root } from '../Root/Root';
import { Visual } from '../Visual/Visual';
type FlowProps = {
steps: HeroStepperStepType[];
};
export function Flow({ steps }: FlowProps) {
const [activeStepIndex, setActiveStepIndex] = useState(0);
const [activeImageIndex, setActiveImageIndex] = useState(0);
useEffect(() => {
setActiveImageIndex(0);
}, [activeStepIndex]);
useEffect(() => {
const imageList = steps[activeStepIndex]?.images ?? [];
if (imageList.length <= 1) {
return;
}
const timerId = window.setInterval(() => {
setActiveImageIndex((previous) => (previous + 1) % imageList.length);
}, 5000);
return () => window.clearInterval(timerId);
}, [activeStepIndex, steps]);
const images = steps[activeStepIndex]?.images ?? [];
return (
<Root>
<Content
activeImageIndex={activeImageIndex}
activeStepIndex={activeStepIndex}
onActiveStepChange={setActiveStepIndex}
steps={steps}
/>
<Visual activeImageIndex={activeImageIndex} images={images} />
</Root>
);
}
@@ -1,61 +0,0 @@
import type { HeroStepperVisualProps } from '@/sections/HeroStepper/types';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import { StepperVisualFrame } from '../StepperVisualFrame/StepperVisualFrame';
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/background.png';
const HOME_STEPPER_SHAPE = '/images/home/stepper/background-shape.png';
const VisualColumn = styled.div`
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
position: sticky;
top: ${theme.spacing(10)};
}
`;
const slideImageClassName = css`
object-fit: contain;
object-position: center;
opacity: 0;
transition: opacity 0.4s ease;
&[data-active='true'] {
opacity: 1;
}
`;
export function Visual({
activeImageIndex = 0,
images,
}: HeroStepperVisualProps) {
if (!images || images.length === 0) {
return null;
}
return (
<VisualColumn>
<StepperVisualFrame
backgroundSrc={HOME_STEPPER_BACKGROUND}
shapeSrc={HOME_STEPPER_SHAPE}
>
{images.map((image, index) => (
<NextImage
key={`${image.src}-${index}`}
alt={image.alt}
className={slideImageClassName}
data-active={String(index === activeImageIndex)}
fill
sizes="(min-width: 921px) 672px, 100vw"
src={image.src}
/>
))}
</StepperVisualFrame>
</VisualColumn>
);
}
@@ -1,11 +0,0 @@
import { Content } from './Content/Content';
import { Flow } from './Flow/Flow';
import { Root } from './Root/Root';
import { Visual } from './Visual/Visual';
export const HeroStepper = {
Content,
Flow,
Root,
Visual,
};
@@ -1,8 +0,0 @@
import type { HeroStepperStepType } from '@/sections/HeroStepper/types/HeroStepperStep';
export type HeroStepperContentProps = {
activeImageIndex: number;
activeStepIndex: number;
onActiveStepChange: (index: number) => void;
steps: HeroStepperStepType[];
};
@@ -1,5 +0,0 @@
import type { HeroStepperStepType } from '@/sections/HeroStepper/types/HeroStepperStep';
export type HeroStepperDataType = {
steps: HeroStepperStepType[];
};
@@ -1,6 +0,0 @@
import type { ImageType } from '@/design-system/components/Image/types/Image';
export type HeroStepperVisualProps = {
activeImageIndex?: number;
images: ImageType[];
};
@@ -1,4 +0,0 @@
export type { HeroStepperContentProps } from './HeroStepperContentProps';
export type { HeroStepperDataType } from './HeroStepperData';
export type { HeroStepperStepType } from './HeroStepperStep';
export type { HeroStepperVisualProps } from './HeroStepperVisualProps';
@@ -0,0 +1,36 @@
'use client';
import type { HomeStepperStepType } from '@/sections/HomeStepper/types/HomeStepperStep';
import { useState, type RefObject } from 'react';
import { SyncScrollProgressFromContainerEffect } from '../../effect-components/SyncScrollProgressFromContainerEffect';
import { LeftColumn } from '../LeftColumn/LeftColumn';
import { RightColumn } from '../RightColumn/RightColumn';
import { Visual } from '../Visual/Visual';
type FlowProps = {
scrollContainerRef: RefObject<HTMLElement | null>;
steps: HomeStepperStepType[];
};
export function Flow({ scrollContainerRef, steps }: FlowProps) {
const [activeStepIndex, setActiveStepIndex] = useState(0);
const [scrollProgress, setScrollProgress] = useState(0);
return (
<>
<SyncScrollProgressFromContainerEffect
scrollContainerRef={scrollContainerRef}
onScrollProgress={setScrollProgress}
/>
<LeftColumn
activeStepIndex={activeStepIndex}
onActiveStepChange={setActiveStepIndex}
scrollProgress={scrollProgress}
steps={steps}
/>
<RightColumn>
<Visual />
</RightColumn>
</>
);
}
@@ -0,0 +1,95 @@
'use client';
import { Body, Heading } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { ObserveActiveStepFromIntersectionEffect } from '../../effect-components/ObserveActiveStepFromIntersectionEffect';
import type { HomeStepperStepType } from '../../types/HomeStepperStep';
import { ProgressBar } from '../ProgressBar/ProgressBar';
const LeftColumnRoot = styled.div`
display: grid;
grid-template-columns: auto 1fr;
gap: ${theme.spacing(6)};
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(10)};
}
`;
const StepsColumn = styled.div`
display: grid;
grid-template-columns: 1fr;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 454px;
}
`;
const StepBlock = styled.div`
align-content: center;
display: grid;
grid-template-columns: 1fr;
min-height: 80vh;
opacity: 0.3;
row-gap: ${theme.spacing(4)};
transition: opacity 0.4s ease;
@media (min-width: ${theme.breakpoints.md}px) {
min-height: 100vh;
row-gap: ${theme.spacing(6)};
}
&[data-active='true'] {
opacity: 1;
}
`;
export type HomeStepperLeftColumnProps = {
activeStepIndex: number;
onActiveStepChange: (index: number) => void;
scrollProgress: number;
steps: HomeStepperStepType[];
};
export function LeftColumn({
activeStepIndex,
onActiveStepChange,
scrollProgress,
steps,
}: HomeStepperLeftColumnProps) {
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<LeftColumnRoot>
<ObserveActiveStepFromIntersectionEffect
onActiveStepChange={onActiveStepChange}
stepRefs={stepRefs}
stepsLength={steps.length}
/>
<ProgressBar
activeStepIndex={activeStepIndex}
scrollProgress={scrollProgress}
steps={steps}
/>
<StepsColumn>
{steps.map((step, index) => (
<StepBlock
data-active={String(index === activeStepIndex)}
data-step-index={index}
key={index}
ref={(element) => {
stepRefs.current[index] = element;
}}
>
<Heading segments={step.heading} size="lg" weight="light" />
<Body body={step.body} size="sm" />
</StepBlock>
))}
</StepsColumn>
</LeftColumnRoot>
);
}
@@ -0,0 +1,125 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { HomeStepperStepType } from '../../types/HomeStepperStep';
const ProgressBarContainer = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: flex-start;
align-self: stretch;
display: flex;
flex-direction: column;
min-height: 0;
}
`;
const StickyViewportCenter = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
height: 100vh;
justify-content: flex-start;
max-height: 100vh;
position: sticky;
top: 0;
`;
const ProgressRail = styled.div`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
`;
const StepIndicatorRow = styled.div`
align-items: flex-start;
display: flex;
gap: ${theme.spacing(3)};
height: 80px;
`;
const PillBackground = styled.div`
background-color: ${theme.colors.primary.border[20]};
border-radius: ${theme.radius(8)};
display: flex;
height: 100%;
overflow: hidden;
width: ${theme.spacing(1)};
`;
const PillFill = styled.div`
background-color: ${theme.colors.highlight[100]};
border-radius: ${theme.radius(8)};
transition: height 0.4s ease;
width: 100%;
`;
const ActiveLabel = styled.p`
color: ${theme.colors.highlight[100]};
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
font-weight: ${theme.font.weight.medium};
line-height: 16px;
margin: 0;
margin-top: -2px;
text-transform: uppercase;
`;
const InactiveDotWrapper = styled.div`
align-items: center;
display: flex;
height: 16px;
justify-content: center;
width: ${theme.spacing(1)};
`;
const InactiveDot = styled.div`
background-color: ${theme.colors.primary.border[20]};
border-radius: 50%;
height: ${theme.spacing(1)};
width: ${theme.spacing(1)};
`;
type ProgressBarProps = {
activeStepIndex: number;
scrollProgress: number;
steps: HomeStepperStepType[];
};
export function ProgressBar({
activeStepIndex,
scrollProgress,
steps,
}: ProgressBarProps) {
const stepCount = steps.length;
const localProgress = Math.min(
1,
Math.max(0, scrollProgress * stepCount - activeStepIndex),
);
const fillPercentage = localProgress * 100;
return (
<ProgressBarContainer>
<StickyViewportCenter>
<ProgressRail>
{steps.map((_, index) =>
index === activeStepIndex ? (
<StepIndicatorRow key={`step-${index}`}>
<PillBackground>
<PillFill style={{ height: `${fillPercentage}%` }} />
</PillBackground>
<ActiveLabel>{String(index + 1).padStart(2, '0')}</ActiveLabel>
</StepIndicatorRow>
) : (
<InactiveDotWrapper key={`step-${index}`}>
<InactiveDot />
</InactiveDotWrapper>
),
)}
</ProgressRail>
</StickyViewportCenter>
</ProgressBarContainer>
);
}
@@ -0,0 +1,22 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const StyledRightColumn = styled.div`
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
position: sticky;
top: ${theme.spacing(10)};
}
`;
type RightColumnProps = {
children: ReactNode;
};
export function RightColumn({ children }: RightColumnProps) {
return <StyledRightColumn>{children}</StyledRightColumn>;
}
@@ -1,14 +1,16 @@
'use client';
import { Container } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import type { ReactNode, RefObject } from 'react';
const StyledSection = styled.section`
background-color: ${theme.colors.primary.text[5]};
width: 100%;
`;
const Grid = styled(Container)`
const StyledContainer = styled(Container)`
display: grid;
grid-template-columns: 1fr;
min-width: 0;
@@ -31,12 +33,15 @@ const Grid = styled(Container)`
}
`;
type RootProps = { children: ReactNode };
type RootProps = {
children: ReactNode;
scrollContainerRef?: RefObject<HTMLElement | null>;
};
export function Root({ children }: RootProps) {
export function Root({ children, scrollContainerRef }: RootProps) {
return (
<StyledSection>
<Grid>{children}</Grid>
<StyledSection ref={scrollContainerRef}>
<StyledContainer>{children}</StyledContainer>
</StyledSection>
);
}
@@ -0,0 +1,20 @@
'use client';
import type { HomeStepperStepType } from '@/sections/HomeStepper/types/HomeStepperStep';
import { useRef } from 'react';
import { Flow } from '../Flow/Flow';
import { Root } from '../Root/Root';
type ScrollSectionProps = {
steps: HomeStepperStepType[];
};
export function ScrollSection({ steps }: ScrollSectionProps) {
const scrollContainerRef = useRef<HTMLElement>(null);
return (
<Root scrollContainerRef={scrollContainerRef}>
<Flow scrollContainerRef={scrollContainerRef} steps={steps} />
</Root>
);
}
@@ -44,7 +44,7 @@ const shapeImageClassName = css`
type StepperVisualFrameProps = {
backgroundSrc: string;
children: ReactNode;
children?: ReactNode;
shapeSrc: string;
};
@@ -0,0 +1,15 @@
'use client';
import { StepperVisualFrame } from '../StepperVisualFrame/StepperVisualFrame';
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/background.png';
const HOME_STEPPER_SHAPE = '/images/home/stepper/background-shape.png';
export function Visual() {
return (
<StepperVisualFrame
backgroundSrc={HOME_STEPPER_BACKGROUND}
shapeSrc={HOME_STEPPER_SHAPE}
/>
);
}
@@ -0,0 +1,15 @@
import { Flow } from './Flow/Flow';
import { LeftColumn } from './LeftColumn/LeftColumn';
import { RightColumn } from './RightColumn/RightColumn';
import { Root } from './Root/Root';
import { ScrollSection } from './ScrollSection/ScrollSection';
import { Visual } from './Visual/Visual';
export const HomeStepper = {
Flow,
LeftColumn,
RightColumn,
Root,
ScrollSection,
Visual,
};
@@ -0,0 +1,55 @@
'use client';
import { useEffect, type RefObject } from 'react';
type ObserveActiveStepFromIntersectionEffectProps = {
onActiveStepChange: (index: number) => void;
stepRefs: RefObject<(HTMLDivElement | null)[]>;
stepsLength: number;
};
export function ObserveActiveStepFromIntersectionEffect({
onActiveStepChange,
stepRefs,
stepsLength,
}: ObserveActiveStepFromIntersectionEffectProps) {
useEffect(() => {
stepRefs.current = stepRefs.current.slice(0, stepsLength);
const elements = stepRefs.current.filter(
(element): element is HTMLDivElement => element !== null,
);
if (elements.length === 0) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const candidates = entries.filter(
(entry) => entry.isIntersecting && entry.intersectionRatio > 0.15,
);
if (candidates.length === 0) {
return;
}
const best = candidates.reduce((previous, current) =>
current.intersectionRatio > previous.intersectionRatio
? current
: previous,
);
const indexAttribute = best.target.getAttribute('data-step-index');
const index = indexAttribute === null ? NaN : Number(indexAttribute);
if (!Number.isNaN(index)) {
onActiveStepChange(index);
}
},
{ root: null, threshold: [0, 0.15, 0.3, 0.5, 0.75, 1] },
);
elements.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [onActiveStepChange, stepRefs, stepsLength]);
return null;
}
@@ -0,0 +1,41 @@
'use client';
import { useEffect, type RefObject } from 'react';
type SyncScrollProgressFromContainerEffectProps = {
onScrollProgress: (progress: number) => void;
scrollContainerRef: RefObject<HTMLElement | null>;
};
export function SyncScrollProgressFromContainerEffect({
onScrollProgress,
scrollContainerRef,
}: SyncScrollProgressFromContainerEffectProps) {
useEffect(() => {
const handleScroll = () => {
const element = scrollContainerRef.current;
if (!element) {
return;
}
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight;
const scrollableDistance = rect.height - windowHeight;
if (scrollableDistance <= 0) {
return;
}
const progress = Math.max(0, Math.min(1, -rect.top / scrollableDistance));
onScrollProgress(progress);
};
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
};
}, [onScrollProgress, scrollContainerRef]);
return null;
}
@@ -0,0 +1,5 @@
import type { HomeStepperStepType } from '@/sections/HomeStepper/types/HomeStepperStep';
export type HomeStepperDataType = {
steps: HomeStepperStepType[];
};
@@ -1,9 +1,7 @@
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 HeroStepperStepType = {
export type HomeStepperStepType = {
body: BodyType;
heading: HeadingType[];
images: ImageType[];
};
@@ -0,0 +1,2 @@
export type { HomeStepperDataType } from './HomeStepperData';
export type { HomeStepperStepType } from './HomeStepperStep';
@@ -0,0 +1,28 @@
import { Heading as BaseHeading } from '@/design-system/components/Heading/Heading';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const HeadingWrapper = styled.div`
color: ${theme.colors.secondary.text[100]};
max-width: 100%;
min-width: 0;
white-space: pre-line;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 517px;
}
`;
type QuoteHeadingProps = {
segments: HeadingType[];
};
export function Heading({ segments }: QuoteHeadingProps) {
return (
<HeadingWrapper>
<BaseHeading as="h2" segments={segments} size="xl" weight="light" />
</HeadingWrapper>
);
}
@@ -0,0 +1,46 @@
import { Container } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
const StyledSection = styled.section`
min-width: 0;
width: 100%;
`;
const StyledContainer = styled(Container)`
column-gap: ${theme.spacing(6)};
display: grid;
grid-template-columns: minmax(0, 1fr);
justify-items: stretch;
padding-bottom: ${theme.spacing(12)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(12)};
row-gap: ${theme.spacing(10)};
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
column-gap: ${theme.spacing(8)};
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
justify-items: start;
padding-bottom: ${theme.spacing(14)};
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(14)};
row-gap: ${theme.spacing(8)};
}
`;
type RootProps = {
backgroundColor: string;
children: ReactNode;
};
export function Root({ backgroundColor, children }: RootProps) {
return (
<StyledSection style={{ backgroundColor }}>
<StyledContainer>{children}</StyledContainer>
</StyledSection>
);
}
@@ -0,0 +1,56 @@
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const VisualContainer = styled.div`
background-color: ${theme.colors.secondary.background[100]};
border-radius: ${theme.radius(1)};
height: min(544px, 70vw);
min-height: ${theme.spacing(80)};
overflow: hidden;
position: relative;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
height: 544px;
max-width: 646px;
}
`;
const StyledIframe = styled.iframe`
border: none;
height: 200%;
left: 51.5%;
mix-blend-mode: lighten;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 200%;
`;
const HueOverlay = styled.div`
background-color: ${theme.colors.highlight[100]};
inset: 0;
mix-blend-mode: hue;
pointer-events: none;
position: absolute;
`;
type VisualProps = {
illustration: IllustrationType;
};
export function Visual({ illustration }: VisualProps) {
return (
<VisualContainer>
<StyledIframe
src={illustration.src}
title={illustration.title}
allow="clipboard-write; encrypted-media; gyroscope; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
<HueOverlay aria-hidden="true" />
</VisualContainer>
);
}
@@ -0,0 +1,5 @@
import { Heading } from './Heading/Heading';
import { Root } from './Root/Root';
import { Visual } from './Visual/Visual';
export const Quote = { Heading, Root, Visual };
@@ -0,0 +1,7 @@
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
export type QuoteDataType = {
illustration: IllustrationType;
heading: HeadingType[];
};
@@ -0,0 +1 @@
export type { QuoteDataType } from './QuoteData';
@@ -0,0 +1,28 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const Line = styled.div`
background: repeating-linear-gradient(
to bottom,
var(--statement-line) 0,
var(--statement-line) 4px,
transparent 4px,
transparent 8px
);
height: 85%;
max-height: 938px;
pointer-events: none;
position: absolute;
right: ${theme.spacing(6)};
top: 50%;
transform: translateY(-50%);
width: 1px;
@media (max-width: ${theme.breakpoints.md - 1}px) {
display: none;
}
`;
export function Decoration() {
return <Line aria-hidden="true" />;
}
@@ -0,0 +1,45 @@
import { Heading as BaseHeading } from '@/design-system/components/Heading/Heading';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
const statementHeadingClassName = css`
text-align: center;
&[data-size='xl'] {
line-height: ${theme.lineHeight(19)};
}
@media (min-width: ${theme.breakpoints.md}px) {
&[data-size='xl'] {
line-height: ${theme.lineHeight(19)};
}
}
`;
const HeadingWrapper = styled.div`
margin-left: auto;
margin-right: auto;
max-width: 901px;
min-width: 0;
width: 100%;
`;
type StatementHeadingProps = {
segments: HeadingType | HeadingType[];
};
export function Heading({ segments }: StatementHeadingProps) {
return (
<HeadingWrapper>
<BaseHeading
as="h2"
className={statementHeadingClassName}
segments={segments}
size="xl"
weight="light"
/>
</HeadingWrapper>
);
}
@@ -0,0 +1,52 @@
import { Container } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { CSSProperties, ReactNode } from 'react';
import { Decoration } from '../Decoration/Decoration';
const StyledSection = styled.section`
min-width: 0;
overflow: visible;
position: relative;
width: 100%;
`;
const StyledContainer = styled(Container)`
display: grid;
justify-items: center;
margin-left: auto;
margin-right: auto;
padding-bottom: ${theme.spacing(28)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(28)};
position: relative;
@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;
};
export function Root({ backgroundColor, children, color }: RootProps) {
const cssVariables = {
'--statement-emphasis': color,
'--statement-line':
'color-mix(in srgb, var(--statement-emphasis) 40%, transparent)',
} as CSSProperties;
return (
<StyledSection style={{ ...cssVariables, backgroundColor, color }}>
<StyledContainer>
<Decoration />
{children}
</StyledContainer>
</StyledSection>
);
}
@@ -0,0 +1,4 @@
import { Heading } from './Heading/Heading';
import { Root } from './Root/Root';
export const Statement = { Heading, Root };
@@ -0,0 +1,5 @@
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
export type StatementDataType = {
heading: HeadingType;
};
@@ -0,0 +1 @@
export type { StatementDataType } from './StatementData';
@@ -0,0 +1,46 @@
const TESTIMONIALS_SHAPE_PATH =
'M0 4a4 4 0 0 1 4-4h344.32c4.197 0 8.369.66 12.361 1.958l49.5 16.084A40 40 0 0 0 422.542 20h517.7c4.293 0 8.559-.691 12.633-2.047l47.785-15.906A40 40 0 0 1 1013.29 0H1356a4 4 0 0 1 4 4v16H0z';
interface TestimonialsShapeProps {
fillColor: string;
}
export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) {
return (
<div
aria-hidden
style={{
width: '100%',
height: '100%',
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: -1,
}}
>
<svg
width="100%"
height="20"
viewBox="0 0 1360 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
style={{ display: 'block' }}
>
<path d={TESTIMONIALS_SHAPE_PATH} fill={fillColor} />
</svg>
<div
style={{
position: 'absolute',
top: 19,
bottom: 0,
left: 0,
right: 0,
backgroundColor: fillColor,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
}}
/>
</div>
);
}
@@ -0,0 +1,307 @@
'use client';
import { Body, Eyebrow, Heading, IconButton } from '@/design-system/components';
import type { EyebrowType } from '@/design-system/components/Eyebrow/types/Eyebrow';
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
import { ArrowLeftIcon, ArrowRightIcon } from '@/icons';
import type { TestimonialCardType } from '@/sections/Testimonials/types/TestimonialCard';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import { useState } from 'react';
import { Embed } from '../Embed/Embed';
import { Separator } from '../Separator/Separator';
const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
const StyledCarousel = styled.div`
display: grid;
grid-template-columns: 1fr;
position: relative;
row-gap: ${theme.spacing(6)};
z-index: 0;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: stretch;
column-gap: ${theme.spacing(15)};
grid-template-columns: auto auto 1fr;
row-gap: 0;
}
`;
const LeftColumn = styled.div`
align-items: center;
column-gap: ${theme.spacing(4)};
display: grid;
grid-template-columns: auto 1fr;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
row-gap: ${theme.spacing(12)};
}
`;
const CounterSlot = styled.div`
order: 1;
@media (min-width: ${theme.breakpoints.md}px) {
order: 2;
}
`;
const EmbedSlot = styled.div`
order: 2;
@media (min-width: ${theme.breakpoints.md}px) {
order: 1;
}
`;
const CounterText = styled.p`
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(10)};
font-weight: ${theme.font.weight.light};
line-height: ${theme.lineHeight(11.5)};
margin: 0;
white-space: nowrap;
@media (min-width: ${theme.breakpoints.md}px) {
font-size: ${theme.font.size(12)};
line-height: ${theme.lineHeight(14)};
text-align: center;
}
`;
const RightColumn = styled.div`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
row-gap: ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(8)};
padding-top: ${theme.spacing(8)};
row-gap: ${theme.spacing(14)};
}
`;
const HeadingContainer = styled.div`
display: grid;
`;
const HeadingWrapper = styled.div`
grid-area: 1 / 1;
opacity: 0;
pointer-events: none;
visibility: hidden;
&[data-active='true'] {
opacity: 1;
pointer-events: auto;
visibility: visible;
}
`;
const FooterRow = styled.div`
align-items: flex-start;
align-self: end;
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(4)};
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
grid-template-columns: auto 1fr;
justify-items: end;
}
`;
const NavGroup = styled.div`
column-gap: ${theme.spacing(2)};
display: grid;
grid-auto-flow: column;
justify-content: start;
order: 2;
@media (min-width: ${theme.breakpoints.md}px) {
order: 1;
}
`;
const AuthorBlock = styled.div`
align-items: center;
column-gap: ${theme.spacing(4)};
display: grid;
grid-template-columns: 48px 1fr;
order: 1;
@media (min-width: ${theme.breakpoints.md}px) {
order: 2;
}
`;
const AvatarFrame = styled.div`
border-radius: ${theme.radius(0.5)};
height: 48px;
overflow: hidden;
position: relative;
width: 48px;
`;
const AuthorMeta = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(2)};
`;
const NameHandleRow = styled.div`
align-items: center;
column-gap: ${theme.spacing(2)};
display: grid;
grid-auto-flow: column;
justify-content: start;
`;
const HandleText = styled.span`
border-left: 1px solid ${theme.colors.primary.border[20]};
padding-left: ${theme.spacing(2)};
`;
type CarouselProps = {
eyebrow: EyebrowType;
illustration: IllustrationType;
testimonials: TestimonialCardType[];
};
export function Carousel({
eyebrow,
illustration,
testimonials,
}: CarouselProps) {
const [index, setIndex] = useState(0);
const total = testimonials.length;
if (total === 0) return null;
const hasPrevious = index > 0;
const hasNext = index < total - 1;
const current = testimonials[index];
const goToPrevious = () => {
if (hasPrevious) setIndex(index - 1);
};
const goToNext = () => {
if (hasNext) setIndex(index + 1);
};
return (
<StyledCarousel
aria-label="Testimonials"
aria-roledescription="carousel"
role="region"
>
<LeftColumn>
<CounterSlot>
<CounterText aria-live="polite">
{index + 1}/{total}
</CounterText>
</CounterSlot>
<EmbedSlot>
<Embed illustration={illustration} />
</EmbedSlot>
</LeftColumn>
<Separator />
<RightColumn>
<Eyebrow colorScheme="primary" heading={eyebrow.heading} />
<HeadingContainer>
{testimonials.map((t, i) => (
<HeadingWrapper key={i} data-active={i === index}>
<Heading as="h2" segments={t.heading} size="md" weight="light" />
</HeadingWrapper>
))}
</HeadingContainer>
<FooterRow>
<NavGroup>
<IconButton
ariaLabel="Previous testimonial"
borderColor={
hasPrevious
? theme.colors.primary.border[20]
: theme.colors.primary.border[10]
}
icon={ArrowLeftIcon}
iconFillColor="transparent"
iconSize={14}
iconStrokeColor={
hasPrevious
? theme.colors.primary.text[80]
: theme.colors.primary.text[20]
}
size={48}
onClick={goToPrevious}
/>
<IconButton
ariaLabel="Next testimonial"
borderColor={
hasNext
? theme.colors.primary.border[20]
: theme.colors.primary.border[10]
}
icon={ArrowRightIcon}
iconFillColor="transparent"
iconSize={14}
iconStrokeColor={
hasNext
? theme.colors.primary.text[80]
: theme.colors.primary.text[20]
}
size={48}
onClick={goToNext}
/>
</NavGroup>
<AuthorBlock>
<AvatarFrame>
<NextImage
alt={current.author.avatar.alt || ''}
fill
sizes="48px"
src={current.author.avatar.src}
style={{ objectFit: 'cover' }}
/>
</AvatarFrame>
<AuthorMeta>
<NameHandleRow>
<Body
as="span"
body={current.author.name}
size="sm"
weight="medium"
/>
<HandleText>
<Body as="span" body={current.author.handle} size="sm" />
</HandleText>
</NameHandleRow>
<Body
as="p"
body={{ text: DATE_FORMATTER.format(current.author.date) }}
size="xs"
/>
</AuthorMeta>
</AuthorBlock>
</FooterRow>
</RightColumn>
</StyledCarousel>
);
}
@@ -0,0 +1,56 @@
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const EmbedContainer = styled.div`
background-color: ${theme.colors.primary.background[100]};
border-radius: ${theme.radius(1)};
height: 279px;
overflow: hidden;
position: relative;
width: 198px;
@media (min-width: ${theme.breakpoints.md}px) {
height: 476px;
width: 336px;
}
`;
const StyledIframe = styled.iframe`
border: none;
height: 200%;
left: 51.5%;
mix-blend-mode: difference;
pointer-events: none;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 200%;
`;
const PlusOverlay = styled.div`
background-color: ${theme.colors.highlight[100]};
inset: 0;
mix-blend-mode: plus-lighter;
pointer-events: none;
position: absolute;
`;
type EmbedProps = {
illustration: IllustrationType;
};
export function Embed({ illustration }: EmbedProps) {
return (
<EmbedContainer>
<StyledIframe
allow="clipboard-write; encrypted-media; gyroscope; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
src={illustration.src}
title={illustration.title}
/>
<PlusOverlay aria-hidden="true" />
</EmbedContainer>
);
}
@@ -0,0 +1,47 @@
import { Container } from '@/design-system/components';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import { TestimonialsShape } from '@/sections/Testimonials/TestimonialsShape';
const StyledSection = styled.section`
isolation: isolate;
overflow: hidden;
position: relative;
width: 100%;
`;
const StyledContainer = styled(Container)`
padding-bottom: ${theme.spacing(22)};
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(22)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
padding-top: ${theme.spacing(25)};
padding-bottom: ${theme.spacing(25)};
}
`;
type RootProps = {
backgroundColor: string;
children: ReactNode;
color: string;
shapeFillColor?: string;
};
export function Root({
backgroundColor,
children,
color,
shapeFillColor,
}: RootProps) {
return (
<StyledSection style={{ backgroundColor, color }}>
{shapeFillColor && <TestimonialsShape fillColor={shapeFillColor} />}
<StyledContainer>{children}</StyledContainer>
</StyledSection>
);
}
@@ -0,0 +1,61 @@
import { PlusIcon } from '@/icons';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const StyledSeparator = styled.div`
align-items: center;
display: grid;
grid-auto-flow: column;
grid-template-columns: auto 1fr auto;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
grid-auto-flow: row;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
height: 100%;
width: auto;
}
`;
const SeparatorLine = styled.div`
background-color: ${theme.colors.primary.border[20]};
height: 1px;
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
height: 100%;
min-height: 0;
width: 1px;
}
`;
const SeparatorIcon = styled.div`
align-items: center;
display: grid;
justify-items: center;
padding-left: ${theme.spacing(1.5)};
padding-right: ${theme.spacing(1.5)};
@media (min-width: ${theme.breakpoints.md}px) {
padding-bottom: ${theme.spacing(1.5)};
padding-left: 0;
padding-right: 0;
padding-top: ${theme.spacing(1.5)};
}
`;
export function Separator() {
return (
<StyledSeparator>
<SeparatorIcon>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</SeparatorIcon>
<SeparatorLine />
<SeparatorIcon>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</SeparatorIcon>
</StyledSeparator>
);
}
@@ -0,0 +1,11 @@
import { Carousel } from './Carousel/Carousel';
import { Embed } from './Embed/Embed';
import { Root } from './Root/Root';
import { Separator } from './Separator/Separator';
export const Testimonials = {
Carousel,
Embed,
Root,
Separator,
};
@@ -0,0 +1,9 @@
import type { BodyType } from '@/design-system/components/Body/types/Body';
import type { ImageType } from '@/design-system/components/Image/types/Image';
export type TestimonialAuthorType = {
avatar: ImageType;
name: BodyType;
handle: BodyType;
date: Date;
};
@@ -0,0 +1,7 @@
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { TestimonialAuthorType } from '@/sections/Testimonials/types/TestimonialAuthor';
export type TestimonialCardType = {
heading: HeadingType;
author: TestimonialAuthorType;
};
@@ -0,0 +1,9 @@
import type { EyebrowType } from '@/design-system/components/Eyebrow/types/Eyebrow';
import type { IllustrationType } from '@/design-system/components/Illustration/types/Illustration';
import type { TestimonialCardType } from '@/sections/Testimonials/types/TestimonialCard';
export type TestimonialsDataType = {
eyebrow: EyebrowType;
testimonials: TestimonialCardType[];
illustration: IllustrationType;
};
@@ -0,0 +1 @@
export type { TestimonialsDataType } from './TestimonialsData';