356 lines
11 KiB
TypeScript
Executable File
356 lines
11 KiB
TypeScript
Executable File
import clsx from 'clsx';
|
|
import {LandingPageContent} from './landing-page-content';
|
|
import {Button, ButtonProps} from '@common/ui/buttons/button';
|
|
import {MixedImage} from '@common/ui/images/mixed-image';
|
|
import {Footer} from '@common/ui/footer/footer';
|
|
import {Trans} from '@common/i18n/trans';
|
|
import {Link} from 'react-router-dom';
|
|
import {createSvgIconFromTree} from '@common/icons/create-svg-icon';
|
|
import {MenuItemConfig} from '@common/core/settings/settings';
|
|
import React, {Fragment, useState} from 'react';
|
|
import {DefaultMetaTags} from '@common/seo/default-meta-tags';
|
|
import {useSettings} from '@common/core/settings/use-settings';
|
|
import {PricingTable} from '@common/billing/pricing-table/pricing-table';
|
|
import {BillingCycleRadio} from '@common/billing/pricing-table/billing-cycle-radio';
|
|
import {UpsellBillingCycle} from '@common/billing/pricing-table/find-best-price';
|
|
import {useProducts} from '@common/billing/pricing-table/use-products';
|
|
import {LandingPageTrendingTitles} from '@app/landing-page/landing-page-trending-titles';
|
|
import {Navbar} from '@common/ui/navigation/navbar/navbar';
|
|
|
|
interface ContentProps {
|
|
content: LandingPageContent;
|
|
}
|
|
export function LandingPage() {
|
|
const settings = useSettings();
|
|
const appearance = settings.homepage?.appearance;
|
|
const showPricing = settings.homepage?.pricing && settings.billing.enable;
|
|
const showTrending = settings.homepage?.trending;
|
|
|
|
if (!appearance) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Fragment>
|
|
<DefaultMetaTags />
|
|
<HeroHeader content={appearance} />
|
|
<PrimaryFeatures content={appearance} />
|
|
<SecondaryFeatures content={appearance} />
|
|
{showTrending && <LandingPageTrendingTitles />}
|
|
<BottomCta content={appearance} />
|
|
{showPricing && <PricingSection content={appearance} />}
|
|
<Footer className="landing-container" />
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
function HeroHeader({content}: ContentProps) {
|
|
const {
|
|
headerTitle,
|
|
headerSubtitle,
|
|
headerImage,
|
|
headerImageOpacity,
|
|
actions,
|
|
headerOverlayColor1,
|
|
headerOverlayColor2,
|
|
blurHeaderImage,
|
|
} = content;
|
|
let overlayBackground = undefined;
|
|
|
|
if (headerOverlayColor1 && headerOverlayColor2) {
|
|
overlayBackground = `linear-gradient(45deg, ${headerOverlayColor1} 0%, ${headerOverlayColor2} 100%)`;
|
|
} else if (headerOverlayColor1) {
|
|
overlayBackground = headerOverlayColor1;
|
|
} else if (headerOverlayColor2) {
|
|
overlayBackground = headerOverlayColor2;
|
|
}
|
|
|
|
return (
|
|
<header className="relative isolate mb-14 overflow-hidden md:mb-60">
|
|
<img
|
|
src={headerImage}
|
|
style={{
|
|
opacity: headerImageOpacity,
|
|
}}
|
|
alt=""
|
|
width="2347"
|
|
height="1244"
|
|
decoding="async"
|
|
loading="lazy"
|
|
className={clsx(
|
|
'absolute left-1/2 top-1/2 z-20 max-w-none -translate-x-1/2 -translate-y-1/2',
|
|
blurHeaderImage && 'blur-sm'
|
|
)}
|
|
/>
|
|
{overlayBackground && (
|
|
<div
|
|
className="absolute z-10 h-full w-full bg-alt"
|
|
style={{background: overlayBackground}}
|
|
/>
|
|
)}
|
|
<div className="gradient absolute inset-0 z-30 m-auto"></div>
|
|
<div className="relative z-30 flex h-full flex-col">
|
|
<Navbar
|
|
color="transparent"
|
|
darkModeColor="transparent"
|
|
className="flex-shrink-0"
|
|
menuPosition="landing-page-navbar"
|
|
primaryButtonColor="white"
|
|
/>
|
|
<div className="mx-auto flex max-w-850 flex-auto flex-col items-center justify-center px-14 py-50 text-center text-white lg:py-140">
|
|
{headerTitle && (
|
|
<h1
|
|
className="text-3xl font-normal md:text-5xl"
|
|
data-testid="headerTitle"
|
|
>
|
|
<Trans message={headerTitle} />
|
|
</h1>
|
|
)}
|
|
{headerSubtitle && (
|
|
<div
|
|
className="max-auto mt-24 max-w-640 text-lg tracking-tight md:text-xl"
|
|
data-testid="headerSubtitle"
|
|
>
|
|
<Trans message={headerSubtitle} />
|
|
</div>
|
|
)}
|
|
<div className="flex min-h-50 gap-20 pb-30 pt-40 empty:min-h-0 md:pb-50 md:pt-60">
|
|
<CtaButton
|
|
item={actions.cta1}
|
|
variant="raised"
|
|
color="white"
|
|
size="lg"
|
|
radius="rounded-full"
|
|
className="min-w-180"
|
|
/>
|
|
<CtaButton
|
|
item={actions.cta2}
|
|
variant="text"
|
|
color="paper"
|
|
size="lg"
|
|
radius="rounded-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="absolute bottom-0 z-20 h-[6vw] w-full translate-y-1/2 -skew-y-3 transform bg"></div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
interface CtaButtonProps extends ButtonProps {
|
|
item?: MenuItemConfig;
|
|
}
|
|
function CtaButton({item, ...buttonProps}: CtaButtonProps) {
|
|
if (!item?.label || !item?.action) return null;
|
|
const Icon = item.icon ? createSvgIconFromTree(item.icon) : undefined;
|
|
return (
|
|
<Button
|
|
elementType={item.type === 'route' ? Link : 'a'}
|
|
href={item.action}
|
|
to={item.action}
|
|
startIcon={Icon ? <Icon /> : undefined}
|
|
{...buttonProps}
|
|
>
|
|
<Trans message={item.label} />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function PrimaryFeatures({content}: ContentProps) {
|
|
if (!content.primaryFeatures?.length) {
|
|
return null;
|
|
}
|
|
return (
|
|
<Fragment>
|
|
<div
|
|
className="landing-container z-20 items-stretch gap-26 md:flex"
|
|
id="primary-features"
|
|
>
|
|
{content.primaryFeatures.map((feature, index) => (
|
|
<div
|
|
key={index}
|
|
className="mb-14 flex-1 rounded-2xl px-24 py-36 text-center shadow-[0_10px_30px_rgba(0,0,0,0.08)] dark:bg-alt md:mb-0"
|
|
data-testid={`primary-root-${index}`}
|
|
>
|
|
<MixedImage
|
|
className="mx-auto mb-30 h-128"
|
|
data-testid={`primary-image-${index}`}
|
|
src={feature.image}
|
|
/>
|
|
<h2
|
|
className="my-16 text-lg font-medium"
|
|
data-testid={`primary-title-${index}`}
|
|
>
|
|
<Trans message={feature.title} />
|
|
</h2>
|
|
<div
|
|
className="text-md text-[0.938rem]"
|
|
data-testid={`primary-subtitle-${index}`}
|
|
>
|
|
<Trans message={feature.subtitle} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-100 h-1 bg-divider" />
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
function SecondaryFeatures({content}: ContentProps) {
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'relative overflow-hidden',
|
|
content.primaryFeatures?.length && 'pt-100'
|
|
)}
|
|
>
|
|
<div className="landing-container relative" id="features">
|
|
{content.secondaryFeatures.map((feature, index) => {
|
|
const isEven = index % 2 === 0;
|
|
return (
|
|
<div
|
|
key={index}
|
|
data-testid={`secondary-root-${index}`}
|
|
className={clsx(
|
|
'relative z-20 mb-14 py-16 md:mb-80 md:flex',
|
|
isEven && 'flex-row-reverse'
|
|
)}
|
|
>
|
|
<img
|
|
src={feature.image}
|
|
className="mr-auto aspect-[600/382] w-580 max-w-full rounded-lg shadow-lg dark:border"
|
|
alt=""
|
|
/>
|
|
<div className="ml-30 mr-auto max-w-350 pt-30">
|
|
<small
|
|
className="mb-16 text-xs font-medium uppercase tracking-widest text-muted"
|
|
data-testid={`secondary-subtitle-${index}`}
|
|
>
|
|
<Trans message={feature.subtitle} />
|
|
</small>
|
|
<h3
|
|
className="py-16 text-3xl"
|
|
data-testid={`secondary-title-${index}`}
|
|
>
|
|
<Trans message={feature.title} />
|
|
</h3>
|
|
<div className="h-2 w-50 bg-black/90 dark:bg-divider" />
|
|
<div
|
|
className="my-20 text-[0.938rem]"
|
|
data-testid={`secondary-description-${index}`}
|
|
>
|
|
<Trans message={feature.description} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface PricingSectionProps {
|
|
content: LandingPageContent;
|
|
}
|
|
function PricingSection({content}: PricingSectionProps) {
|
|
const query = useProducts('landingPage');
|
|
const [selectedCycle, setSelectedCycle] =
|
|
useState<UpsellBillingCycle>('yearly');
|
|
|
|
return (
|
|
<div className="py-80 sm:py-128" id="pricing">
|
|
<div className="mx-auto max-w-1280 px-24 lg:px-32">
|
|
<div className="text-center">
|
|
{content.pricingTitle && (
|
|
<h2
|
|
className="font-display text-3xl tracking-tight sm:text-4xl"
|
|
data-testid="pricingTitle"
|
|
>
|
|
<Trans message={content.pricingTitle} />
|
|
</h2>
|
|
)}
|
|
{content.pricingSubtitle && (
|
|
<p
|
|
className="mt-16 text-lg text-muted"
|
|
data-testid="pricingSubtitle"
|
|
>
|
|
<Trans message={content.pricingSubtitle} />
|
|
</p>
|
|
)}
|
|
</div>
|
|
<BillingCycleRadio
|
|
products={query.data?.products}
|
|
selectedCycle={selectedCycle}
|
|
onChange={setSelectedCycle}
|
|
className="my-50 flex justify-center"
|
|
size="lg"
|
|
/>
|
|
<PricingTable
|
|
selectedCycle={selectedCycle}
|
|
productLoader="landingPage"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BottomCta({
|
|
content: {footerSubtitle, footerImage, footerTitle, actions},
|
|
}: ContentProps) {
|
|
if (!footerTitle && !footerSubtitle) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="relative overflow-hidden bg-black py-90 text-white before:pointer-events-none before:absolute before:inset-0 before:z-10 before:bg-gradient-to-r before:from-black before:to-transparent md:py-128"
|
|
data-testid="footerImage"
|
|
>
|
|
{footerImage && (
|
|
<img
|
|
draggable={false}
|
|
src={footerImage}
|
|
alt=""
|
|
width="2347"
|
|
height="1244"
|
|
decoding="async"
|
|
loading="lazy"
|
|
className="absolute left-1/2 top-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 blur-sm"
|
|
/>
|
|
)}
|
|
<div className="relative z-20 mx-auto max-w-1280 px-24 text-center sm:px-16 lg:px-32">
|
|
<div className="mx-auto max-w-512 text-center">
|
|
{footerTitle && (
|
|
<h2
|
|
className=" font-display text-3xl tracking-tight sm:text-4xl"
|
|
data-testid="footerTitle"
|
|
>
|
|
<Trans message={footerTitle} />
|
|
</h2>
|
|
)}
|
|
{footerSubtitle && (
|
|
<p
|
|
className="mt-16 text-lg tracking-tight"
|
|
data-testid="footerSubtitle"
|
|
>
|
|
<Trans message={footerSubtitle} />
|
|
</p>
|
|
)}
|
|
<CtaButton
|
|
item={actions.cta3}
|
|
size="lg"
|
|
radius="rounded-full"
|
|
variant="flat"
|
|
color="white"
|
|
className="mt-40 block"
|
|
data-testid="cta3"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|