@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,80 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
|
|
4
|
+
export type CardVariant = "outlined" | "elevated" | "inset";
|
|
5
|
+
|
|
6
|
+
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
variant?: CardVariant;
|
|
8
|
+
/** Softens the whole card with hover lift + border tint. */
|
|
9
|
+
interactive?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const variants: Record<CardVariant, string> = {
|
|
13
|
+
outlined: "border border-border bg-surface",
|
|
14
|
+
elevated: "border border-border bg-surface shadow-soft",
|
|
15
|
+
inset: "bg-surface-strong",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
|
19
|
+
({ className, variant = "outlined", interactive = false, ...props }, ref) => (
|
|
20
|
+
<div
|
|
21
|
+
ref={ref}
|
|
22
|
+
className={cn(
|
|
23
|
+
"rounded-xl transition-[border-color,box-shadow,transform] duration-200",
|
|
24
|
+
variants[variant],
|
|
25
|
+
interactive &&
|
|
26
|
+
"cursor-pointer hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-raised",
|
|
27
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
Card.displayName = "Card";
|
|
35
|
+
|
|
36
|
+
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
37
|
+
icon?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
|
41
|
+
({ className, icon, children, ...props }, ref) => (
|
|
42
|
+
<div ref={ref} className={cn("flex flex-col gap-2 p-6 pb-0", className)} {...props}>
|
|
43
|
+
{icon ? <div className="mb-1.5">{icon}</div> : null}
|
|
44
|
+
{children}
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
CardHeader.displayName = "CardHeader";
|
|
49
|
+
|
|
50
|
+
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
|
51
|
+
({ className, ...props }, ref) => (
|
|
52
|
+
<h3
|
|
53
|
+
ref={ref}
|
|
54
|
+
className={cn("font-display text-lg font-semibold tracking-tight text-foreground", className)}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
CardTitle.displayName = "CardTitle";
|
|
60
|
+
|
|
61
|
+
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
|
62
|
+
({ className, ...props }, ref) => (
|
|
63
|
+
<p ref={ref} className={cn("text-sm leading-6 text-muted-foreground", className)} {...props} />
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
CardDescription.displayName = "CardDescription";
|
|
67
|
+
|
|
68
|
+
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
69
|
+
({ className, ...props }, ref) => (
|
|
70
|
+
<div ref={ref} className={cn("p-6 pt-5", className)} {...props} />
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
CardContent.displayName = "CardContent";
|
|
74
|
+
|
|
75
|
+
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
76
|
+
({ className, ...props }, ref) => (
|
|
77
|
+
<div ref={ref} className={cn("flex items-center gap-3 border-t border-border/70 p-6 pt-5", className)} {...props} />
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
CardFooter.displayName = "CardFooter";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { forwardRef, type ElementType, type HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
|
|
4
|
+
export type ContainerSize = "sm" | "md" | "lg" | "xl" | "full";
|
|
5
|
+
|
|
6
|
+
export interface ContainerProps extends HTMLAttributes<HTMLElement> {
|
|
7
|
+
/** Render as a specific element (section, header, …). */
|
|
8
|
+
as?: ElementType;
|
|
9
|
+
/**
|
|
10
|
+
* Maximum content width:
|
|
11
|
+
* `sm` → prose · `md` → reading · `lg` → plans/splits ·
|
|
12
|
+
* `xl` → default marketing page · `full` → edge-to-edge.
|
|
13
|
+
*/
|
|
14
|
+
size?: ContainerSize;
|
|
15
|
+
/** Responsive gutters (px-5 / sm:px-8). Disable for flush content. */
|
|
16
|
+
gutters?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sizes: Record<Exclude<ContainerSize, "full">, string> = {
|
|
20
|
+
sm: "max-w-xl",
|
|
21
|
+
md: "max-w-3xl",
|
|
22
|
+
lg: "max-w-5xl",
|
|
23
|
+
xl: "max-w-6xl",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shared layout wrapper — the single source of the container + gutter rules
|
|
28
|
+
* used across Section, Footer, CTA, templates…
|
|
29
|
+
*/
|
|
30
|
+
export const Container = forwardRef<HTMLElement, ContainerProps>(
|
|
31
|
+
({ className, as, size = "xl", gutters = true, ...props }, ref) => {
|
|
32
|
+
const Tag = (as ?? "div") as ElementType;
|
|
33
|
+
return (
|
|
34
|
+
<Tag
|
|
35
|
+
ref={ref as never}
|
|
36
|
+
className={cn(
|
|
37
|
+
"mx-auto w-full",
|
|
38
|
+
size !== "full" && sizes[size],
|
|
39
|
+
gutters && "px-5 sm:px-8",
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
Container.displayName = "Container";
|
|
48
|
+
|
|
49
|
+
export type StackGap = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 16 | 20;
|
|
50
|
+
|
|
51
|
+
const gaps: Record<StackGap, string> = {
|
|
52
|
+
0: "gap-0",
|
|
53
|
+
1: "gap-1",
|
|
54
|
+
2: "gap-2",
|
|
55
|
+
3: "gap-3",
|
|
56
|
+
4: "gap-4",
|
|
57
|
+
5: "gap-5",
|
|
58
|
+
6: "gap-6",
|
|
59
|
+
8: "gap-8",
|
|
60
|
+
10: "gap-10",
|
|
61
|
+
12: "gap-12",
|
|
62
|
+
16: "gap-16",
|
|
63
|
+
20: "gap-20",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface StackProps extends HTMLAttributes<HTMLDivElement> {
|
|
67
|
+
gap?: StackGap;
|
|
68
|
+
horizontal?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Vertical (or horizontal) rhythm helper using flex gaps. */
|
|
72
|
+
export const Stack = forwardRef<HTMLDivElement, StackProps>(
|
|
73
|
+
({ className, gap = 4, horizontal = false, ...props }, ref) => (
|
|
74
|
+
<div
|
|
75
|
+
ref={ref}
|
|
76
|
+
className={cn(
|
|
77
|
+
"flex",
|
|
78
|
+
horizontal ? "flex-row flex-wrap items-center" : "flex-col",
|
|
79
|
+
gaps[gap],
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
{...props}
|
|
83
|
+
/>
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
Stack.displayName = "Stack";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useState, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { Plus } from "../icons";
|
|
4
|
+
|
|
5
|
+
export interface FaqItem {
|
|
6
|
+
question: string;
|
|
7
|
+
answer: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FAQProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
11
|
+
items: FaqItem[];
|
|
12
|
+
option?: "accordion" | "split" | "cards";
|
|
13
|
+
allowMultiple?: boolean;
|
|
14
|
+
defaultOpen?: number[];
|
|
15
|
+
eyebrow?: ReactNode;
|
|
16
|
+
title?: ReactNode;
|
|
17
|
+
description?: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const FAQ = ({
|
|
21
|
+
className,
|
|
22
|
+
items,
|
|
23
|
+
option = "accordion",
|
|
24
|
+
allowMultiple = false,
|
|
25
|
+
defaultOpen = [],
|
|
26
|
+
eyebrow,
|
|
27
|
+
title,
|
|
28
|
+
description,
|
|
29
|
+
...props
|
|
30
|
+
}: FAQProps) => {
|
|
31
|
+
const [openSet, setOpenSet] = useState<Set<number>>(
|
|
32
|
+
() => new Set(allowMultiple ? defaultOpen : defaultOpen.slice(0, 1)),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const toggle = (index: number) => {
|
|
36
|
+
setOpenSet((prev) => {
|
|
37
|
+
const next = new Set(prev);
|
|
38
|
+
if (next.has(index)) {
|
|
39
|
+
next.delete(index);
|
|
40
|
+
} else {
|
|
41
|
+
if (!allowMultiple) next.clear();
|
|
42
|
+
next.add(index);
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const headerRow = (label: string) => (
|
|
49
|
+
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{label}</p>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** cards — always-open pairs in a quiet hairline grid. */
|
|
53
|
+
if (option === "cards") {
|
|
54
|
+
return (
|
|
55
|
+
<div className={cn("w-full", className)} {...props}>
|
|
56
|
+
{eyebrow ? <div className="mb-10">{headerRow(String(eyebrow))}</div> : null}
|
|
57
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
58
|
+
{items.map((item, i) => (
|
|
59
|
+
<div
|
|
60
|
+
key={item.question}
|
|
61
|
+
className="flex flex-col rounded-xl border border-border bg-surface p-7"
|
|
62
|
+
>
|
|
63
|
+
<div className="flex items-baseline gap-4">
|
|
64
|
+
<span className="font-display text-sm font-semibold tabular-nums text-foreground/35">
|
|
65
|
+
{String(i + 1).padStart(2, "0")}
|
|
66
|
+
</span>
|
|
67
|
+
<h3 className="font-display text-lg font-semibold tracking-tight text-foreground">
|
|
68
|
+
{item.question}
|
|
69
|
+
</h3>
|
|
70
|
+
</div>
|
|
71
|
+
<p className="mt-4 border-t border-border pt-4 text-sm leading-6 text-muted-foreground">
|
|
72
|
+
{item.answer}
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** accordion — single-column, hairline divided. */
|
|
82
|
+
const accordion = (
|
|
83
|
+
<div className="divide-y divide-border rounded-2xl border border-border bg-surface">
|
|
84
|
+
{items.map((item, index) => {
|
|
85
|
+
const open = openSet.has(index);
|
|
86
|
+
return (
|
|
87
|
+
<div key={item.question}>
|
|
88
|
+
<h3>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
aria-expanded={open}
|
|
92
|
+
onClick={() => toggle(index)}
|
|
93
|
+
className="flex w-full cursor-pointer items-center justify-between gap-4 px-6 py-5 text-left text-[15px] font-medium text-foreground transition-colors duration-150 hover:bg-surface-strong/60 focus-visible:outline-none"
|
|
94
|
+
>
|
|
95
|
+
{item.question}
|
|
96
|
+
<Plus
|
|
97
|
+
className={cn(
|
|
98
|
+
"h-4.5 w-4.5 shrink-0 text-foreground/45 transition-transform duration-200 sm:h-5 sm:w-5",
|
|
99
|
+
open && "rotate-45 text-primary",
|
|
100
|
+
)}
|
|
101
|
+
/>
|
|
102
|
+
</button>
|
|
103
|
+
</h3>
|
|
104
|
+
{open ? (
|
|
105
|
+
<p className="px-6 pb-6 text-sm leading-7 text-muted-foreground">{item.answer}</p>
|
|
106
|
+
) : null}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (option === "split") {
|
|
114
|
+
return (
|
|
115
|
+
<div className={cn("grid w-full gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:gap-20", className)} {...props}>
|
|
116
|
+
<div className="lg:sticky lg:top-24 lg:self-start">
|
|
117
|
+
{eyebrow ? headerRow(String(eyebrow)) : null}
|
|
118
|
+
{title ? (
|
|
119
|
+
<h2 className="mt-4 font-display text-3xl font-semibold leading-[1.08] tracking-tight text-foreground sm:text-4xl">
|
|
120
|
+
{title}
|
|
121
|
+
</h2>
|
|
122
|
+
) : null}
|
|
123
|
+
{description ? (
|
|
124
|
+
<p className="mt-4 text-pretty text-base leading-7 text-muted-foreground">{description}</p>
|
|
125
|
+
) : null}
|
|
126
|
+
</div>
|
|
127
|
+
<div className="divide-y divide-border">
|
|
128
|
+
{items.map((item, index) => {
|
|
129
|
+
const open = openSet.has(index);
|
|
130
|
+
return (
|
|
131
|
+
<div key={item.question}>
|
|
132
|
+
<h3>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
aria-expanded={open}
|
|
136
|
+
onClick={() => toggle(index)}
|
|
137
|
+
className="flex w-full cursor-pointer items-center justify-between gap-4 py-5 text-left text-base font-semibold text-foreground transition-colors duration-150 focus-visible:outline-none"
|
|
138
|
+
>
|
|
139
|
+
{item.question}
|
|
140
|
+
<Plus
|
|
141
|
+
className={cn(
|
|
142
|
+
"h-4.5 w-4.5 shrink-0 text-foreground/45 transition-transform duration-200 sm:h-5 sm:w-5",
|
|
143
|
+
open && "rotate-45 text-primary",
|
|
144
|
+
)}
|
|
145
|
+
/>
|
|
146
|
+
</button>
|
|
147
|
+
</h3>
|
|
148
|
+
{open ? <p className="pb-6 text-sm leading-7 text-muted-foreground">{item.answer}</p> : null}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className={cn("mx-auto w-full max-w-3xl", className)} {...props}>
|
|
159
|
+
{accordion}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
FAQ.displayName = "FAQ";
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { getContrastText } from "../utils/contrast";
|
|
4
|
+
|
|
5
|
+
export interface FeatureItem {
|
|
6
|
+
icon?: ReactNode;
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
accent?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FeatureCardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
13
|
+
icon?: ReactNode;
|
|
14
|
+
title: ReactNode;
|
|
15
|
+
description?: ReactNode;
|
|
16
|
+
/** Arbitrary CSS color for the icon chip; glyph color computed for contrast. */
|
|
17
|
+
accent?: string;
|
|
18
|
+
large?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const FeatureCard = forwardRef<HTMLDivElement, FeatureCardProps>(
|
|
22
|
+
({ className, icon, title, description, accent, large = false, ...props }, ref) => {
|
|
23
|
+
const chipStyle = accent
|
|
24
|
+
? { backgroundColor: accent, color: getContrastText(accent, "#111111", "#ffffff") }
|
|
25
|
+
: undefined;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
ref={ref}
|
|
30
|
+
className={cn(
|
|
31
|
+
"group relative flex h-full flex-col rounded-xl border border-border bg-surface p-7 transition-colors duration-200 hover:border-foreground/15",
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{icon ? (
|
|
37
|
+
<div
|
|
38
|
+
className={cn(
|
|
39
|
+
"mb-5 inline-flex h-10 w-10 items-center justify-center rounded-lg text-[1.05rem]",
|
|
40
|
+
!accent && "bg-primary-soft text-primary",
|
|
41
|
+
)}
|
|
42
|
+
style={chipStyle}
|
|
43
|
+
>
|
|
44
|
+
{icon}
|
|
45
|
+
</div>
|
|
46
|
+
) : null}
|
|
47
|
+
<h3 className={cn("font-display font-semibold tracking-tight text-foreground", large ? "text-2xl" : "text-lg")}>
|
|
48
|
+
{title}
|
|
49
|
+
</h3>
|
|
50
|
+
{description ? (
|
|
51
|
+
<p className={cn("mt-2.5 text-pretty leading-relaxed text-muted-foreground", large ? "text-[15px]" : "text-sm")}>
|
|
52
|
+
{description}
|
|
53
|
+
</p>
|
|
54
|
+
) : null}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
FeatureCard.displayName = "FeatureCard";
|
|
60
|
+
|
|
61
|
+
export interface FeatureGridProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
62
|
+
features: FeatureItem[];
|
|
63
|
+
option?: "columns" | "bento" | "editorialRows";
|
|
64
|
+
columns?: 2 | 3 | 4;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const gridCols: Record<NonNullable<FeatureGridProps["columns"]>, string> = {
|
|
68
|
+
2: "sm:grid-cols-2",
|
|
69
|
+
3: "sm:grid-cols-2 lg:grid-cols-3",
|
|
70
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Asymmetric bento layout for up to 6 features. */
|
|
74
|
+
const bentoSpans: string[] = [
|
|
75
|
+
"sm:col-span-2 lg:row-span-2",
|
|
76
|
+
"",
|
|
77
|
+
"",
|
|
78
|
+
"lg:col-span-2",
|
|
79
|
+
"",
|
|
80
|
+
"lg:col-span-2",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
export const FeatureGrid = forwardRef<HTMLDivElement, FeatureGridProps>(
|
|
84
|
+
({ className, features, option = "columns", columns = 3, ...props }, ref) => {
|
|
85
|
+
if (option === "editorialRows") {
|
|
86
|
+
return (
|
|
87
|
+
<div ref={ref} className={cn("w-full", className)} {...props}>
|
|
88
|
+
<div className="divide-y divide-border border-t border-b border-border">
|
|
89
|
+
{features.map((feature, i) => (
|
|
90
|
+
<div
|
|
91
|
+
key={i}
|
|
92
|
+
className="group flex flex-col gap-3 py-6 transition-colors duration-200 sm:flex-row sm:items-baseline sm:gap-10"
|
|
93
|
+
>
|
|
94
|
+
<span className="text-sm font-medium tabular-nums text-muted-foreground/60 sm:w-12">
|
|
95
|
+
{String(i + 1).padStart(2, "0")}
|
|
96
|
+
</span>
|
|
97
|
+
<div className="flex-1 sm:flex sm:items-baseline sm:justify-between sm:gap-10">
|
|
98
|
+
<h3 className="font-display text-xl font-semibold tracking-tight text-foreground sm:w-2/5">
|
|
99
|
+
{feature.title}
|
|
100
|
+
</h3>
|
|
101
|
+
<p className="mt-1.5 text-sm leading-7 text-muted-foreground sm:mt-0 sm:w-1/2">
|
|
102
|
+
{feature.description}
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
{feature.icon ? (
|
|
106
|
+
<span className="hidden text-foreground/30 sm:inline-flex">{feature.icon}</span>
|
|
107
|
+
) : null}
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (option === "bento") {
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
ref={ref}
|
|
119
|
+
className={cn(
|
|
120
|
+
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 lg:auto-rows-[minmax(9rem,auto)]",
|
|
121
|
+
className,
|
|
122
|
+
)}
|
|
123
|
+
{...props}
|
|
124
|
+
>
|
|
125
|
+
{features.map((feature, i) =>
|
|
126
|
+
feature ? (
|
|
127
|
+
<div key={i} className={cn(i < bentoSpans.length && bentoSpans[i])}>
|
|
128
|
+
<FeatureCard {...feature} large={i === 0} className="h-full" />
|
|
129
|
+
</div>
|
|
130
|
+
) : null,
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div
|
|
138
|
+
ref={ref}
|
|
139
|
+
className={cn(
|
|
140
|
+
"grid grid-cols-1 gap-4",
|
|
141
|
+
gridCols[columns],
|
|
142
|
+
className,
|
|
143
|
+
)}
|
|
144
|
+
{...props}
|
|
145
|
+
>
|
|
146
|
+
{features.map((feature, i) => (
|
|
147
|
+
<FeatureCard key={i} {...feature} className="h-full" />
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
FeatureGrid.displayName = "FeatureGrid";
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { Container } from "./Container";
|
|
4
|
+
|
|
5
|
+
export interface FooterLink {
|
|
6
|
+
label: string;
|
|
7
|
+
href: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FooterColumn {
|
|
11
|
+
title: string;
|
|
12
|
+
links: FooterLink[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FooterProps extends HTMLAttributes<HTMLElement> {
|
|
16
|
+
brand?: ReactNode;
|
|
17
|
+
description?: string;
|
|
18
|
+
columns: FooterColumn[];
|
|
19
|
+
socials?: ReactNode;
|
|
20
|
+
bottom?: ReactNode;
|
|
21
|
+
option?: "classic" | "minimal" | "editorial";
|
|
22
|
+
badge?: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Footer = ({
|
|
26
|
+
className,
|
|
27
|
+
brand,
|
|
28
|
+
description,
|
|
29
|
+
columns,
|
|
30
|
+
socials,
|
|
31
|
+
bottom,
|
|
32
|
+
option = "classic",
|
|
33
|
+
badge,
|
|
34
|
+
...props
|
|
35
|
+
}: FooterProps) => {
|
|
36
|
+
const brandNode = (
|
|
37
|
+
<span className="flex items-center gap-2 font-display text-lg font-bold tracking-tight">
|
|
38
|
+
<span aria-hidden className="inline-block h-2.5 w-2.5 rounded-sm bg-current" />
|
|
39
|
+
{brand}
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (option === "minimal") {
|
|
44
|
+
return (
|
|
45
|
+
<footer className={cn("w-full border-t border-border bg-background", className)} {...props}>
|
|
46
|
+
<Container className="flex flex-col items-center justify-between gap-6 py-10 sm:flex-row">
|
|
47
|
+
{brandNode}
|
|
48
|
+
<ul className="flex flex-wrap items-center justify-center gap-x-8 gap-y-3">
|
|
49
|
+
{columns.flatMap((c) => c.links).slice(0, 6).map((link) => (
|
|
50
|
+
<li key={link.href}>
|
|
51
|
+
<a href={link.href} className="text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground">
|
|
52
|
+
{link.label}
|
|
53
|
+
</a>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
<div className="text-sm text-muted-foreground">{bottom}</div>
|
|
58
|
+
</Container>
|
|
59
|
+
</footer>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (option === "editorial") {
|
|
64
|
+
return (
|
|
65
|
+
<footer className={cn("w-full border-t border-border bg-background", className)} {...props}>
|
|
66
|
+
<Container className="py-16 sm:py-20">
|
|
67
|
+
<div className="mb-14 flex flex-col justify-between gap-8 sm:flex-row sm:items-end">
|
|
68
|
+
<div>
|
|
69
|
+
<span className="font-display text-5xl font-bold tracking-[-0.03em] text-foreground sm:text-7xl">
|
|
70
|
+
{brand}
|
|
71
|
+
</span>
|
|
72
|
+
{description ? (
|
|
73
|
+
<p className="mt-3 max-w-sm text-sm leading-6 text-muted-foreground">{description}</p>
|
|
74
|
+
) : null}
|
|
75
|
+
</div>
|
|
76
|
+
{socials ? <div className="flex items-center gap-3">{socials}</div> : null}
|
|
77
|
+
</div>
|
|
78
|
+
<div className="grid grid-cols-2 gap-8 border-t border-border pt-12 md:grid-cols-4">
|
|
79
|
+
{columns.map((column) => (
|
|
80
|
+
<div key={column.title}>
|
|
81
|
+
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
|
82
|
+
{column.title}
|
|
83
|
+
</h3>
|
|
84
|
+
<ul className="mt-4 space-y-2.5">
|
|
85
|
+
{column.links.map((link) => (
|
|
86
|
+
<li key={link.href}>
|
|
87
|
+
<a href={link.href} className="text-[15px] text-foreground/80 transition-colors duration-150 hover:text-foreground">
|
|
88
|
+
{link.label}
|
|
89
|
+
</a>
|
|
90
|
+
</li>
|
|
91
|
+
))}
|
|
92
|
+
</ul>
|
|
93
|
+
</div>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
|
|
97
|
+
<span>{bottom}</span>
|
|
98
|
+
<span>© {new Date().getFullYear()} {brand}. All rights reserved.</span>
|
|
99
|
+
</div>
|
|
100
|
+
</Container>
|
|
101
|
+
</footer>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<footer className={cn("w-full bg-secondary text-on-secondary", className)} {...props}>
|
|
107
|
+
<Container className="py-16">
|
|
108
|
+
<div className="grid grid-cols-2 gap-10 md:grid-cols-4 lg:grid-cols-6">
|
|
109
|
+
<div className="col-span-2">
|
|
110
|
+
<span style={{ color: "rgb(var(--color-on-secondary))" }}>{brandNode}</span>
|
|
111
|
+
{description ? (
|
|
112
|
+
<p className="mt-4 max-w-xs text-sm leading-6 text-on-secondary/60">{description}</p>
|
|
113
|
+
) : null}
|
|
114
|
+
{socials ? <div className="mt-6 flex items-center gap-3">{socials}</div> : null}
|
|
115
|
+
{badge ? <div className="mt-6">{badge}</div> : null}
|
|
116
|
+
</div>
|
|
117
|
+
{columns.map((column) => (
|
|
118
|
+
<div key={column.title}>
|
|
119
|
+
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-on-secondary/50">
|
|
120
|
+
{column.title}
|
|
121
|
+
</h3>
|
|
122
|
+
<ul className="mt-4 space-y-2.5">
|
|
123
|
+
{column.links.map((link) => (
|
|
124
|
+
<li key={link.href}>
|
|
125
|
+
<a
|
|
126
|
+
href={link.href}
|
|
127
|
+
className="text-sm text-on-secondary/70 transition-colors duration-150 hover:text-on-secondary"
|
|
128
|
+
>
|
|
129
|
+
{link.label}
|
|
130
|
+
</a>
|
|
131
|
+
</li>
|
|
132
|
+
))}
|
|
133
|
+
</ul>
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-white/10 pt-8 text-sm text-on-secondary/50 sm:flex-row">
|
|
138
|
+
<span>{bottom}</span>
|
|
139
|
+
<span>© {new Date().getFullYear()} {brand}. All rights reserved.</span>
|
|
140
|
+
</div>
|
|
141
|
+
</Container>
|
|
142
|
+
</footer>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
Footer.displayName = "Footer";
|