@voila.dev/ui-landing 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +63 -0
- package/src/components/article-card.tsx +200 -0
- package/src/components/article-tags.tsx +31 -0
- package/src/components/bento-grid.tsx +197 -0
- package/src/components/comparison-section.tsx +209 -0
- package/src/components/contact-cards.tsx +49 -0
- package/src/components/container-variants.ts +27 -0
- package/src/components/container.tsx +34 -0
- package/src/components/cta-banner.tsx +93 -0
- package/src/components/eyebrow.tsx +99 -0
- package/src/components/feature-grid.tsx +162 -0
- package/src/components/heading-variants.ts +41 -0
- package/src/components/heading.tsx +47 -0
- package/src/components/landing-hero.tsx +144 -0
- package/src/components/logo-marquee.tsx +139 -0
- package/src/components/numbered-cards.tsx +148 -0
- package/src/components/page-header.tsx +56 -0
- package/src/components/prose-article.tsx +37 -0
- package/src/components/section-intro.tsx +82 -0
- package/src/components/section-variants.ts +47 -0
- package/src/components/section.tsx +45 -0
- package/src/components/site-footer.tsx +190 -0
- package/src/components/site-header.tsx +254 -0
- package/src/components/stats-row.tsx +69 -0
- package/src/components/step-tracks.tsx +235 -0
- package/src/components/testimonial-grid.tsx +182 -0
- package/src/components/text-variants.ts +65 -0
- package/src/components/text.tsx +45 -0
- package/src/lib/tones.ts +85 -0
- package/src/styles/landing.css +20 -0
- package/src/styles.css +10 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
|
|
3
|
+
import { Heading, type HeadingProps } from "#/components/heading.tsx";
|
|
4
|
+
import { Text, type TextProps } from "#/components/text.tsx";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bordered contact cards (email/phone). Compose: Root > Card > CardTitle +
|
|
8
|
+
* CardDescription + an action (e.g. a `@voila.dev/ui` Button rendered as a
|
|
9
|
+
* `mailto:`/`tel:` anchor).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function Root({ className, ...props }: React.ComponentProps<"div">) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
data-slot="contact-cards"
|
|
16
|
+
className={cn("grid gap-8 md:grid-cols-2", className)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
data-slot="contact-card"
|
|
26
|
+
className={cn("rounded-2xl border border-border p-8", className)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function CardTitle({ className, ...props }: HeadingProps) {
|
|
33
|
+
return (
|
|
34
|
+
<Heading level="h2" className={cn("mb-2 text-xl", className)} {...props} />
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function CardDescription({ className, ...props }: TextProps) {
|
|
39
|
+
return (
|
|
40
|
+
<Text className={cn("mb-4 text-muted-foreground", className)} {...props} />
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const ContactCards = {
|
|
45
|
+
Root,
|
|
46
|
+
Card,
|
|
47
|
+
CardTitle,
|
|
48
|
+
CardDescription,
|
|
49
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "@voila.dev/ui/cva";
|
|
2
|
+
|
|
3
|
+
export const containerVariants = cva({
|
|
4
|
+
base: "mx-auto w-full px-4 sm:px-6 lg:px-8",
|
|
5
|
+
variants: {
|
|
6
|
+
size: {
|
|
7
|
+
sm: "max-w-3xl",
|
|
8
|
+
md: "max-w-5xl",
|
|
9
|
+
lg: "max-w-6xl",
|
|
10
|
+
xl: "max-w-7xl",
|
|
11
|
+
full: "max-w-full",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: {
|
|
15
|
+
size: "xl",
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type ContainerVariants = VariantProps<typeof containerVariants>;
|
|
20
|
+
|
|
21
|
+
export const containerSizeOptions = [
|
|
22
|
+
"sm",
|
|
23
|
+
"md",
|
|
24
|
+
"lg",
|
|
25
|
+
"xl",
|
|
26
|
+
"full",
|
|
27
|
+
] as const satisfies readonly NonNullable<ContainerVariants["size"]>[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
3
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ContainerVariants,
|
|
7
|
+
containerVariants,
|
|
8
|
+
} from "#/components/container-variants.ts";
|
|
9
|
+
|
|
10
|
+
type ContainerProps = useRender.ComponentProps<"div"> & ContainerVariants;
|
|
11
|
+
|
|
12
|
+
/** Centered max-width column with responsive gutters. */
|
|
13
|
+
function Container({ className, render, size, ...props }: ContainerProps) {
|
|
14
|
+
return useRender({
|
|
15
|
+
defaultTagName: "div",
|
|
16
|
+
props: mergeProps<"div">(
|
|
17
|
+
{
|
|
18
|
+
className: cn(containerVariants({ size }), className),
|
|
19
|
+
},
|
|
20
|
+
props,
|
|
21
|
+
),
|
|
22
|
+
render,
|
|
23
|
+
state: {
|
|
24
|
+
slot: "landing-container",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
type ContainerVariants,
|
|
31
|
+
containerSizeOptions,
|
|
32
|
+
containerVariants,
|
|
33
|
+
} from "#/components/container-variants.ts";
|
|
34
|
+
export { Container, type ContainerProps };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
|
|
3
|
+
import { Container, type ContainerProps } from "#/components/container.tsx";
|
|
4
|
+
import { Heading, type HeadingProps } from "#/components/heading.tsx";
|
|
5
|
+
import { Section, type SectionProps } from "#/components/section.tsx";
|
|
6
|
+
import { Text, type TextProps } from "#/components/text.tsx";
|
|
7
|
+
import { accentOrangeBlobClass, brandGradientClass } from "#/lib/tones.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gradient call-to-action banner with decorative blur blobs. Compose: Root > Title,
|
|
11
|
+
* Description, Actions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface CtaBannerRootProps extends SectionProps {
|
|
15
|
+
containerSize?: ContainerProps["size"];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Root({
|
|
19
|
+
spacing = "lg",
|
|
20
|
+
background,
|
|
21
|
+
containerSize = "md",
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
...props
|
|
25
|
+
}: CtaBannerRootProps) {
|
|
26
|
+
return (
|
|
27
|
+
<Section
|
|
28
|
+
spacing={spacing}
|
|
29
|
+
background={background}
|
|
30
|
+
className={className}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
<Container size={containerSize}>
|
|
34
|
+
<div
|
|
35
|
+
data-slot="cta-banner"
|
|
36
|
+
className={cn(
|
|
37
|
+
"relative overflow-hidden rounded-3xl px-6 py-12 text-center text-primary-foreground shadow-xl sm:px-12 sm:py-16",
|
|
38
|
+
brandGradientClass,
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
<div className="pointer-events-none absolute -right-16 -top-16 h-56 w-56 rounded-full bg-white/10 blur-3xl" />
|
|
42
|
+
<div
|
|
43
|
+
className={cn(
|
|
44
|
+
"pointer-events-none absolute -bottom-20 -left-10 h-56 w-56 rounded-full blur-3xl",
|
|
45
|
+
accentOrangeBlobClass,
|
|
46
|
+
)}
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
<div className="relative mx-auto max-w-2xl">{children}</div>
|
|
50
|
+
</div>
|
|
51
|
+
</Container>
|
|
52
|
+
</Section>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Title({ className, ...props }: HeadingProps) {
|
|
57
|
+
return (
|
|
58
|
+
<Heading
|
|
59
|
+
level="h2"
|
|
60
|
+
align="center"
|
|
61
|
+
className={cn("mb-4 text-white", className)}
|
|
62
|
+
{...props}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Description({ className, ...props }: TextProps) {
|
|
68
|
+
return (
|
|
69
|
+
<Text className={cn("mb-8 text-lg text-white/90", className)} {...props} />
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Actions({ className, ...props }: React.ComponentProps<"div">) {
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
data-slot="cta-banner-actions"
|
|
77
|
+
className={cn(
|
|
78
|
+
"flex flex-col justify-center gap-4 sm:flex-row",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const CtaBanner = {
|
|
87
|
+
Root,
|
|
88
|
+
Title,
|
|
89
|
+
Description,
|
|
90
|
+
Actions,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type { CtaBannerRootProps };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type Tone,
|
|
6
|
+
toneOptions,
|
|
7
|
+
toneSolidBackgroundClass,
|
|
8
|
+
toneTextClass,
|
|
9
|
+
toneTintBackgroundClass,
|
|
10
|
+
} from "#/lib/tones.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The badge pill that opens most sections ("Nouvelle plateforme", "Pour les
|
|
14
|
+
* clubs"…): tinted rounded-full chip with an optional pulse dot or icon and a
|
|
15
|
+
* label in the tone color. Reproduces the inline pattern of
|
|
16
|
+
* the source design.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const EyebrowToneContext = createContext<Tone>("primary");
|
|
20
|
+
|
|
21
|
+
interface EyebrowRootProps extends React.ComponentProps<"div"> {
|
|
22
|
+
tone?: Tone;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Root({ tone = "primary", className, ...props }: EyebrowRootProps) {
|
|
26
|
+
return (
|
|
27
|
+
<EyebrowToneContext.Provider value={tone}>
|
|
28
|
+
<div
|
|
29
|
+
data-slot="eyebrow"
|
|
30
|
+
className={cn(
|
|
31
|
+
"inline-flex items-center gap-2 rounded-full px-4 py-1.5",
|
|
32
|
+
toneTintBackgroundClass[tone],
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
</EyebrowToneContext.Provider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface EyebrowDotProps extends React.ComponentProps<"span"> {
|
|
42
|
+
/** The hero variant pulses; the values variant is still. */
|
|
43
|
+
pulse?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Dot({ pulse = false, className, ...props }: EyebrowDotProps) {
|
|
47
|
+
const tone = useContext(EyebrowToneContext);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<span
|
|
51
|
+
data-slot="eyebrow-dot"
|
|
52
|
+
className={cn(
|
|
53
|
+
"h-2 w-2 rounded-full",
|
|
54
|
+
pulse && "animate-pulse",
|
|
55
|
+
toneSolidBackgroundClass[tone],
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Icon({ className, ...props }: React.ComponentProps<"span">) {
|
|
64
|
+
const tone = useContext(EyebrowToneContext);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
data-slot="eyebrow-icon"
|
|
69
|
+
className={cn(
|
|
70
|
+
"flex items-center [&_svg]:h-4 [&_svg]:w-4",
|
|
71
|
+
toneTextClass[tone],
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Label({ className, ...props }: React.ComponentProps<"span">) {
|
|
80
|
+
const tone = useContext(EyebrowToneContext);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<span
|
|
84
|
+
data-slot="eyebrow-label"
|
|
85
|
+
className={cn("text-sm font-medium", toneTextClass[tone], className)}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const Eyebrow = {
|
|
92
|
+
Root,
|
|
93
|
+
Dot,
|
|
94
|
+
Icon,
|
|
95
|
+
Label,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type { EyebrowDotProps, EyebrowRootProps };
|
|
99
|
+
export { type Tone, toneOptions };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "@voila.dev/ui/cva";
|
|
2
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Tone,
|
|
7
|
+
toneTextClass,
|
|
8
|
+
toneTintBackgroundClass,
|
|
9
|
+
} from "#/lib/tones.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Grid of icon cards (pain points, benefits, product highlights). Compose: Root (tone,
|
|
13
|
+
* columns) > Card > CardIcon + CardTitle + CardDescription.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FeatureGridToneContext = createContext<Tone>("primary");
|
|
17
|
+
|
|
18
|
+
const featureGridVariants = cva({
|
|
19
|
+
base: "grid gap-6",
|
|
20
|
+
variants: {
|
|
21
|
+
columns: {
|
|
22
|
+
"2": "md:grid-cols-2",
|
|
23
|
+
"3": "md:grid-cols-3",
|
|
24
|
+
"4": "md:grid-cols-2 lg:grid-cols-4",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
defaultVariants: {
|
|
28
|
+
columns: "3",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
type FeatureGridVariants = VariantProps<typeof featureGridVariants>;
|
|
33
|
+
|
|
34
|
+
const featureGridColumnsOptions = [
|
|
35
|
+
"2",
|
|
36
|
+
"3",
|
|
37
|
+
"4",
|
|
38
|
+
] as const satisfies readonly NonNullable<FeatureGridVariants["columns"]>[];
|
|
39
|
+
|
|
40
|
+
interface FeatureGridRootProps
|
|
41
|
+
extends React.ComponentProps<"div">,
|
|
42
|
+
FeatureGridVariants {
|
|
43
|
+
tone?: Tone;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Root({
|
|
47
|
+
tone = "primary",
|
|
48
|
+
columns,
|
|
49
|
+
className,
|
|
50
|
+
...props
|
|
51
|
+
}: FeatureGridRootProps) {
|
|
52
|
+
return (
|
|
53
|
+
<FeatureGridToneContext.Provider value={tone}>
|
|
54
|
+
<div
|
|
55
|
+
data-slot="feature-grid"
|
|
56
|
+
className={cn(featureGridVariants({ columns }), className)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
</FeatureGridToneContext.Provider>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const featureGridCardVariants = cva({
|
|
64
|
+
base: "h-full rounded-2xl bg-card text-card-foreground transition-all duration-200",
|
|
65
|
+
variants: {
|
|
66
|
+
variant: {
|
|
67
|
+
elevated: "border border-transparent p-8 shadow-lg",
|
|
68
|
+
outline: "border border-border p-8",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
defaultVariants: {
|
|
72
|
+
variant: "elevated",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
type FeatureGridCardVariants = VariantProps<typeof featureGridCardVariants>;
|
|
77
|
+
|
|
78
|
+
const featureGridCardVariantOptions = [
|
|
79
|
+
"elevated",
|
|
80
|
+
"outline",
|
|
81
|
+
] as const satisfies readonly NonNullable<FeatureGridCardVariants["variant"]>[];
|
|
82
|
+
|
|
83
|
+
interface FeatureGridCardProps
|
|
84
|
+
extends React.ComponentProps<"div">,
|
|
85
|
+
FeatureGridCardVariants {}
|
|
86
|
+
|
|
87
|
+
function Card({ variant, className, ...props }: FeatureGridCardProps) {
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
data-slot="feature-grid-card"
|
|
91
|
+
className={cn(featureGridCardVariants({ variant }), className)}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface FeatureGridCardIconProps extends React.ComponentProps<"span"> {
|
|
98
|
+
tone?: Tone;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function CardIcon({ tone, className, ...props }: FeatureGridCardIconProps) {
|
|
102
|
+
const inheritedTone = useContext(FeatureGridToneContext);
|
|
103
|
+
const resolvedTone = tone ?? inheritedTone;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<span
|
|
107
|
+
data-slot="feature-grid-card-icon"
|
|
108
|
+
className={cn(
|
|
109
|
+
"mb-4 flex h-12 w-12 items-center justify-center rounded-xl [&_svg]:h-6 [&_svg]:w-6",
|
|
110
|
+
toneTintBackgroundClass[resolvedTone],
|
|
111
|
+
toneTextClass[resolvedTone],
|
|
112
|
+
className,
|
|
113
|
+
)}
|
|
114
|
+
{...props}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
|
120
|
+
return (
|
|
121
|
+
<h3
|
|
122
|
+
data-slot="feature-grid-card-title"
|
|
123
|
+
className={cn(
|
|
124
|
+
"mb-2 font-heading text-xl font-bold tracking-tight text-foreground",
|
|
125
|
+
className,
|
|
126
|
+
)}
|
|
127
|
+
{...props}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
133
|
+
return (
|
|
134
|
+
<p
|
|
135
|
+
data-slot="feature-grid-card-description"
|
|
136
|
+
className={cn("text-sm leading-relaxed text-muted-foreground", className)}
|
|
137
|
+
{...props}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const FeatureGrid = {
|
|
143
|
+
Root,
|
|
144
|
+
Card,
|
|
145
|
+
CardIcon,
|
|
146
|
+
CardTitle,
|
|
147
|
+
CardDescription,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type {
|
|
151
|
+
FeatureGridCardIconProps,
|
|
152
|
+
FeatureGridCardProps,
|
|
153
|
+
FeatureGridCardVariants,
|
|
154
|
+
FeatureGridRootProps,
|
|
155
|
+
FeatureGridVariants,
|
|
156
|
+
};
|
|
157
|
+
export {
|
|
158
|
+
featureGridCardVariantOptions,
|
|
159
|
+
featureGridCardVariants,
|
|
160
|
+
featureGridColumnsOptions,
|
|
161
|
+
featureGridVariants,
|
|
162
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "@voila.dev/ui/cva";
|
|
2
|
+
|
|
3
|
+
export const headingVariants = cva({
|
|
4
|
+
base: "font-heading font-bold tracking-tight text-foreground",
|
|
5
|
+
variants: {
|
|
6
|
+
level: {
|
|
7
|
+
h1: "text-4xl md:text-5xl lg:text-6xl",
|
|
8
|
+
h2: "text-3xl md:text-4xl lg:text-5xl",
|
|
9
|
+
h3: "text-2xl md:text-3xl",
|
|
10
|
+
h4: "text-xl md:text-2xl",
|
|
11
|
+
h5: "text-lg md:text-xl",
|
|
12
|
+
h6: "text-base md:text-lg",
|
|
13
|
+
},
|
|
14
|
+
align: {
|
|
15
|
+
left: "text-left",
|
|
16
|
+
center: "text-center",
|
|
17
|
+
right: "text-right",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: {
|
|
21
|
+
level: "h2",
|
|
22
|
+
align: "left",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type HeadingVariants = VariantProps<typeof headingVariants>;
|
|
27
|
+
|
|
28
|
+
export const headingLevelOptions = [
|
|
29
|
+
"h1",
|
|
30
|
+
"h2",
|
|
31
|
+
"h3",
|
|
32
|
+
"h4",
|
|
33
|
+
"h5",
|
|
34
|
+
"h6",
|
|
35
|
+
] as const satisfies readonly NonNullable<HeadingVariants["level"]>[];
|
|
36
|
+
|
|
37
|
+
export const headingAlignOptions = [
|
|
38
|
+
"left",
|
|
39
|
+
"center",
|
|
40
|
+
"right",
|
|
41
|
+
] as const satisfies readonly NonNullable<HeadingVariants["align"]>[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
3
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type HeadingVariants,
|
|
7
|
+
headingVariants,
|
|
8
|
+
} from "#/components/heading-variants.ts";
|
|
9
|
+
|
|
10
|
+
type HeadingLevel = NonNullable<HeadingVariants["level"]>;
|
|
11
|
+
|
|
12
|
+
type HeadingProps = useRender.ComponentProps<"h2"> &
|
|
13
|
+
HeadingVariants & {
|
|
14
|
+
/** Sets both the rendered tag and the size scale (override the tag with `render`). */
|
|
15
|
+
level?: HeadingLevel;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Marketing heading — `font-heading`, responsive size scale per level. */
|
|
19
|
+
function Heading({
|
|
20
|
+
className,
|
|
21
|
+
render,
|
|
22
|
+
level = "h2",
|
|
23
|
+
align,
|
|
24
|
+
...props
|
|
25
|
+
}: HeadingProps) {
|
|
26
|
+
return useRender({
|
|
27
|
+
defaultTagName: level,
|
|
28
|
+
props: mergeProps<"h2">(
|
|
29
|
+
{
|
|
30
|
+
className: cn(headingVariants({ level, align }), className),
|
|
31
|
+
},
|
|
32
|
+
props,
|
|
33
|
+
),
|
|
34
|
+
render,
|
|
35
|
+
state: {
|
|
36
|
+
slot: "landing-heading",
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
type HeadingVariants,
|
|
43
|
+
headingAlignOptions,
|
|
44
|
+
headingLevelOptions,
|
|
45
|
+
headingVariants,
|
|
46
|
+
} from "#/components/heading-variants.ts";
|
|
47
|
+
export { Heading, type HeadingLevel, type HeadingProps };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "@voila.dev/ui/cva";
|
|
2
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
3
|
+
|
|
4
|
+
import { Container } from "#/components/container.tsx";
|
|
5
|
+
import { Heading, type HeadingProps } from "#/components/heading.tsx";
|
|
6
|
+
import { Section, type SectionProps } from "#/components/section.tsx";
|
|
7
|
+
import { Text, type TextProps } from "#/components/text.tsx";
|
|
8
|
+
import { type Tone, toneTextClass } from "#/lib/tones.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Landing page hero. Split layout = content column + illustration/media
|
|
12
|
+
* column; centered layout for
|
|
13
|
+
* media-less heroes. Compose: Root > Content (Eyebrow, Title + Highlight,
|
|
14
|
+
* Lead, Actions, StatsRow) + Media.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const landingHeroLayoutVariants = cva({
|
|
18
|
+
base: "grid items-center gap-12",
|
|
19
|
+
variants: {
|
|
20
|
+
layout: {
|
|
21
|
+
split: "lg:grid-cols-2",
|
|
22
|
+
centered: "justify-items-center text-center",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
layout: "split",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type LandingHeroLayoutVariants = VariantProps<typeof landingHeroLayoutVariants>;
|
|
31
|
+
|
|
32
|
+
const landingHeroLayoutOptions = [
|
|
33
|
+
"split",
|
|
34
|
+
"centered",
|
|
35
|
+
] as const satisfies readonly NonNullable<
|
|
36
|
+
LandingHeroLayoutVariants["layout"]
|
|
37
|
+
>[];
|
|
38
|
+
|
|
39
|
+
interface LandingHeroRootProps
|
|
40
|
+
extends SectionProps,
|
|
41
|
+
LandingHeroLayoutVariants {}
|
|
42
|
+
|
|
43
|
+
function Root({
|
|
44
|
+
layout,
|
|
45
|
+
spacing = "lg",
|
|
46
|
+
background,
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
...props
|
|
50
|
+
}: LandingHeroRootProps) {
|
|
51
|
+
return (
|
|
52
|
+
<Section
|
|
53
|
+
spacing={spacing}
|
|
54
|
+
background={background}
|
|
55
|
+
className={cn("overflow-hidden", className)}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
<Container>
|
|
59
|
+
<div className={landingHeroLayoutVariants({ layout })}>{children}</div>
|
|
60
|
+
</Container>
|
|
61
|
+
</Section>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Content({ className, ...props }: React.ComponentProps<"div">) {
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
data-slot="landing-hero-content"
|
|
69
|
+
className={cn("animate-fade-up max-w-2xl", className)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Title({ className, ...props }: HeadingProps) {
|
|
76
|
+
return <Heading level="h1" className={cn("mb-6", className)} {...props} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface LandingHeroHighlightProps extends React.ComponentProps<"span"> {
|
|
80
|
+
tone?: Tone;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Colored span inside the title — the multi-tone headline of the home hero. */
|
|
84
|
+
function Highlight({
|
|
85
|
+
tone = "primary",
|
|
86
|
+
className,
|
|
87
|
+
...props
|
|
88
|
+
}: LandingHeroHighlightProps) {
|
|
89
|
+
return (
|
|
90
|
+
<span
|
|
91
|
+
data-slot="landing-hero-highlight"
|
|
92
|
+
className={cn(toneTextClass[tone], className)}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function Lead({ className, ...props }: TextProps) {
|
|
99
|
+
return (
|
|
100
|
+
<Text
|
|
101
|
+
variant="lead"
|
|
102
|
+
className={cn("mb-8 max-w-xl", className)}
|
|
103
|
+
{...props}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function Actions({ className, ...props }: React.ComponentProps<"div">) {
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
data-slot="landing-hero-actions"
|
|
112
|
+
className={cn("flex flex-col gap-4 sm:flex-row", className)}
|
|
113
|
+
{...props}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Illustration column — hidden below `lg`, like the Astro heroes. */
|
|
119
|
+
function Media({ className, ...props }: React.ComponentProps<"div">) {
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
data-slot="landing-hero-media"
|
|
123
|
+
className={cn("hidden lg:block", className)}
|
|
124
|
+
{...props}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const LandingHero = {
|
|
130
|
+
Root,
|
|
131
|
+
Content,
|
|
132
|
+
Title,
|
|
133
|
+
Highlight,
|
|
134
|
+
Lead,
|
|
135
|
+
Actions,
|
|
136
|
+
Media,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export type {
|
|
140
|
+
LandingHeroHighlightProps,
|
|
141
|
+
LandingHeroLayoutVariants,
|
|
142
|
+
LandingHeroRootProps,
|
|
143
|
+
};
|
|
144
|
+
export { landingHeroLayoutOptions, landingHeroLayoutVariants };
|