@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useState, type FormEvent, type HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { ArrowRight, Check, Mail } from "../icons";
|
|
4
|
+
|
|
5
|
+
export interface NewsletterProps extends Omit<HTMLAttributes<HTMLFormElement>, "onSubmit"> {
|
|
6
|
+
option?: "inline" | "card" | "underline";
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
buttonLabel?: string;
|
|
9
|
+
successMessage?: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
onSubmit?: (email: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const inputBase =
|
|
15
|
+
"w-full rounded-lg border border-border bg-surface px-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background";
|
|
16
|
+
|
|
17
|
+
export const Newsletter = ({
|
|
18
|
+
className,
|
|
19
|
+
option = "inline",
|
|
20
|
+
placeholder = "you@company.com",
|
|
21
|
+
buttonLabel = "Join",
|
|
22
|
+
successMessage = "You’re on the list!",
|
|
23
|
+
note,
|
|
24
|
+
onSubmit,
|
|
25
|
+
...props
|
|
26
|
+
}: NewsletterProps) => {
|
|
27
|
+
const [email, setEmail] = useState("");
|
|
28
|
+
const [done, setDone] = useState(false);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
const handleSubmit = (event: FormEvent) => {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
const value = email.trim();
|
|
34
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
35
|
+
setError("Please enter a valid email.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setError(null);
|
|
39
|
+
setDone(true);
|
|
40
|
+
onSubmit?.(value);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const success = (
|
|
44
|
+
<p className="flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3.5 text-sm font-medium text-accent" role="status">
|
|
45
|
+
<Check className="h-4 w-4" />
|
|
46
|
+
{successMessage}
|
|
47
|
+
</p>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (option === "card") {
|
|
51
|
+
return (
|
|
52
|
+
<form onSubmit={handleSubmit} className={cn("w-full", className)} {...props}>
|
|
53
|
+
{done ? (
|
|
54
|
+
success
|
|
55
|
+
) : (
|
|
56
|
+
<div className="rounded-xl border border-border bg-surface p-7">
|
|
57
|
+
<div className="mb-5 flex items-center gap-3">
|
|
58
|
+
<span className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-primary-soft text-primary">
|
|
59
|
+
<Mail className="h-4 w-4" />
|
|
60
|
+
</span>
|
|
61
|
+
<p className="font-display text-lg font-semibold tracking-tight text-foreground">
|
|
62
|
+
Monthly digest
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
<label htmlFor="newsletter-email" className="sr-only">
|
|
66
|
+
Your email address
|
|
67
|
+
</label>
|
|
68
|
+
<div className="flex flex-col gap-3 sm:flex-row">
|
|
69
|
+
<input
|
|
70
|
+
id="newsletter-email"
|
|
71
|
+
type="email"
|
|
72
|
+
autoComplete="email"
|
|
73
|
+
value={email}
|
|
74
|
+
onChange={(e) => {
|
|
75
|
+
setEmail(e.target.value);
|
|
76
|
+
if (error) setError(null);
|
|
77
|
+
}}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
className={cn(inputBase, "h-11")}
|
|
80
|
+
/>
|
|
81
|
+
<button
|
|
82
|
+
type="submit"
|
|
83
|
+
className="inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-semibold text-on-primary transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
84
|
+
>
|
|
85
|
+
{buttonLabel}
|
|
86
|
+
<ArrowRight className="h-4 w-4 opacity-60" />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
{error ? (
|
|
90
|
+
<p className="mt-2 text-sm font-medium text-red-500" role="alert">{error}</p>
|
|
91
|
+
) : note ? (
|
|
92
|
+
<p className="mt-3 text-xs leading-5 text-muted-foreground">{note}</p>
|
|
93
|
+
) : null}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</form>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (option === "underline") {
|
|
101
|
+
return (
|
|
102
|
+
<form onSubmit={handleSubmit} className={cn("w-full", className)} {...props}>
|
|
103
|
+
{done ? (
|
|
104
|
+
success
|
|
105
|
+
) : (
|
|
106
|
+
<div className="flex items-end gap-3 border-b border-foreground/20 pb-3 transition-colors duration-200 focus-within:border-foreground">
|
|
107
|
+
<label htmlFor="newsletter-email" className="sr-only">
|
|
108
|
+
Your email address
|
|
109
|
+
</label>
|
|
110
|
+
<input
|
|
111
|
+
id="newsletter-email"
|
|
112
|
+
type="email"
|
|
113
|
+
autoComplete="email"
|
|
114
|
+
value={email}
|
|
115
|
+
onChange={(e) => {
|
|
116
|
+
setEmail(e.target.value);
|
|
117
|
+
if (error) setError(null);
|
|
118
|
+
}}
|
|
119
|
+
placeholder={placeholder}
|
|
120
|
+
className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
|
121
|
+
/>
|
|
122
|
+
<button
|
|
123
|
+
type="submit"
|
|
124
|
+
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground transition-colors duration-150 hover:text-primary"
|
|
125
|
+
>
|
|
126
|
+
{buttonLabel}
|
|
127
|
+
<ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{error ? (
|
|
132
|
+
<p className="mt-2 text-sm font-medium text-red-500" role="alert">{error}</p>
|
|
133
|
+
) : note ? (
|
|
134
|
+
<p className="mt-2 text-xs leading-5 text-muted-foreground">{note}</p>
|
|
135
|
+
) : null}
|
|
136
|
+
</form>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<form onSubmit={handleSubmit} className={cn("w-full", className)} {...props}>
|
|
142
|
+
{done ? (
|
|
143
|
+
success
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
<label htmlFor="newsletter-email" className="sr-only">
|
|
147
|
+
Your email address
|
|
148
|
+
</label>
|
|
149
|
+
<div className="flex flex-col gap-3 sm:flex-row">
|
|
150
|
+
<input
|
|
151
|
+
id="newsletter-email"
|
|
152
|
+
type="email"
|
|
153
|
+
autoComplete="email"
|
|
154
|
+
value={email}
|
|
155
|
+
onChange={(e) => {
|
|
156
|
+
setEmail(e.target.value);
|
|
157
|
+
if (error) setError(null);
|
|
158
|
+
}}
|
|
159
|
+
placeholder={placeholder}
|
|
160
|
+
className={cn(inputBase, "h-11")}
|
|
161
|
+
/>
|
|
162
|
+
<button
|
|
163
|
+
type="submit"
|
|
164
|
+
className="inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-semibold text-on-primary transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
165
|
+
>
|
|
166
|
+
{buttonLabel}
|
|
167
|
+
<ArrowRight className="h-4 w-4 opacity-60" />
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
{error ? (
|
|
171
|
+
<p className="mt-2 text-sm font-medium text-red-500" role="alert">{error}</p>
|
|
172
|
+
) : note ? (
|
|
173
|
+
<p className="mt-2 text-xs leading-5 text-muted-foreground">{note}</p>
|
|
174
|
+
) : null}
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
</form>
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
Newsletter.displayName = "Newsletter";
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { useState, type HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { getContrastText } from "../utils/contrast";
|
|
4
|
+
import { useTokenForeground } from "../utils/useTokenForeground";
|
|
5
|
+
import { ArrowRight, Check } from "../icons";
|
|
6
|
+
|
|
7
|
+
export interface Plan {
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
monthly: number | null;
|
|
11
|
+
yearly: number | null;
|
|
12
|
+
features: string[];
|
|
13
|
+
cta?: string;
|
|
14
|
+
href?: string;
|
|
15
|
+
highlighted?: boolean;
|
|
16
|
+
customColor?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PricingProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
20
|
+
plans: Plan[];
|
|
21
|
+
option?: "cards" | "bento" | "compact";
|
|
22
|
+
defaultBilling?: "monthly" | "yearly";
|
|
23
|
+
onSelect?: (plan: Plan, billing: "monthly" | "yearly") => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function PriceValue({
|
|
27
|
+
value,
|
|
28
|
+
billing,
|
|
29
|
+
dim,
|
|
30
|
+
}: {
|
|
31
|
+
value: number | null;
|
|
32
|
+
billing: "monthly" | "yearly";
|
|
33
|
+
/** False when rendered on a colored background where opacity would kill contrast. */
|
|
34
|
+
dim?: boolean;
|
|
35
|
+
}) {
|
|
36
|
+
if (value === null) {
|
|
37
|
+
return <span>Custom</span>;
|
|
38
|
+
}
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<span className="text-[2.6rem] font-semibold tracking-tight sm:text-[3rem]">${value}</span>
|
|
42
|
+
<span className={cn("text-sm font-normal", dim && "opacity-60")}>
|
|
43
|
+
/{billing === "monthly" ? "mo" : "yr"}
|
|
44
|
+
</span>
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function BillingToggle({
|
|
50
|
+
billing,
|
|
51
|
+
onChange,
|
|
52
|
+
dark,
|
|
53
|
+
}: {
|
|
54
|
+
billing: "monthly" | "yearly";
|
|
55
|
+
onChange: (b: "monthly" | "yearly") => void;
|
|
56
|
+
dark?: boolean;
|
|
57
|
+
}) {
|
|
58
|
+
// The accent token is a background color, but its shipped "on-accent"
|
|
59
|
+
// pairing isn't guaranteed to be contrast-safe (e.g. white on amber).
|
|
60
|
+
// Derive the badge text color from the actual accent value instead.
|
|
61
|
+
const accentText = useTokenForeground("--color-accent");
|
|
62
|
+
return (
|
|
63
|
+
<div className="mb-10 flex items-center justify-center gap-4">
|
|
64
|
+
<span
|
|
65
|
+
className={cn(
|
|
66
|
+
"text-sm font-medium transition-colors",
|
|
67
|
+
billing === "monthly" ? (dark ? "text-white" : "text-foreground") : "text-muted-foreground",
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
Monthly
|
|
71
|
+
</span>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
role="switch"
|
|
75
|
+
aria-checked={billing === "yearly"}
|
|
76
|
+
aria-label="Billing period"
|
|
77
|
+
onClick={() => onChange(billing === "monthly" ? "yearly" : "monthly")}
|
|
78
|
+
className={cn(
|
|
79
|
+
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200",
|
|
80
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
81
|
+
billing === "yearly" ? "bg-primary" : "bg-border",
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<span
|
|
85
|
+
className={cn(
|
|
86
|
+
"inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform duration-200",
|
|
87
|
+
billing === "yearly" ? "translate-x-6" : "translate-x-1",
|
|
88
|
+
)}
|
|
89
|
+
/>
|
|
90
|
+
</button>
|
|
91
|
+
<span
|
|
92
|
+
className={cn(
|
|
93
|
+
"flex items-center gap-2 text-sm font-medium transition-colors",
|
|
94
|
+
billing === "yearly" ? (dark ? "text-white" : "text-foreground") : "text-muted-foreground",
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
Yearly
|
|
98
|
+
<span
|
|
99
|
+
className="rounded-full bg-accent px-2 py-0.5 text-xs font-semibold"
|
|
100
|
+
style={{ color: accentText }}
|
|
101
|
+
>
|
|
102
|
+
−2 ay
|
|
103
|
+
</span>
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const Pricing = ({
|
|
110
|
+
className,
|
|
111
|
+
plans,
|
|
112
|
+
option = "cards",
|
|
113
|
+
defaultBilling = "monthly",
|
|
114
|
+
onSelect,
|
|
115
|
+
...props
|
|
116
|
+
}: PricingProps) => {
|
|
117
|
+
const [billing, setBilling] = useState<"monthly" | "yearly">(defaultBilling);
|
|
118
|
+
|
|
119
|
+
const price = (plan: Plan) => (billing === "monthly" ? plan.monthly : plan.yearly);
|
|
120
|
+
const priceLabel = (v: number | null) => (v === null ? "Custom" : `$${v}`);
|
|
121
|
+
|
|
122
|
+
const planButton = (plan: Plan, dark?: boolean) => {
|
|
123
|
+
const isHighlighted = plan.highlighted && !plan.customColor;
|
|
124
|
+
const fg = plan.customColor ? getContrastText(plan.customColor, "#111111", "#ffffff") : undefined;
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => onSelect?.(plan, billing)}
|
|
129
|
+
className={cn(
|
|
130
|
+
"inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-lg px-5 text-sm font-semibold transition-colors duration-200",
|
|
131
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
132
|
+
isHighlighted || plan.customColor
|
|
133
|
+
? "hover:brightness-95"
|
|
134
|
+
: dark
|
|
135
|
+
? "bg-white text-black hover:bg-white/90"
|
|
136
|
+
: "border border-border text-foreground hover:bg-surface",
|
|
137
|
+
)}
|
|
138
|
+
style={
|
|
139
|
+
plan.customColor
|
|
140
|
+
? { backgroundColor: plan.customColor, color: fg }
|
|
141
|
+
: isHighlighted
|
|
142
|
+
? {
|
|
143
|
+
backgroundColor: "rgb(var(--color-primary))",
|
|
144
|
+
color: "rgb(var(--color-on-primary))",
|
|
145
|
+
}
|
|
146
|
+
: undefined
|
|
147
|
+
}
|
|
148
|
+
>
|
|
149
|
+
{plan.cta || "Start"}
|
|
150
|
+
<ArrowRight className="h-4 w-4 opacity-60" />
|
|
151
|
+
</button>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (option === "compact") {
|
|
156
|
+
return (
|
|
157
|
+
<div className={cn("w-full", className)} {...props}>
|
|
158
|
+
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
|
|
159
|
+
{plans.map((plan) => {
|
|
160
|
+
const isHighlighted = plan.highlighted && !plan.customColor;
|
|
161
|
+
const bg = plan.customColor ? "rgb(var(--color-surface))" : isHighlighted ? "rgb(var(--color-primary-soft))" : undefined;
|
|
162
|
+
const fg = plan.customColor ? plan.customColor : isHighlighted ? "rgb(var(--color-primary))" : undefined;
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
key={plan.name}
|
|
166
|
+
className={cn(
|
|
167
|
+
"group flex flex-col gap-5 p-5 transition-colors duration-200 hover:bg-surface-strong sm:flex-row sm:items-center sm:justify-between",
|
|
168
|
+
isHighlighted && "bg-primary-soft/60",
|
|
169
|
+
)}
|
|
170
|
+
style={bg ? { backgroundColor: bg } : undefined}
|
|
171
|
+
>
|
|
172
|
+
<div className="min-w-0">
|
|
173
|
+
<div className="flex items-center gap-2.5">
|
|
174
|
+
<h3 className="font-display text-lg font-semibold tracking-tight text-foreground">
|
|
175
|
+
{plan.name}
|
|
176
|
+
</h3>
|
|
177
|
+
{isHighlighted ? (
|
|
178
|
+
<span className="rounded-full bg-primary px-2.5 py-0.5 text-[11px] font-semibold text-on-primary uppercase tracking-wider">
|
|
179
|
+
Popular
|
|
180
|
+
</span>
|
|
181
|
+
) : null}
|
|
182
|
+
</div>
|
|
183
|
+
{plan.description ? (
|
|
184
|
+
<p className="mt-1 truncate text-sm text-muted-foreground">{plan.description}</p>
|
|
185
|
+
) : null}
|
|
186
|
+
</div>
|
|
187
|
+
<div className="flex items-center gap-6">
|
|
188
|
+
<ul className="hidden items-center gap-4 lg:flex">
|
|
189
|
+
{plan.features.slice(0, 3).map((f) => (
|
|
190
|
+
<li key={f} className="flex items-center gap-1.5 text-sm text-muted-foreground" style={{ color: fg }}>
|
|
191
|
+
<Check className="h-3.5 w-3.5 text-current" />
|
|
192
|
+
{f}
|
|
193
|
+
</li>
|
|
194
|
+
))}
|
|
195
|
+
</ul>
|
|
196
|
+
<div className="font-display text-2xl font-semibold tracking-tight text-foreground">
|
|
197
|
+
{priceLabel(price(plan))}
|
|
198
|
+
{price(plan) !== null ? (
|
|
199
|
+
<span className="text-sm font-normal text-muted-foreground">/{billing === "monthly" ? "mo" : "yr"}</span>
|
|
200
|
+
) : null}
|
|
201
|
+
</div>
|
|
202
|
+
{planButton(plan)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (option === "bento") {
|
|
213
|
+
return (
|
|
214
|
+
<div className={cn("w-full", className)} {...props}>
|
|
215
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
216
|
+
{plans.map((plan) => {
|
|
217
|
+
const isHighlighted = plan.highlighted && !plan.customColor;
|
|
218
|
+
const bg = plan.customColor || (isHighlighted ? "rgb(var(--color-primary))" : undefined);
|
|
219
|
+
const fg = plan.customColor
|
|
220
|
+
? getContrastText(plan.customColor, "#111111", "#ffffff")
|
|
221
|
+
: isHighlighted
|
|
222
|
+
? "rgb(var(--color-on-primary))"
|
|
223
|
+
: undefined;
|
|
224
|
+
return (
|
|
225
|
+
<div
|
|
226
|
+
key={plan.name}
|
|
227
|
+
className={cn(
|
|
228
|
+
"flex h-full flex-col rounded-xl p-8",
|
|
229
|
+
bg
|
|
230
|
+
? "lg:col-span-2"
|
|
231
|
+
: "border border-border bg-surface",
|
|
232
|
+
)}
|
|
233
|
+
style={bg ? { backgroundColor: bg, color: fg } : undefined}
|
|
234
|
+
>
|
|
235
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
236
|
+
<div>
|
|
237
|
+
<h3 className={cn("font-display text-xl font-semibold tracking-tight", bg ? "text-current" : "text-foreground")}>
|
|
238
|
+
{plan.name}
|
|
239
|
+
</h3>
|
|
240
|
+
{plan.description ? (
|
|
241
|
+
<p className={cn("mt-1.5 text-sm", bg ? undefined : "text-muted-foreground")}>
|
|
242
|
+
{plan.description}
|
|
243
|
+
</p>
|
|
244
|
+
) : null}
|
|
245
|
+
</div>
|
|
246
|
+
<div className={cn("font-display", bg ? "text-current" : "text-foreground")}>
|
|
247
|
+
<PriceValue value={price(plan)} billing={billing} dim={!bg} />
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<ul
|
|
251
|
+
className={cn(
|
|
252
|
+
"mt-7 grid grid-cols-1 gap-x-8 gap-y-2.5 text-sm sm:grid-cols-2",
|
|
253
|
+
bg ? "lg:max-w-2xl" : undefined,
|
|
254
|
+
)}
|
|
255
|
+
>
|
|
256
|
+
{plan.features.map((feature) => (
|
|
257
|
+
<li key={feature} className={cn("flex items-start gap-2.5", bg ? "text-current" : "text-muted-foreground")}>
|
|
258
|
+
<Check className={cn("mt-0.5 h-4 w-4 shrink-0", bg ? "text-current" : "text-primary")} />
|
|
259
|
+
{feature}
|
|
260
|
+
</li>
|
|
261
|
+
))}
|
|
262
|
+
</ul>
|
|
263
|
+
<div className="mt-8">{planButton(plan, !bg)}</div>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
})}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<div className={cn("w-full", className)} {...props}>
|
|
274
|
+
<BillingToggle billing={billing} onChange={setBilling} />
|
|
275
|
+
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
|
276
|
+
{plans.map((plan) => {
|
|
277
|
+
const isHighlighted = plan.highlighted && !plan.customColor;
|
|
278
|
+
const bg = plan.customColor || (isHighlighted ? "rgb(var(--color-primary))" : undefined);
|
|
279
|
+
const fg = plan.customColor
|
|
280
|
+
? getContrastText(plan.customColor, "#111111", "#ffffff")
|
|
281
|
+
: isHighlighted
|
|
282
|
+
? "rgb(var(--color-on-primary))"
|
|
283
|
+
: undefined;
|
|
284
|
+
return (
|
|
285
|
+
<div
|
|
286
|
+
key={plan.name}
|
|
287
|
+
className={cn(
|
|
288
|
+
"relative flex flex-col rounded-xl p-7",
|
|
289
|
+
bg ? "shadow-raised" : "border border-border bg-surface",
|
|
290
|
+
!bg && isHighlighted && "border-primary",
|
|
291
|
+
)}
|
|
292
|
+
style={bg ? { backgroundColor: bg, color: fg } : undefined}
|
|
293
|
+
>
|
|
294
|
+
{isHighlighted ? (
|
|
295
|
+
<span
|
|
296
|
+
className={cn(
|
|
297
|
+
"absolute -top-3 left-7 rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider",
|
|
298
|
+
!bg && "bg-primary text-on-primary",
|
|
299
|
+
)}
|
|
300
|
+
style={
|
|
301
|
+
bg
|
|
302
|
+
? { backgroundColor: "rgb(var(--color-on-primary))", color: "rgb(var(--color-primary))" }
|
|
303
|
+
: undefined
|
|
304
|
+
}
|
|
305
|
+
>
|
|
306
|
+
Most popular
|
|
307
|
+
</span>
|
|
308
|
+
) : null}
|
|
309
|
+
<h3 className={cn("font-display text-lg font-semibold tracking-tight", bg ? "text-current" : "text-foreground")}>
|
|
310
|
+
{plan.name}
|
|
311
|
+
</h3>
|
|
312
|
+
{plan.description ? (
|
|
313
|
+
<p className={cn("mt-1.5 text-sm", bg ? undefined : "text-muted-foreground")}>
|
|
314
|
+
{plan.description}
|
|
315
|
+
</p>
|
|
316
|
+
) : null}
|
|
317
|
+
<div className={cn("mt-6 flex items-baseline font-display", bg ? "text-current" : "text-foreground")}>
|
|
318
|
+
<PriceValue value={price(plan)} billing={billing} dim={!bg} />
|
|
319
|
+
</div>
|
|
320
|
+
<ul className={cn("mt-7 flex-1 space-y-2.5 text-sm", bg ? "text-current" : "text-muted-foreground")}>
|
|
321
|
+
{plan.features.map((feature) => (
|
|
322
|
+
<li key={feature} className="flex items-start gap-2.5">
|
|
323
|
+
<Check className={cn("mt-0.5 h-4 w-4 shrink-0", bg ? "text-current" : "text-primary")} />
|
|
324
|
+
{feature}
|
|
325
|
+
</li>
|
|
326
|
+
))}
|
|
327
|
+
</ul>
|
|
328
|
+
<div className="mt-8">{planButton(plan, !bg)}</div>
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
Pricing.displayName = "Pricing";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
|
|
4
|
+
export type ProseSize = "sm" | "md" | "lg" | "xl" | "2xl";
|
|
5
|
+
|
|
6
|
+
export interface ProseProps extends HTMLAttributes<HTMLElement> {
|
|
7
|
+
/** Content scale. `lg` (prose-lg) is the comfortable blog default. */
|
|
8
|
+
size?: ProseSize;
|
|
9
|
+
/** Renders raw HTML (CMS / MDX output). Mutually exclusive with children. */
|
|
10
|
+
html?: string;
|
|
11
|
+
/** When true, removes the reading-width cap. */
|
|
12
|
+
wide?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sizes: Record<ProseSize, string> = {
|
|
16
|
+
sm: "prose-sm",
|
|
17
|
+
md: "prose",
|
|
18
|
+
lg: "prose-lg",
|
|
19
|
+
xl: "prose-xl",
|
|
20
|
+
"2xl": "prose-2xl",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Blog / article / documentation body text.
|
|
25
|
+
*
|
|
26
|
+
* Wraps Tailwind Typography (`@tailwindcss/typography`) and re-skins its
|
|
27
|
+
* colors, type scale and code blocks through the same design tokens as every
|
|
28
|
+
* other component, so dark palettes and font swaps work out of the box.
|
|
29
|
+
*/
|
|
30
|
+
export const Prose = forwardRef<HTMLElement, ProseProps>(
|
|
31
|
+
({ className, size = "lg", html, wide = false, children, ...props }, ref) => (
|
|
32
|
+
<article
|
|
33
|
+
ref={ref}
|
|
34
|
+
className={cn(
|
|
35
|
+
"prose prose-neutral",
|
|
36
|
+
sizes[size],
|
|
37
|
+
!wide && "max-w-3xl",
|
|
38
|
+
className,
|
|
39
|
+
)}
|
|
40
|
+
{...(html ? { dangerouslySetInnerHTML: { __html: html } } : null)}
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
{html ? null : children}
|
|
44
|
+
</article>
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
Prose.displayName = "Prose";
|
|
48
|
+
|
|
49
|
+
/** Intro / stand-first paragraph styled with the `prose` lead class. */
|
|
50
|
+
export const ProseLead = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
|
51
|
+
({ className, ...props }, ref) => (
|
|
52
|
+
<p ref={ref} className={cn("lead", className)} {...props} />
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
ProseLead.displayName = "ProseLead";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { Container } from "./Container";
|
|
4
|
+
|
|
5
|
+
export type EyebrowStyle = "caps" | "soft" | "plain";
|
|
6
|
+
|
|
7
|
+
export interface SectionHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
8
|
+
eyebrow?: ReactNode;
|
|
9
|
+
eyebrowStyle?: EyebrowStyle;
|
|
10
|
+
title?: ReactNode;
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
align?: "left" | "center";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const eyebrowBase = "inline-flex items-center gap-2 text-xs font-semibold tracking-[0.18em] uppercase";
|
|
16
|
+
|
|
17
|
+
const eyebrowStyles: Record<EyebrowStyle, string> = {
|
|
18
|
+
caps: cn(eyebrowBase, "text-foreground/50"),
|
|
19
|
+
soft: cn(
|
|
20
|
+
eyebrowBase,
|
|
21
|
+
"rounded-full px-3 py-1 text-primary tracking-widest bg-primary-soft uppercase",
|
|
22
|
+
),
|
|
23
|
+
plain: cn(eyebrowBase, "text-primary"),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const SectionHeader = forwardRef<HTMLDivElement, SectionHeaderProps>(
|
|
27
|
+
(
|
|
28
|
+
{ className, eyebrow, eyebrowStyle = "caps", title, description, align = "center", children, ...props },
|
|
29
|
+
ref,
|
|
30
|
+
) => (
|
|
31
|
+
<div
|
|
32
|
+
ref={ref}
|
|
33
|
+
className={cn(
|
|
34
|
+
"mb-14 max-w-2xl",
|
|
35
|
+
align === "center" && "mx-auto text-center",
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
{eyebrow ? <p className={eyebrowStyles[eyebrowStyle]}>{eyebrow}</p> : null}
|
|
41
|
+
{title ? (
|
|
42
|
+
<h2 className="mt-5 font-display text-balance text-[1.9rem] font-semibold leading-[1.08] tracking-tight text-foreground sm:text-4xl">
|
|
43
|
+
{title}
|
|
44
|
+
</h2>
|
|
45
|
+
) : null}
|
|
46
|
+
{description ? (
|
|
47
|
+
<p className="mt-4 text-pretty text-base leading-7 text-muted-foreground sm:text-lg">
|
|
48
|
+
{description}
|
|
49
|
+
</p>
|
|
50
|
+
) : null}
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
SectionHeader.displayName = "SectionHeader";
|
|
56
|
+
|
|
57
|
+
export interface SectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
58
|
+
size?: "sm" | "md" | "lg";
|
|
59
|
+
eyebrow?: ReactNode;
|
|
60
|
+
eyebrowStyle?: EyebrowStyle;
|
|
61
|
+
title?: ReactNode;
|
|
62
|
+
description?: ReactNode;
|
|
63
|
+
align?: "left" | "center";
|
|
64
|
+
containerClassName?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const paddings: Record<NonNullable<SectionProps["size"]>, string> = {
|
|
68
|
+
sm: "py-14",
|
|
69
|
+
md: "py-20 sm:py-24",
|
|
70
|
+
lg: "py-24 sm:py-32",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const Section = forwardRef<HTMLElement, SectionProps>(
|
|
74
|
+
(
|
|
75
|
+
{
|
|
76
|
+
className,
|
|
77
|
+
containerClassName,
|
|
78
|
+
size = "md",
|
|
79
|
+
eyebrow,
|
|
80
|
+
eyebrowStyle,
|
|
81
|
+
title,
|
|
82
|
+
description,
|
|
83
|
+
align = "center",
|
|
84
|
+
children,
|
|
85
|
+
id,
|
|
86
|
+
...props
|
|
87
|
+
},
|
|
88
|
+
ref,
|
|
89
|
+
) => (
|
|
90
|
+
<section ref={ref} id={id} className={cn("w-full", paddings[size], className)} {...props}>
|
|
91
|
+
<Container className={containerClassName}>
|
|
92
|
+
{eyebrow || title || description ? (
|
|
93
|
+
<SectionHeader
|
|
94
|
+
eyebrow={eyebrow}
|
|
95
|
+
eyebrowStyle={eyebrowStyle}
|
|
96
|
+
title={title}
|
|
97
|
+
description={description}
|
|
98
|
+
align={align}
|
|
99
|
+
/>
|
|
100
|
+
) : null}
|
|
101
|
+
{children}
|
|
102
|
+
</Container>
|
|
103
|
+
</section>
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
Section.displayName = "Section";
|