@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.
@@ -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";