@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
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voila.dev/ui-landing",
|
|
3
|
+
"version": "1.1.9",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Your marketing site, from the same system as your product.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://ui.voila.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/voila-voila-dev/ui.git",
|
|
11
|
+
"directory": "packages/ui-landing"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"react",
|
|
15
|
+
"landing-page",
|
|
16
|
+
"marketing",
|
|
17
|
+
"sections",
|
|
18
|
+
"tailwindcss"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"!src/**/*.test.tsx",
|
|
27
|
+
"!src/**/*.stories.ts",
|
|
28
|
+
"!src/**/*.stories.tsx"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": [
|
|
31
|
+
"**/*.css"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
"./styles.css": "./src/styles.css",
|
|
35
|
+
"./styles/landing.css": "./src/styles/landing.css",
|
|
36
|
+
"./lib/tones": "./src/lib/tones.ts",
|
|
37
|
+
"./section-variants": "./src/components/section-variants.ts",
|
|
38
|
+
"./container-variants": "./src/components/container-variants.ts",
|
|
39
|
+
"./heading-variants": "./src/components/heading-variants.ts",
|
|
40
|
+
"./text-variants": "./src/components/text-variants.ts",
|
|
41
|
+
"./components/*": "./src/components/*.tsx"
|
|
42
|
+
},
|
|
43
|
+
"imports": {
|
|
44
|
+
"#/*": "./src/*"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"check": "biome check",
|
|
48
|
+
"check-types": "tsc --noEmit",
|
|
49
|
+
"format": "biome format",
|
|
50
|
+
"lint": "biome lint",
|
|
51
|
+
"test": "vitest run"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@base-ui/react": "^1.4.1",
|
|
55
|
+
"@phosphor-icons/react": "2.1.10",
|
|
56
|
+
"@voila.dev/ui": "0.0.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react": "^19.0.0",
|
|
60
|
+
"react-dom": "^19.0.0",
|
|
61
|
+
"tailwindcss": "^4.0.0"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
3
|
+
import { ArrowUpRightIcon } from "@phosphor-icons/react";
|
|
4
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
5
|
+
|
|
6
|
+
import { ArticleTags } from "#/components/article-tags.tsx";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Blog article card.
|
|
10
|
+
* Compose: Root (anchor) > Frame > Image | ImageFallback + Content (Tags,
|
|
11
|
+
* Title, Description, Meta > MetaItem…/Arrow).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The clickable wrapper — an anchor by default; pass `render` for a router Link. */
|
|
15
|
+
function Root({ className, render, ...props }: useRender.ComponentProps<"a">) {
|
|
16
|
+
return useRender({
|
|
17
|
+
defaultTagName: "a",
|
|
18
|
+
props: mergeProps<"a">(
|
|
19
|
+
{
|
|
20
|
+
className: cn("group block", className),
|
|
21
|
+
},
|
|
22
|
+
props,
|
|
23
|
+
),
|
|
24
|
+
render,
|
|
25
|
+
state: {
|
|
26
|
+
slot: "article-card",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The card surface (Astro `Card` default/none/lift as `article`). */
|
|
32
|
+
function Frame({ className, ...props }: React.ComponentProps<"article">) {
|
|
33
|
+
return (
|
|
34
|
+
<article
|
|
35
|
+
data-slot="article-card-frame"
|
|
36
|
+
className={cn(
|
|
37
|
+
"rounded-lg border border-border bg-card text-card-foreground transition-all duration-200 hover:-translate-y-1 hover:shadow-lg",
|
|
38
|
+
className,
|
|
39
|
+
)}
|
|
40
|
+
{...props}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ArticleCardImageProps extends React.ComponentProps<"img"> {
|
|
46
|
+
alt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Image({ className, alt, ...props }: ArticleCardImageProps) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="article-card-image"
|
|
53
|
+
className="relative aspect-video overflow-hidden rounded-t-lg"
|
|
54
|
+
>
|
|
55
|
+
<img
|
|
56
|
+
alt={alt}
|
|
57
|
+
loading="lazy"
|
|
58
|
+
className={cn(
|
|
59
|
+
"h-full w-full object-cover transition-transform duration-300 group-hover:scale-105",
|
|
60
|
+
className,
|
|
61
|
+
)}
|
|
62
|
+
{...props}
|
|
63
|
+
/>
|
|
64
|
+
<div className="absolute inset-0 bg-linear-to-t from-black/20 to-transparent" />
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Gradient placeholder shown when the article has no cover image. */
|
|
70
|
+
function ImageFallback({
|
|
71
|
+
className,
|
|
72
|
+
children,
|
|
73
|
+
...props
|
|
74
|
+
}: React.ComponentProps<"div">) {
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
data-slot="article-card-image-fallback"
|
|
78
|
+
className={cn(
|
|
79
|
+
"relative aspect-video overflow-hidden rounded-t-lg bg-linear-to-br from-provider/20 to-organization/20",
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
{...props}
|
|
83
|
+
>
|
|
84
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
85
|
+
<span className="text-6xl opacity-30">{children ?? "📝"}</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Content({ className, ...props }: React.ComponentProps<"div">) {
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
data-slot="article-card-content"
|
|
95
|
+
className={cn("p-5", className)}
|
|
96
|
+
{...props}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Tags({ className, ...props }: React.ComponentProps<"div">) {
|
|
102
|
+
return <ArticleTags.Root className={cn("mb-3", className)} {...props} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function Title({ className, ...props }: React.ComponentProps<"h3">) {
|
|
106
|
+
return (
|
|
107
|
+
<h3
|
|
108
|
+
data-slot="article-card-title"
|
|
109
|
+
className={cn(
|
|
110
|
+
"mb-2 line-clamp-2 text-lg font-semibold transition-colors group-hover:text-provider",
|
|
111
|
+
className,
|
|
112
|
+
)}
|
|
113
|
+
{...props}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function Description({ className, ...props }: React.ComponentProps<"p">) {
|
|
119
|
+
return (
|
|
120
|
+
<p
|
|
121
|
+
data-slot="article-card-description"
|
|
122
|
+
className={cn(
|
|
123
|
+
"mb-4 line-clamp-2 text-sm text-muted-foreground",
|
|
124
|
+
className,
|
|
125
|
+
)}
|
|
126
|
+
{...props}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Meta({ className, ...props }: React.ComponentProps<"div">) {
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
data-slot="article-card-meta"
|
|
135
|
+
className={cn(
|
|
136
|
+
"flex items-center justify-between text-xs text-muted-foreground",
|
|
137
|
+
className,
|
|
138
|
+
)}
|
|
139
|
+
{...props}
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function MetaItems({ className, ...props }: React.ComponentProps<"div">) {
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
data-slot="article-card-meta-items"
|
|
148
|
+
className={cn("flex items-center gap-3", className)}
|
|
149
|
+
{...props}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Date / reading-time entry — icon child + text. */
|
|
155
|
+
function MetaItem({ className, ...props }: React.ComponentProps<"span">) {
|
|
156
|
+
return (
|
|
157
|
+
<span
|
|
158
|
+
data-slot="article-card-meta-item"
|
|
159
|
+
className={cn(
|
|
160
|
+
"flex items-center gap-1 [&_svg]:h-3.5 [&_svg]:w-3.5",
|
|
161
|
+
className,
|
|
162
|
+
)}
|
|
163
|
+
{...props}
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function Arrow({
|
|
169
|
+
className,
|
|
170
|
+
...props
|
|
171
|
+
}: React.ComponentProps<typeof ArrowUpRightIcon>) {
|
|
172
|
+
return (
|
|
173
|
+
<ArrowUpRightIcon
|
|
174
|
+
data-slot="article-card-arrow"
|
|
175
|
+
className={cn(
|
|
176
|
+
"h-4 w-4 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5",
|
|
177
|
+
className,
|
|
178
|
+
)}
|
|
179
|
+
{...props}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const ArticleCard = {
|
|
185
|
+
Root,
|
|
186
|
+
Frame,
|
|
187
|
+
Image,
|
|
188
|
+
ImageFallback,
|
|
189
|
+
Content,
|
|
190
|
+
Tags,
|
|
191
|
+
Title,
|
|
192
|
+
Description,
|
|
193
|
+
Meta,
|
|
194
|
+
MetaItems,
|
|
195
|
+
MetaItem,
|
|
196
|
+
Arrow,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export type { ArticleCardImageProps };
|
|
200
|
+
export { ArticleTags };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
|
|
3
|
+
/** Tag chip row for blog articles. */
|
|
4
|
+
|
|
5
|
+
function Root({ className, ...props }: React.ComponentProps<"div">) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-slot="article-tags"
|
|
9
|
+
className={cn("flex flex-wrap gap-2", className)}
|
|
10
|
+
{...props}
|
|
11
|
+
/>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function Tag({ className, ...props }: React.ComponentProps<"span">) {
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
data-slot="article-tag"
|
|
19
|
+
className={cn(
|
|
20
|
+
"inline-flex items-center rounded-full bg-provider/10 px-2.5 py-0.5 text-xs font-medium text-provider transition-colors",
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ArticleTags = {
|
|
29
|
+
Root,
|
|
30
|
+
Tag,
|
|
31
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
|
|
3
|
+
import { accentOrangeBlobClass, brandGradientClass } from "#/lib/tones.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bento grid with a featured gradient tile. Compose: Root > FeaturedItem (FeaturedContent >
|
|
7
|
+
* FeaturedIcon/FeaturedLabel/FeaturedTitle + FeaturedDescription) + Item
|
|
8
|
+
* (ItemIcon + ItemBody > ItemTitle/ItemDescription).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function Root({ className, ...props }: React.ComponentProps<"div">) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
data-slot="bento-grid"
|
|
15
|
+
className={cn("grid gap-6 lg:grid-cols-3", className)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The gradient hero tile — spans two rows on desktop, owns the blur blobs. */
|
|
22
|
+
function FeaturedItem({
|
|
23
|
+
className,
|
|
24
|
+
children,
|
|
25
|
+
...props
|
|
26
|
+
}: React.ComponentProps<"article">) {
|
|
27
|
+
return (
|
|
28
|
+
<article
|
|
29
|
+
data-slot="bento-featured-item"
|
|
30
|
+
className={cn(
|
|
31
|
+
"animate-fade-up relative flex flex-col justify-between overflow-hidden rounded-2xl p-8 text-primary-foreground shadow-xl lg:row-span-2",
|
|
32
|
+
brandGradientClass,
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
>
|
|
37
|
+
<div className="pointer-events-none absolute -right-16 -top-16 h-56 w-56 rounded-full bg-white/10 blur-2xl" />
|
|
38
|
+
<div
|
|
39
|
+
className={cn(
|
|
40
|
+
"pointer-events-none absolute -bottom-20 -left-10 h-56 w-56 rounded-full blur-3xl",
|
|
41
|
+
accentOrangeBlobClass,
|
|
42
|
+
)}
|
|
43
|
+
/>
|
|
44
|
+
{children}
|
|
45
|
+
</article>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function FeaturedContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="bento-featured-content"
|
|
53
|
+
className={cn("relative", className)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function FeaturedIcon({ className, ...props }: React.ComponentProps<"div">) {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
data-slot="bento-featured-icon"
|
|
63
|
+
className={cn(
|
|
64
|
+
"mb-6 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-white/15 backdrop-blur [&_svg]:h-7 [&_svg]:w-7",
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function FeaturedLabel({ className, ...props }: React.ComponentProps<"p">) {
|
|
73
|
+
return (
|
|
74
|
+
<p
|
|
75
|
+
data-slot="bento-featured-label"
|
|
76
|
+
className={cn(
|
|
77
|
+
"mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-white/70",
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
{...props}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function FeaturedTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
|
86
|
+
return (
|
|
87
|
+
<h3
|
|
88
|
+
data-slot="bento-featured-title"
|
|
89
|
+
className={cn(
|
|
90
|
+
"font-heading text-5xl font-bold tracking-tight",
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function FeaturedDescription({
|
|
99
|
+
className,
|
|
100
|
+
...props
|
|
101
|
+
}: React.ComponentProps<"p">) {
|
|
102
|
+
return (
|
|
103
|
+
<p
|
|
104
|
+
data-slot="bento-featured-description"
|
|
105
|
+
className={cn(
|
|
106
|
+
"relative mt-6 text-base leading-relaxed text-white/90",
|
|
107
|
+
className,
|
|
108
|
+
)}
|
|
109
|
+
{...props}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface BentoGridItemProps extends React.ComponentProps<"article"> {
|
|
115
|
+
/** Spans two columns on desktop (the last tile of an odd set). */
|
|
116
|
+
wide?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function Item({ wide = false, className, ...props }: BentoGridItemProps) {
|
|
120
|
+
return (
|
|
121
|
+
<article
|
|
122
|
+
data-slot="bento-item"
|
|
123
|
+
className={cn(
|
|
124
|
+
"animate-fade-up group rounded-2xl border border-border bg-card p-6 shadow-sm transition-all hover:-translate-y-1 hover:border-primary/20 hover:shadow-md lg:p-8",
|
|
125
|
+
wide && "lg:col-span-2",
|
|
126
|
+
className,
|
|
127
|
+
)}
|
|
128
|
+
{...props}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Icon tile + text laid out side by side. */
|
|
134
|
+
function ItemLayout({ className, ...props }: React.ComponentProps<"div">) {
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
data-slot="bento-item-layout"
|
|
138
|
+
className={cn("flex items-start gap-4", className)}
|
|
139
|
+
{...props}
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ItemIcon({ className, ...props }: React.ComponentProps<"div">) {
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
data-slot="bento-item-icon"
|
|
148
|
+
className={cn(
|
|
149
|
+
"flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary/15 [&_svg]:h-6 [&_svg]:w-6",
|
|
150
|
+
className,
|
|
151
|
+
)}
|
|
152
|
+
{...props}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function ItemBody({ className, ...props }: React.ComponentProps<"div">) {
|
|
158
|
+
return <div data-slot="bento-item-body" className={className} {...props} />;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ItemTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
|
162
|
+
return (
|
|
163
|
+
<h3
|
|
164
|
+
data-slot="bento-item-title"
|
|
165
|
+
className={cn("mb-2 font-heading text-xl font-semibold", className)}
|
|
166
|
+
{...props}
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
172
|
+
return (
|
|
173
|
+
<p
|
|
174
|
+
data-slot="bento-item-description"
|
|
175
|
+
className={cn("text-sm leading-relaxed text-muted-foreground", className)}
|
|
176
|
+
{...props}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const BentoGrid = {
|
|
182
|
+
Root,
|
|
183
|
+
FeaturedItem,
|
|
184
|
+
FeaturedContent,
|
|
185
|
+
FeaturedIcon,
|
|
186
|
+
FeaturedLabel,
|
|
187
|
+
FeaturedTitle,
|
|
188
|
+
FeaturedDescription,
|
|
189
|
+
Item,
|
|
190
|
+
ItemLayout,
|
|
191
|
+
ItemIcon,
|
|
192
|
+
ItemBody,
|
|
193
|
+
ItemTitle,
|
|
194
|
+
ItemDescription,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export type { BentoGridItemProps };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react";
|
|
2
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
import { Container } from "#/components/container.tsx";
|
|
6
|
+
import { Section, type SectionProps } from "#/components/section.tsx";
|
|
7
|
+
import { type Tone, tonePanelClass, toneTextClass } from "#/lib/tones.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Before/after benefits split (crossed-out list vs checked list + tag chips +
|
|
11
|
+
* illustration).
|
|
12
|
+
* Compose: Root (tone) > Content (Eyebrow, Heading, Panels > Panel >
|
|
13
|
+
* PanelTitle + PanelList > PanelItem, TagList > Tag, actions) + Media.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ComparisonToneContext = createContext<Tone>("primary");
|
|
17
|
+
|
|
18
|
+
type ComparisonPanelVariant = "without" | "with";
|
|
19
|
+
|
|
20
|
+
const ComparisonPanelContext = createContext<ComparisonPanelVariant>("with");
|
|
21
|
+
|
|
22
|
+
interface ComparisonSectionRootProps extends SectionProps {
|
|
23
|
+
tone?: Tone;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Root({
|
|
27
|
+
tone = "primary",
|
|
28
|
+
spacing = "lg",
|
|
29
|
+
background,
|
|
30
|
+
className,
|
|
31
|
+
children,
|
|
32
|
+
...props
|
|
33
|
+
}: ComparisonSectionRootProps) {
|
|
34
|
+
return (
|
|
35
|
+
<ComparisonToneContext.Provider value={tone}>
|
|
36
|
+
{/* overflow-hidden: the Media illustrations carry decorative blur blobs
|
|
37
|
+
that would otherwise create a few pixels of horizontal scroll. */}
|
|
38
|
+
<Section
|
|
39
|
+
spacing={spacing}
|
|
40
|
+
background={background}
|
|
41
|
+
className={cn("overflow-hidden", className)}
|
|
42
|
+
{...props}
|
|
43
|
+
>
|
|
44
|
+
<Container>
|
|
45
|
+
<div className="grid items-center gap-12 lg:grid-cols-2">
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
</Container>
|
|
49
|
+
</Section>
|
|
50
|
+
</ComparisonToneContext.Provider>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Content({ className, ...props }: React.ComponentProps<"div">) {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
data-slot="comparison-content"
|
|
58
|
+
className={cn("min-w-0", className)}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Panels({ className, ...props }: React.ComponentProps<"div">) {
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
data-slot="comparison-panels"
|
|
68
|
+
className={cn("mb-6 space-y-6", className)}
|
|
69
|
+
{...props}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ComparisonPanelProps extends React.ComponentProps<"div"> {
|
|
75
|
+
variant: ComparisonPanelVariant;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function Panel({ variant, className, ...props }: ComparisonPanelProps) {
|
|
79
|
+
const tone = useContext(ComparisonToneContext);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<ComparisonPanelContext.Provider value={variant}>
|
|
83
|
+
<div
|
|
84
|
+
data-slot="comparison-panel"
|
|
85
|
+
className={cn(
|
|
86
|
+
"animate-fade-up rounded-2xl border p-5",
|
|
87
|
+
variant === "without"
|
|
88
|
+
? "border-border bg-muted/50"
|
|
89
|
+
: tonePanelClass[tone],
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
</ComparisonPanelContext.Provider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function PanelTitle({ className, ...props }: React.ComponentProps<"p">) {
|
|
99
|
+
const tone = useContext(ComparisonToneContext);
|
|
100
|
+
const variant = useContext(ComparisonPanelContext);
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<p
|
|
104
|
+
data-slot="comparison-panel-title"
|
|
105
|
+
className={cn(
|
|
106
|
+
"mb-3 text-sm font-semibold uppercase tracking-wide",
|
|
107
|
+
variant === "without" ? "text-muted-foreground" : toneTextClass[tone],
|
|
108
|
+
className,
|
|
109
|
+
)}
|
|
110
|
+
{...props}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function PanelList({ className, ...props }: React.ComponentProps<"ul">) {
|
|
116
|
+
return (
|
|
117
|
+
<ul
|
|
118
|
+
data-slot="comparison-panel-list"
|
|
119
|
+
className={cn("space-y-3", className)}
|
|
120
|
+
{...props}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface ComparisonPanelItemProps extends React.ComponentProps<"li"> {
|
|
126
|
+
/** Replaces the default XCircle/CheckCircle mark. */
|
|
127
|
+
icon?: React.ReactNode;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function PanelItem({
|
|
131
|
+
icon,
|
|
132
|
+
className,
|
|
133
|
+
children,
|
|
134
|
+
...props
|
|
135
|
+
}: ComparisonPanelItemProps) {
|
|
136
|
+
const variant = useContext(ComparisonPanelContext);
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<li
|
|
140
|
+
data-slot="comparison-panel-item"
|
|
141
|
+
className={cn("flex animate-fade-up items-start gap-3", className)}
|
|
142
|
+
{...props}
|
|
143
|
+
>
|
|
144
|
+
{icon ??
|
|
145
|
+
(variant === "without" ? (
|
|
146
|
+
<XCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
|
147
|
+
) : (
|
|
148
|
+
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-success" />
|
|
149
|
+
))}
|
|
150
|
+
<span className={cn(variant === "without" && "text-muted-foreground")}>
|
|
151
|
+
{children}
|
|
152
|
+
</span>
|
|
153
|
+
</li>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function TagList({ className, ...props }: React.ComponentProps<"div">) {
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
data-slot="comparison-tag-list"
|
|
161
|
+
className={cn("mb-8 flex flex-wrap gap-2", className)}
|
|
162
|
+
{...props}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function Tag({ className, ...props }: React.ComponentProps<"span">) {
|
|
168
|
+
return (
|
|
169
|
+
<span
|
|
170
|
+
data-slot="comparison-tag"
|
|
171
|
+
className={cn(
|
|
172
|
+
"rounded-full border border-border bg-background px-3 py-1 text-sm",
|
|
173
|
+
className,
|
|
174
|
+
)}
|
|
175
|
+
{...props}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Illustration column — hidden below `lg`. */
|
|
181
|
+
function Media({ className, ...props }: React.ComponentProps<"div">) {
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
data-slot="comparison-media"
|
|
185
|
+
className={cn("hidden lg:block", className)}
|
|
186
|
+
{...props}
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const ComparisonSection = {
|
|
192
|
+
Root,
|
|
193
|
+
Content,
|
|
194
|
+
Panels,
|
|
195
|
+
Panel,
|
|
196
|
+
PanelTitle,
|
|
197
|
+
PanelList,
|
|
198
|
+
PanelItem,
|
|
199
|
+
TagList,
|
|
200
|
+
Tag,
|
|
201
|
+
Media,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export type {
|
|
205
|
+
ComparisonPanelItemProps,
|
|
206
|
+
ComparisonPanelProps,
|
|
207
|
+
ComparisonPanelVariant,
|
|
208
|
+
ComparisonSectionRootProps,
|
|
209
|
+
};
|