@ugurdemirel/landcraft 0.1.0
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/README.md +132 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +589 -0
- package/dist/index.js +4521 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +5979 -0
- package/package.json +67 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/Blog.tsx +243 -0
- package/src/components/Button.tsx +94 -0
- package/src/components/CTA.tsx +155 -0
- package/src/components/Card.tsx +80 -0
- package/src/components/Container.tsx +86 -0
- package/src/components/FAQ.tsx +163 -0
- package/src/components/Features.tsx +153 -0
- package/src/components/Footer.tsx +145 -0
- package/src/components/Hero.tsx +169 -0
- package/src/components/LogoCloud.tsx +100 -0
- package/src/components/MegaMenu.tsx +468 -0
- package/src/components/Navbar.tsx +183 -0
- package/src/components/Newsletter.tsx +180 -0
- package/src/components/Pricing.tsx +336 -0
- package/src/components/Prose.tsx +55 -0
- package/src/components/Section.tsx +106 -0
- package/src/components/Stats.tsx +181 -0
- package/src/components/Testimonials.tsx +205 -0
- package/src/icons.tsx +290 -0
- package/src/index.ts +111 -0
- package/src/styles/index.css +87 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/contrast.ts +107 -0
- package/src/utils/useTokenForeground.ts +38 -0
- package/tailwind.preset.cjs +121 -0
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ugurdemirel/landcraft",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Marketing/landing-page focused React component library built on Tailwind CSS with token-based theming and automatic contrast.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.cjs",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
},
|
|
18
|
+
"./styles.css": "./dist/styles.css",
|
|
19
|
+
"./tailwind-preset": "./tailwind.preset.cjs"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"tailwind.preset.cjs",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": [
|
|
28
|
+
"*.css"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "pnpm run build:js && pnpm run build:css",
|
|
32
|
+
"build:js": "vite build",
|
|
33
|
+
"build:css": "tailwindcss -i ./src/styles/index.css -o ./dist/styles.css",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"clean": "rimraf dist",
|
|
36
|
+
"prepublishOnly": "pnpm run build"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"react": ">=18.0.0",
|
|
40
|
+
"react-dom": ">=18.0.0",
|
|
41
|
+
"tailwindcss": "^3.4.0",
|
|
42
|
+
"@tailwindcss/typography": "^0.5.15"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@tailwindcss/typography": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"clsx": "^2.1.1",
|
|
51
|
+
"tailwind-merge": "^2.5.5"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@tailwindcss/typography": "^0.5.15",
|
|
55
|
+
"@types/node": "^22.9.0",
|
|
56
|
+
"@types/react": "^18.3.12",
|
|
57
|
+
"@types/react-dom": "^18.3.1",
|
|
58
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
59
|
+
"autoprefixer": "^10.4.20",
|
|
60
|
+
"postcss": "^8.4.49",
|
|
61
|
+
"rimraf": "^6.0.1",
|
|
62
|
+
"tailwindcss": "^3.4.17",
|
|
63
|
+
"typescript": "^5.6.3",
|
|
64
|
+
"vite": "^5.4.11",
|
|
65
|
+
"vite-plugin-dts": "^4.3.0"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { getContrastText } from "../utils/contrast";
|
|
4
|
+
|
|
5
|
+
export type BadgeVariant = "solid" | "soft" | "outline" | "dot";
|
|
6
|
+
export type BadgeSize = "sm" | "md";
|
|
7
|
+
|
|
8
|
+
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
9
|
+
variant?: BadgeVariant;
|
|
10
|
+
size?: BadgeSize;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
/** Paints the chip with an arbitrary CSS color; text color is computed for contrast. */
|
|
13
|
+
customColor?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const base =
|
|
17
|
+
"inline-flex items-center rounded-full font-medium tracking-tight transition-colors duration-200";
|
|
18
|
+
|
|
19
|
+
const sizes: Record<BadgeSize, string> = {
|
|
20
|
+
sm: "px-2.5 py-0.5 text-xs gap-1",
|
|
21
|
+
md: "px-3 py-1 text-[13px] gap-1.5",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const variants: Record<BadgeVariant, string> = {
|
|
25
|
+
solid: "bg-primary text-on-primary",
|
|
26
|
+
soft: "bg-primary-soft text-primary",
|
|
27
|
+
outline: "text-muted-foreground ring-1 ring-inset ring-border",
|
|
28
|
+
dot: "text-foreground ring-1 ring-inset ring-border bg-surface",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
|
32
|
+
(
|
|
33
|
+
{ className, variant = "soft", size = "md", icon, customColor, style, children, ...props },
|
|
34
|
+
ref,
|
|
35
|
+
) => {
|
|
36
|
+
const customStyle = customColor
|
|
37
|
+
? { ...style, backgroundColor: customColor, color: getContrastText(customColor, "#111111", "#ffffff") }
|
|
38
|
+
: style;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<span
|
|
42
|
+
ref={ref}
|
|
43
|
+
style={customStyle}
|
|
44
|
+
className={cn(
|
|
45
|
+
base,
|
|
46
|
+
sizes[size],
|
|
47
|
+
customColor ? undefined : variants[variant],
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{variant === "dot" ? (
|
|
53
|
+
<span
|
|
54
|
+
aria-hidden
|
|
55
|
+
className={cn(
|
|
56
|
+
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
57
|
+
customColor ? "currentColor" : "bg-primary",
|
|
58
|
+
)}
|
|
59
|
+
style={customColor ? { backgroundColor: customColor } : undefined}
|
|
60
|
+
/>
|
|
61
|
+
) : null}
|
|
62
|
+
{icon ? <span className="shrink-0 opacity-70">{icon}</span> : null}
|
|
63
|
+
{children}
|
|
64
|
+
</span>
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
Badge.displayName = "Badge";
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { ArrowRight, ArrowUpRight } from "../icons";
|
|
4
|
+
import { Badge } from "./Badge";
|
|
5
|
+
import { Container } from "./Container";
|
|
6
|
+
|
|
7
|
+
export interface BlogAuthor {
|
|
8
|
+
name: string;
|
|
9
|
+
avatar?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BlogPost {
|
|
13
|
+
title: string;
|
|
14
|
+
excerpt?: string;
|
|
15
|
+
/** Display date, e.g. "12 Aug 2026". */
|
|
16
|
+
date: string;
|
|
17
|
+
readTime?: string;
|
|
18
|
+
category?: string;
|
|
19
|
+
cover?: string;
|
|
20
|
+
/** Arbitrary CSS color for the cover placeholder accent. */
|
|
21
|
+
accent?: string;
|
|
22
|
+
href?: string;
|
|
23
|
+
author?: BlogAuthor;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function AuthorMark({ name, avatar }: { name: string; avatar?: ReactNode }) {
|
|
27
|
+
if (avatar) return avatar;
|
|
28
|
+
const initials = name
|
|
29
|
+
.split(" ")
|
|
30
|
+
.map((p) => p[0])
|
|
31
|
+
.join("")
|
|
32
|
+
.slice(0, 2)
|
|
33
|
+
.toUpperCase();
|
|
34
|
+
return (
|
|
35
|
+
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-primary-soft text-[11px] font-bold text-primary">
|
|
36
|
+
{initials}
|
|
37
|
+
</span>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BlogCardProps extends HTMLAttributes<HTMLAnchorElement> {
|
|
42
|
+
post: BlogPost;
|
|
43
|
+
option?: "card" | "row";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const Cover = ({ post }: { post: BlogPost }) =>
|
|
47
|
+
post.cover ? (
|
|
48
|
+
<img src={post.cover} alt="" loading="lazy" className="aspect-[16/10] w-full object-cover" />
|
|
49
|
+
) : (
|
|
50
|
+
<div
|
|
51
|
+
className="grid aspect-[16/10] w-full place-items-center bg-surface-strong"
|
|
52
|
+
style={
|
|
53
|
+
post.accent
|
|
54
|
+
? { background: `linear-gradient(135deg, ${post.accent}22, ${post.accent}11)` }
|
|
55
|
+
: undefined
|
|
56
|
+
}
|
|
57
|
+
>
|
|
58
|
+
<span
|
|
59
|
+
className="font-display text-4xl font-bold tracking-[-0.04em]"
|
|
60
|
+
style={{ color: post.accent ?? "rgb(var(--color-foreground) / 0.12)" }}
|
|
61
|
+
>
|
|
62
|
+
{post.category?.[0]?.toUpperCase()}
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export const BlogCard = forwardRef<HTMLAnchorElement, BlogCardProps>(
|
|
68
|
+
({ className, post, option = "card", ...props }, ref) => {
|
|
69
|
+
if (option === "row") {
|
|
70
|
+
return (
|
|
71
|
+
<a
|
|
72
|
+
ref={ref}
|
|
73
|
+
href={post.href ?? "#"}
|
|
74
|
+
className={cn("group flex cursor-pointer items-baseline gap-5 py-6", className)}
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
<div className="min-w-0 flex-1">
|
|
78
|
+
<div className="flex items-center gap-3">
|
|
79
|
+
{post.category ? <Badge variant="soft" size="sm">{post.category}</Badge> : null}
|
|
80
|
+
<span className="text-xs tabular-nums text-muted-foreground">
|
|
81
|
+
{post.date}
|
|
82
|
+
{post.readTime ? ` · ${post.readTime}` : ""}
|
|
83
|
+
</span>
|
|
84
|
+
</div>
|
|
85
|
+
<h3 className="mt-2.5 font-display text-xl font-semibold tracking-tight text-foreground transition-transform duration-200 group-hover:translate-x-1 sm:text-2xl">
|
|
86
|
+
{post.title}
|
|
87
|
+
</h3>
|
|
88
|
+
</div>
|
|
89
|
+
<span className="text-foreground/30 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-foreground">
|
|
90
|
+
<ArrowUpRight className="h-5 w-5" />
|
|
91
|
+
</span>
|
|
92
|
+
</a>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const author = post.author;
|
|
97
|
+
return (
|
|
98
|
+
<a
|
|
99
|
+
ref={ref}
|
|
100
|
+
href={post.href ?? "#"}
|
|
101
|
+
className={cn(
|
|
102
|
+
"group flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border border-border bg-surface transition-colors duration-200 hover:border-foreground/20",
|
|
103
|
+
className,
|
|
104
|
+
)}
|
|
105
|
+
{...props}
|
|
106
|
+
>
|
|
107
|
+
<div className="overflow-hidden">
|
|
108
|
+
<Cover post={post} />
|
|
109
|
+
</div>
|
|
110
|
+
<div className="flex flex-1 flex-col p-6">
|
|
111
|
+
<div className="flex items-center justify-between gap-3">
|
|
112
|
+
{post.category ? <Badge variant="soft" size="sm">{post.category}</Badge> : <span />}
|
|
113
|
+
{post.readTime ? (
|
|
114
|
+
<span className="text-xs text-muted-foreground">{post.readTime}</span>
|
|
115
|
+
) : null}
|
|
116
|
+
</div>
|
|
117
|
+
<h3 className="mt-4 font-display text-lg font-semibold leading-snug tracking-tight text-foreground sm:text-xl">
|
|
118
|
+
{post.title}
|
|
119
|
+
</h3>
|
|
120
|
+
{post.excerpt ? (
|
|
121
|
+
<p className="mt-2.5 line-clamp-2 text-sm leading-6 text-muted-foreground">
|
|
122
|
+
{post.excerpt}
|
|
123
|
+
</p>
|
|
124
|
+
) : null}
|
|
125
|
+
<div className="mt-5 flex items-center justify-between gap-3 border-t border-border pt-4">
|
|
126
|
+
{author ? (
|
|
127
|
+
<span className="flex min-w-0 items-center gap-2.5">
|
|
128
|
+
<AuthorMark name={author.name} avatar={author.avatar} />
|
|
129
|
+
<span className="truncate text-xs font-medium text-foreground">{author.name}</span>
|
|
130
|
+
</span>
|
|
131
|
+
) : (
|
|
132
|
+
<span className="text-xs tabular-nums text-muted-foreground">{post.date}</span>
|
|
133
|
+
)}
|
|
134
|
+
<ArrowRight className="h-4 w-4 text-foreground/35 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-foreground" />
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</a>
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
BlogCard.displayName = "BlogCard";
|
|
142
|
+
|
|
143
|
+
export interface BlogSectionProps
|
|
144
|
+
extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
145
|
+
posts: BlogPost[];
|
|
146
|
+
/** Show only the first N posts. */
|
|
147
|
+
limit?: number;
|
|
148
|
+
columns?: 2 | 3;
|
|
149
|
+
option?: "card" | "row";
|
|
150
|
+
eyebrow?: ReactNode;
|
|
151
|
+
eyebrowStyle?: "caps" | "soft" | "plain";
|
|
152
|
+
title?: ReactNode;
|
|
153
|
+
description?: ReactNode;
|
|
154
|
+
showAllLabel?: string;
|
|
155
|
+
showAllHref?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const gridCols: Record<NonNullable<BlogSectionProps["columns"]>, string> = {
|
|
159
|
+
2: "sm:grid-cols-2",
|
|
160
|
+
3: "sm:grid-cols-2 lg:grid-cols-3",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const BlogSection = forwardRef<HTMLElement, BlogSectionProps>(
|
|
164
|
+
(
|
|
165
|
+
{
|
|
166
|
+
className,
|
|
167
|
+
posts,
|
|
168
|
+
limit,
|
|
169
|
+
columns = 3,
|
|
170
|
+
option = "card",
|
|
171
|
+
eyebrow,
|
|
172
|
+
eyebrowStyle = "caps",
|
|
173
|
+
title,
|
|
174
|
+
description,
|
|
175
|
+
showAllLabel,
|
|
176
|
+
showAllHref,
|
|
177
|
+
...props
|
|
178
|
+
},
|
|
179
|
+
ref,
|
|
180
|
+
) => {
|
|
181
|
+
const visible = limit ? posts.slice(0, limit) : posts;
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<section ref={ref} className={cn("w-full py-20 sm:py-24", className)} {...props}>
|
|
185
|
+
<Container>
|
|
186
|
+
{(eyebrow || title || description || showAllHref) && (
|
|
187
|
+
<div className="mb-12 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
|
188
|
+
<div className="max-w-2xl">
|
|
189
|
+
{eyebrow ? (
|
|
190
|
+
<p
|
|
191
|
+
className={cn(
|
|
192
|
+
"text-xs font-semibold uppercase tracking-[0.18em]",
|
|
193
|
+
eyebrowStyle === "caps" && "text-foreground/50",
|
|
194
|
+
eyebrowStyle === "soft" &&
|
|
195
|
+
"inline-flex items-center rounded-full bg-primary-soft px-3 py-1.5 text-primary",
|
|
196
|
+
eyebrowStyle === "plain" && "text-primary",
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
{eyebrow}
|
|
200
|
+
</p>
|
|
201
|
+
) : null}
|
|
202
|
+
{title ? (
|
|
203
|
+
<h2 className="mt-4 font-display text-balance text-3xl font-semibold leading-[1.08] tracking-tight text-foreground sm:text-4xl">
|
|
204
|
+
{title}
|
|
205
|
+
</h2>
|
|
206
|
+
) : null}
|
|
207
|
+
{description ? (
|
|
208
|
+
<p className="mt-4 text-pretty text-base leading-7 text-muted-foreground">
|
|
209
|
+
{description}
|
|
210
|
+
</p>
|
|
211
|
+
) : null}
|
|
212
|
+
</div>
|
|
213
|
+
{showAllHref ? (
|
|
214
|
+
<a
|
|
215
|
+
href={showAllHref}
|
|
216
|
+
className="group inline-flex shrink-0 cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground transition-colors duration-150 hover:text-primary"
|
|
217
|
+
>
|
|
218
|
+
{showAllLabel ?? "All posts"}
|
|
219
|
+
<ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
|
220
|
+
</a>
|
|
221
|
+
) : null}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{option === "row" ? (
|
|
226
|
+
<div className="divide-y divide-border border-t border-border">
|
|
227
|
+
{visible.map((post) => (
|
|
228
|
+
<BlogCard key={post.title} post={post} option="row" />
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
) : (
|
|
232
|
+
<div className={cn("grid grid-cols-1 gap-4", gridCols[columns])}>
|
|
233
|
+
{visible.map((post) => (
|
|
234
|
+
<BlogCard key={post.title} post={post} option="card" />
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</Container>
|
|
239
|
+
</section>
|
|
240
|
+
);
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
BlogSection.displayName = "BlogSection";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { forwardRef, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { getContrastText } from "../utils/contrast";
|
|
4
|
+
|
|
5
|
+
export type ButtonVariant = "primary" | "dark" | "outline" | "ghost" | "link";
|
|
6
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
7
|
+
|
|
8
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
variant?: ButtonVariant;
|
|
10
|
+
size?: ButtonSize;
|
|
11
|
+
iconLeft?: ReactNode;
|
|
12
|
+
iconRight?: ReactNode;
|
|
13
|
+
/** Paints the button with an arbitrary CSS color; text color is computed for contrast. */
|
|
14
|
+
customColor?: string;
|
|
15
|
+
fullWidth?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const variants: Record<ButtonVariant, string> = {
|
|
19
|
+
primary: "bg-primary text-on-primary hover:bg-primary-hover",
|
|
20
|
+
dark: "bg-secondary text-on-secondary hover:bg-secondary-hover",
|
|
21
|
+
outline:
|
|
22
|
+
"border border-border bg-transparent text-foreground hover:border-foreground/30 hover:bg-surface",
|
|
23
|
+
ghost: "text-foreground hover:bg-surface",
|
|
24
|
+
link: "px-0 h-auto text-primary hover:text-primary-hover",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const sizes: Record<ButtonSize, string> = {
|
|
28
|
+
sm: "h-9 px-4 text-[13px] gap-1.5",
|
|
29
|
+
md: "h-11 px-5 text-sm gap-2",
|
|
30
|
+
lg: "h-12 px-7 text-[15px] gap-2",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const base =
|
|
34
|
+
"group inline-flex select-none items-center justify-center rounded-lg font-medium tracking-tight transition-[background-color,border-color,color,box-shadow] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer";
|
|
35
|
+
|
|
36
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
37
|
+
(
|
|
38
|
+
{
|
|
39
|
+
className,
|
|
40
|
+
variant = "primary",
|
|
41
|
+
size = "md",
|
|
42
|
+
iconLeft,
|
|
43
|
+
iconRight,
|
|
44
|
+
customColor,
|
|
45
|
+
fullWidth,
|
|
46
|
+
style,
|
|
47
|
+
type = "button",
|
|
48
|
+
children,
|
|
49
|
+
...props
|
|
50
|
+
},
|
|
51
|
+
ref,
|
|
52
|
+
) => {
|
|
53
|
+
const customStyle: CSSProperties | undefined = customColor
|
|
54
|
+
? {
|
|
55
|
+
...style,
|
|
56
|
+
backgroundColor: customColor,
|
|
57
|
+
color: getContrastText(customColor),
|
|
58
|
+
}
|
|
59
|
+
: style;
|
|
60
|
+
|
|
61
|
+
const isLink = variant === "link";
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<button
|
|
65
|
+
ref={ref}
|
|
66
|
+
type={type}
|
|
67
|
+
style={customStyle}
|
|
68
|
+
className={cn(
|
|
69
|
+
base,
|
|
70
|
+
sizes[size],
|
|
71
|
+
customColor ? "hover:brightness-95" : variants[variant],
|
|
72
|
+
isLink && "underline-offset-4 hover:underline",
|
|
73
|
+
fullWidth && "w-full",
|
|
74
|
+
className,
|
|
75
|
+
)}
|
|
76
|
+
{...props}
|
|
77
|
+
>
|
|
78
|
+
{iconLeft ? <span className={cn("shrink-0", !isLink && "opacity-70")}>{iconLeft}</span> : null}
|
|
79
|
+
{children}
|
|
80
|
+
{iconRight ? (
|
|
81
|
+
<span
|
|
82
|
+
className={cn(
|
|
83
|
+
"shrink-0 transition-transform duration-200",
|
|
84
|
+
!isLink && "opacity-70 group-hover:translate-x-0.5",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{iconRight}
|
|
88
|
+
</span>
|
|
89
|
+
) : null}
|
|
90
|
+
</button>
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
Button.displayName = "Button";
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { useTokenForeground } from "../utils/useTokenForeground";
|
|
4
|
+
import { Container } from "./Container";
|
|
5
|
+
|
|
6
|
+
export interface CTAProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
description?: ReactNode;
|
|
9
|
+
action?: ReactNode;
|
|
10
|
+
secondaryAction?: ReactNode;
|
|
11
|
+
option?: "panel" | "surface" | "inverse";
|
|
12
|
+
align?: "left" | "center";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CTA = forwardRef<HTMLElement, CTAProps>(
|
|
16
|
+
(
|
|
17
|
+
{ className, title, description, action, secondaryAction, option = "panel", align = "left", id, ...props },
|
|
18
|
+
ref,
|
|
19
|
+
) => {
|
|
20
|
+
const isPanel = option === "panel";
|
|
21
|
+
const isInverse = option === "inverse";
|
|
22
|
+
// panel → brand gradient; inverse → ink. Readable text must be derived from
|
|
23
|
+
// the actual painting token so contrast survives any palette.
|
|
24
|
+
const fg = useTokenForeground(isInverse ? "--color-secondary" : "--color-primary");
|
|
25
|
+
|
|
26
|
+
/** surface — quiet paper band: hairline rule, big display type, actions right. */
|
|
27
|
+
if (option === "surface") {
|
|
28
|
+
return (
|
|
29
|
+
<section ref={ref} id={id} className={cn("w-full", className)} {...props}>
|
|
30
|
+
<Container className="py-20 sm:py-24">
|
|
31
|
+
<div
|
|
32
|
+
className={cn(
|
|
33
|
+
"flex flex-col gap-10 border-t border-border pt-14 sm:flex-row sm:items-end sm:justify-between sm:pt-16",
|
|
34
|
+
align === "center" && "items-center text-center sm:flex-col sm:items-center",
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
<div className={cn("max-w-3xl", align === "center" && "mx-auto")}>
|
|
38
|
+
<h2 className="font-display text-4xl font-semibold leading-[1.03] tracking-[-0.03em] text-balance text-foreground sm:text-6xl">
|
|
39
|
+
{title}
|
|
40
|
+
</h2>
|
|
41
|
+
{description ? (
|
|
42
|
+
<p className={cn("mt-5 max-w-xl text-pretty text-base leading-7 text-muted-foreground sm:text-lg", align === "center" && "mx-auto")}>
|
|
43
|
+
{description}
|
|
44
|
+
</p>
|
|
45
|
+
) : null}
|
|
46
|
+
</div>
|
|
47
|
+
<div
|
|
48
|
+
className={cn(
|
|
49
|
+
"flex flex-wrap items-center gap-4",
|
|
50
|
+
align === "center" && "justify-center",
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
{action}
|
|
54
|
+
{secondaryAction}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</Container>
|
|
58
|
+
</section>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<section ref={ref} id={id} className={cn("w-full py-6 pb-20", className)} {...props}>
|
|
64
|
+
<Container>
|
|
65
|
+
{isPanel ? (
|
|
66
|
+
<div
|
|
67
|
+
className="relative overflow-hidden rounded-[1.75rem] px-7 py-14 sm:px-14 sm:py-20"
|
|
68
|
+
style={{
|
|
69
|
+
background:
|
|
70
|
+
"radial-gradient(120% 160% at 0% 0%, rgb(var(--color-primary)), rgb(var(--color-primary-hover)) 55%, rgb(var(--color-primary) / 0.85))",
|
|
71
|
+
color: fg,
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<div aria-hidden className="pointer-events-none absolute inset-0 grain opacity-[0.07] mix-blend-overlay" />
|
|
75
|
+
<div
|
|
76
|
+
aria-hidden
|
|
77
|
+
className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full"
|
|
78
|
+
style={{ background: "rgb(var(--color-accent))", opacity: 0.35, filter: "blur(60px)" }}
|
|
79
|
+
/>
|
|
80
|
+
<div
|
|
81
|
+
className={cn(
|
|
82
|
+
"relative flex flex-col gap-9",
|
|
83
|
+
align === "center" ? "items-center text-center" : "items-start",
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<div className={cn("max-w-2xl", align === "center" && "mx-auto")}>
|
|
87
|
+
<h2 className="font-display text-balance text-3xl font-semibold leading-[1.06] tracking-[-0.02em] sm:text-5xl">
|
|
88
|
+
{title}
|
|
89
|
+
</h2>
|
|
90
|
+
{description ? (
|
|
91
|
+
<p
|
|
92
|
+
className={cn(
|
|
93
|
+
"mt-4 text-pretty text-base leading-7 sm:text-lg",
|
|
94
|
+
align === "center" && "mx-auto max-w-xl",
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{description}
|
|
98
|
+
</p>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
"flex flex-wrap items-center gap-4",
|
|
104
|
+
align === "center" && "justify-center",
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{action}
|
|
108
|
+
{secondaryAction}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
) : (
|
|
113
|
+
<div
|
|
114
|
+
className="relative overflow-hidden rounded-[1.75rem] px-7 py-16 sm:px-16 sm:py-20"
|
|
115
|
+
style={{
|
|
116
|
+
backgroundColor: "rgb(var(--color-secondary))",
|
|
117
|
+
color: fg,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<div aria-hidden className="pointer-events-none absolute inset-0 grain opacity-[0.09] mix-blend-overlay" />
|
|
121
|
+
<div
|
|
122
|
+
className={cn(
|
|
123
|
+
"relative flex flex-col items-start gap-10",
|
|
124
|
+
align === "center" && "items-center text-center",
|
|
125
|
+
"lg:flex-row lg:items-end lg:justify-between",
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
<div className={cn("max-w-3xl", align === "center" && "mx-auto")}>
|
|
129
|
+
<h2 className="font-display text-balance text-4xl font-semibold leading-[1.02] tracking-[-0.03em] sm:text-6xl">
|
|
130
|
+
{title}
|
|
131
|
+
</h2>
|
|
132
|
+
{description ? (
|
|
133
|
+
<p className={cn("mt-5 max-w-xl text-pretty text-base leading-7 sm:text-lg", align === "center" && "mx-auto")}>
|
|
134
|
+
{description}
|
|
135
|
+
</p>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
<div
|
|
139
|
+
className={cn(
|
|
140
|
+
"flex flex-wrap items-center gap-4",
|
|
141
|
+
align === "center" && "justify-center",
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
{action}
|
|
145
|
+
{secondaryAction}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</Container>
|
|
151
|
+
</section>
|
|
152
|
+
);
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
CTA.displayName = "CTA";
|