This commit is contained in:
eric
2026-03-14 00:28:34 -05:00
parent cf1e3760c2
commit 1f8c9c6d26
88 changed files with 5463 additions and 22657 deletions

View File

@@ -0,0 +1,78 @@
import Link from "next/link";
import { NAV_LINKS, ENTRY_PATHS } from "~/lib/data";
export function SiteFooter() {
return (
<footer className="border-t border-neutral-200 bg-neutral-50">
<div className="container mx-auto px-4 py-12 md:px-6">
<div className="grid gap-8 md:grid-cols-4">
<div className="md:col-span-2">
<Link href="/" className="text-lg font-semibold text-neutral-900">
Nomadro
</Link>
<p className="mt-2 max-w-sm text-sm text-neutral-600">
</p>
</div>
<div>
<h4 className="text-sm font-semibold text-neutral-900">
</h4>
<ul className="mt-3 space-y-2">
{ENTRY_PATHS.map((path) => (
<li key={path.id}>
<Link
href={path.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-600 hover:text-neutral-900"
>
{path.title}
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-semibold text-neutral-900">
</h4>
<ul className="mt-3 space-y-2">
<li>
<Link
href="/about"
className="text-sm text-neutral-600 hover:text-neutral-900"
>
</Link>
</li>
<li>
<Link
href="/contact"
className="text-sm text-neutral-600 hover:text-neutral-900"
>
</Link>
</li>
<li>
<Link
href="https://meetup.hackrobot.cn"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-neutral-600 hover:text-neutral-900"
>
</Link>
</li>
</ul>
</div>
</div>
<div className="mt-12 border-t border-neutral-200 pt-8">
<p className="text-center text-xs text-neutral-500">
© {new Date().getFullYear()} Nomadro. Digital Nomad OS ·
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { Menu, X } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { NAV_LINKS } from "~/lib/data";
import { Button } from "~/components/ui/button";
export function SiteHeader() {
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header
className={`sticky top-0 z-50 w-full border-b transition-colors duration-300 ${
scrolled
? "border-neutral-200 bg-white/95 shadow-sm backdrop-blur-md"
: "border-neutral-200/80 bg-white/80 backdrop-blur-md"
}`}
>
<div className="container mx-auto flex h-16 items-center justify-between px-4 md:px-6">
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-semibold tracking-tight text-neutral-900">
Nomadro
</span>
<span className="hidden text-xs text-neutral-500 md:inline">
Digital Nomad OS /
</span>
</Link>
<nav className="hidden items-center gap-8 md:flex">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="text-sm font-medium text-neutral-600 transition-colors hover:text-neutral-900"
>
{link.label}
</Link>
))}
<Link
href="https://meetup.hackrobot.cn"
target="_blank"
rel="noopener noreferrer"
>
<Button size="sm"></Button>
</Link>
</nav>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-lg md:hidden"
onClick={() => setMobileOpen(true)}
aria-label="打开菜单"
>
<Menu className="h-5 w-5 text-neutral-700" />
</button>
</div>
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-white md:hidden"
>
<div className="flex h-16 items-center justify-between border-b px-4">
<span className="text-lg font-semibold">Nomadro</span>
<button
type="button"
className="flex h-10 w-10 items-center justify-center"
onClick={() => setMobileOpen(false)}
aria-label="关闭菜单"
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex flex-col gap-1 p-4">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="rounded-lg px-4 py-3 text-base font-medium text-neutral-700 hover:bg-neutral-50"
onClick={() => setMobileOpen(false)}
>
{link.label}
</Link>
))}
<Link
href="https://meetup.hackrobot.cn"
target="_blank"
rel="noopener noreferrer"
className="mt-4 rounded-lg bg-neutral-900 px-4 py-3 text-center text-base font-medium text-white"
onClick={() => setMobileOpen(false)}
>
</Link>
</nav>
</motion.div>
)}
</AnimatePresence>
</header>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { motion } from "framer-motion";
import { CONTENT_PREVIEW_ITEMS } from "~/lib/data";
const TAG_COLORS: Record<string, string> = {
: "bg-blue-50 text-blue-700",
: "bg-emerald-50 text-emerald-700",
: "bg-amber-50 text-amber-700",
: "bg-rose-50 text-rose-700",
: "bg-violet-50 text-violet-700",
: "bg-neutral-100 text-neutral-700",
};
export function ContentPreview() {
return (
<section className="bg-white py-16 md:py-24">
<div className="container mx-auto px-4 md:px-6">
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
className="text-2xl font-semibold text-neutral-900 md:text-3xl"
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mt-2 text-neutral-600"
>
</motion.p>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{CONTENT_PREVIEW_ITEMS.map((item, i) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: Math.min(i * 0.05, 0.2) }}
>
<Link
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="group block rounded-xl border border-neutral-200 p-5 transition-all hover:border-neutral-300 hover:shadow-md"
>
<span
className={`inline-block rounded-md px-2 py-0.5 text-xs font-medium ${
TAG_COLORS[item.type] ?? "bg-neutral-100 text-neutral-600"
}`}
>
{item.tag}
</span>
<h3 className="mt-3 font-medium text-neutral-900">
{item.title}
</h3>
<p className="mt-1 text-sm text-neutral-500">{item.summary}</p>
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-neutral-600 group-hover:text-neutral-900">
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</Link>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { MapPin, BookOpen, Crown } from "lucide-react";
import { motion } from "framer-motion";
import { ECOSYSTEM_COLUMNS } from "~/lib/data";
const ICON_MAP = [MapPin, BookOpen, Crown] as const;
export function Ecosystem() {
return (
<section className="bg-neutral-50 py-16 md:py-24">
<div className="container mx-auto px-4 md:px-6">
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
className="text-center text-2xl font-semibold text-neutral-900 md:text-3xl"
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mx-auto mt-3 max-w-2xl text-center text-neutral-600"
>
</motion.p>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{ECOSYSTEM_COLUMNS.map((col, i) => {
const Icon = ICON_MAP[i];
return (
<motion.div
key={col.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: i * 0.08 }}
className="rounded-xl border border-neutral-200 bg-white p-6"
>
<Icon className="h-9 w-9 text-neutral-600" />
<h3 className="mt-4 font-medium text-neutral-900">
{col.title}
</h3>
<div className="mt-3 flex flex-wrap gap-2">
{col.items.map((item) => (
<span
key={item}
className="rounded-md bg-neutral-100 px-2.5 py-1 text-xs text-neutral-600"
>
{item}
</span>
))}
</div>
</motion.div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { MapPin, BookOpen, Crown, ArrowRight } from "lucide-react";
import { motion } from "framer-motion";
import { ENTRY_PATHS } from "~/lib/data";
const ICON_MAP = {
MapPin,
BookOpen,
Crown,
} as const;
const ACCENT_STYLES = {
blue: "border-l-blue-500/80 hover:border-l-blue-500",
green: "border-l-emerald-500/80 hover:border-l-emerald-500",
amber: "border-l-amber-500/80 hover:border-l-amber-500",
} as const;
export function EntryGrid() {
return (
<section className="bg-white py-16 md:py-24">
<div className="container mx-auto px-4 md:px-6">
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
className="text-center text-2xl font-semibold text-neutral-900 md:text-3xl"
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mx-auto mt-3 max-w-2xl text-center text-neutral-600"
>
</motion.p>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{ENTRY_PATHS.map((path, i) => {
const Icon = ICON_MAP[path.icon];
const accent = ACCENT_STYLES[path.accent];
return (
<motion.div
key={path.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: i * 0.08 }}
>
<Link
href={path.href}
target="_blank"
rel="noopener noreferrer"
className={`group block rounded-xl border border-neutral-200 bg-white p-6 transition-all hover:-translate-y-1 hover:shadow-lg ${accent} border-l-4`}
>
<Icon className="h-10 w-10 text-neutral-600" />
<h3 className="mt-4 text-lg font-semibold text-neutral-900">
{path.title}
</h3>
<p className="mt-2 text-sm text-neutral-600">
{path.description}
</p>
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-neutral-700 group-hover:text-neutral-900">
{path.cta}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</Link>
</motion.div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { MapPin, BookOpen, Crown } from "lucide-react";
import { motion } from "framer-motion";
import { FINAL_CTAS } from "~/lib/data";
import { Button } from "~/components/ui/button";
const ICONS = [MapPin, BookOpen, Crown] as const;
export function FinalCta() {
return (
<section className="bg-neutral-900 py-16 text-white md:py-24">
<div className="container mx-auto px-4 md:px-6">
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
className="text-center text-2xl font-semibold md:text-3xl"
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mx-auto mt-3 max-w-xl text-center text-neutral-300"
>
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="mt-12 flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:justify-center"
>
{FINAL_CTAS.map((cta, i) => {
const Icon = ICONS[i];
return (
<Link
key={cta.label}
href={cta.href}
target="_blank"
rel="noopener noreferrer"
className="group"
>
<Button
variant="outline"
size="lg"
className="w-full min-w-[200px] border-neutral-600 bg-transparent text-white hover:bg-neutral-800 hover:text-white sm:w-auto"
>
<Icon className="mr-2 h-4 w-4" />
{cta.label}
</Button>
</Link>
);
})}
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import Link from "next/link";
import { MapPin, BookOpen, Crown } from "lucide-react";
import { motion } from "framer-motion";
import { HERO_TAGS } from "~/lib/data";
import { Button } from "~/components/ui/button";
const HERO_CARDS = [
{
title: "探索城市",
desc: "找到适合远程生活的地方",
icon: MapPin,
href: "https://meetup.hackrobot.cn",
},
{
title: "学习专题",
desc: "从零搭建你的技能与收入结构",
icon: BookOpen,
href: "https://digital.hackrobot.cn",
},
{
title: "会员社区",
desc: "进入更高密度的人脉与资源网络",
icon: Crown,
href: "https://vip.hackrobot.cn",
},
];
export function Hero() {
return (
<section className="relative overflow-hidden bg-white">
<div className="container mx-auto px-4 py-16 md:px-6 md:py-24 lg:py-32">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
<div className="flex flex-col justify-center">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl md:text-5xl">
</h1>
<p className="mt-4 text-lg text-neutral-600 md:text-xl">
</p>
<p className="mt-2 text-base text-neutral-500">
</p>
</motion.div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="mt-6 max-w-lg text-sm text-neutral-500"
>
+ + +
</motion.p>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="mt-8 flex flex-wrap gap-3"
>
<Link
href="https://meetup.hackrobot.cn"
target="_blank"
rel="noopener noreferrer"
>
<Button size="lg" className="min-w-[180px]">
</Button>
</Link>
<Link
href="https://meetup.hackrobot.cn"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="lg">
</Button>
</Link>
<Link
href="https://digital.hackrobot.cn"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="lg">
</Button>
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-10 flex flex-wrap gap-2"
>
{HERO_TAGS.map((tag, i) => (
<span
key={tag}
className="rounded-full border border-neutral-200 bg-neutral-50 px-3 py-1 text-xs text-neutral-600"
>
{tag}
</span>
))}
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="flex flex-col gap-4 lg:justify-center"
>
<div className="grid gap-4 sm:grid-cols-3">
{HERO_CARDS.map((card, i) => (
<Link
key={card.title}
href={card.href}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col rounded-xl border border-neutral-200 bg-white p-5 shadow-sm transition-all hover:-translate-y-1 hover:shadow-md"
>
<card.icon className="h-8 w-8 text-neutral-400 transition-colors group-hover:text-neutral-700" />
<h3 className="mt-3 font-medium text-neutral-900">
{card.title}
</h3>
<p className="mt-1 text-sm text-neutral-500">{card.desc}</p>
</Link>
))}
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { MapPin, Wallet, Wrench, Users } from "lucide-react";
import { motion } from "framer-motion";
import { METHODOLOGY_SYSTEMS } from "~/lib/data";
const ICON_MAP = {
location: MapPin,
income: Wallet,
tools: Wrench,
community: Users,
} as const;
export function Methodology() {
return (
<section className="bg-neutral-50 py-16 md:py-24">
<div className="container mx-auto px-4 md:px-6">
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
className="text-center text-2xl font-semibold text-neutral-900 md:text-3xl"
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mx-auto mt-3 max-w-2xl text-center text-neutral-600"
>
</motion.p>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{METHODOLOGY_SYSTEMS.map((sys, i) => {
const Icon = ICON_MAP[sys.id];
return (
<motion.div
key={sys.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: i * 0.05 }}
className="rounded-xl border border-neutral-200 bg-white p-6"
>
<Icon className="h-9 w-9 text-neutral-600" />
<h3 className="mt-4 font-medium text-neutral-900">
{sys.title}
</h3>
<p className="mt-2 text-sm text-neutral-600">
{sys.description}
</p>
</motion.div>
);
})}
</div>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mx-auto mt-12 max-w-2xl text-center text-sm text-neutral-600"
>
</motion.p>
</div>
</section>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { motion } from "framer-motion";
import { SCENARIOS } from "~/lib/data";
export function Scenarios() {
return (
<section className="bg-white py-16 md:py-24">
<div className="container mx-auto px-4 md:px-6">
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
className="text-center text-2xl font-semibold text-neutral-900 md:text-3xl"
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mx-auto mt-3 max-w-2xl text-center text-neutral-600"
>
</motion.p>
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{SCENARIOS.map((s, i) => (
<motion.div
key={s.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: i * 0.06 }}
>
<Link
href={s.href}
target="_blank"
rel="noopener noreferrer"
className="group block rounded-xl border border-neutral-200 bg-neutral-50/50 p-5 transition-all hover:border-neutral-300 hover:bg-white hover:shadow-md"
>
<p className="font-medium text-neutral-900">{s.pain}</p>
<p className="mt-1 text-sm text-neutral-500">{s.path}</p>
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-neutral-700 group-hover:text-neutral-900">
{s.cta}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</Link>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Target, Layers, Handshake } from "lucide-react";
import { motion } from "framer-motion";
import { TRUST_PRINCIPLES } from "~/lib/data";
const ICON_MAP = [Target, Layers, Handshake] as const;
export function Trust() {
return (
<section className="bg-neutral-50 py-16 md:py-24">
<div className="container mx-auto px-4 md:px-6">
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
className="text-center text-2xl font-semibold text-neutral-900 md:text-3xl"
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mx-auto mt-3 max-w-2xl text-center text-neutral-600"
>
</motion.p>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{TRUST_PRINCIPLES.map((p, i) => {
const Icon = ICON_MAP[i];
return (
<motion.div
key={p.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: i * 0.06 }}
className="rounded-xl border border-neutral-200 bg-white p-6 text-center"
>
<Icon className="mx-auto h-9 w-9 text-neutral-600" />
<h3 className="mt-4 font-medium text-neutral-900">
{p.title}
</h3>
<p className="mt-2 text-sm text-neutral-600">
{p.description}
</p>
</motion.div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { MapPin, TrendingUp, Zap, Users } from "lucide-react";
import { motion } from "framer-motion";
import { VALUE_PROPOSITIONS } from "~/lib/data";
const ICON_MAP = {
MapPin,
TrendingUp,
Zap,
Users,
} as const;
export function ValueGrid() {
return (
<section className="bg-neutral-50 py-16 md:py-24">
<div className="container mx-auto px-4 md:px-6">
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
className="text-center text-2xl font-semibold text-neutral-900 md:text-3xl"
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mx-auto mt-3 max-w-2xl text-center text-neutral-600"
>
</motion.p>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{VALUE_PROPOSITIONS.map((item, i) => {
const Icon = ICON_MAP[item.icon];
return (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: i * 0.05 }}
className="rounded-xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md"
>
<Icon className="h-10 w-10 text-neutral-600" />
<h3 className="mt-4 font-medium text-neutral-900">
{item.title}
</h3>
<p className="mt-2 text-sm text-neutral-600">
{item.description}
</p>
</motion.div>
);
})}
</div>
</div>
</section>
);
}

38
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,38 @@
import * as React from "react";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "outline" | "ghost" | "link";
size?: "default" | "sm" | "lg";
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className = "", variant = "default", size = "default", ...props }, ref) => {
const base =
"inline-flex items-center justify-center whitespace-nowrap rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
const variants = {
default:
"bg-neutral-900 text-white hover:bg-neutral-800 focus-visible:ring-neutral-900",
outline:
"border border-neutral-200 bg-transparent hover:bg-neutral-50 focus-visible:ring-neutral-400",
ghost: "hover:bg-neutral-100 focus-visible:ring-neutral-400",
link: "text-neutral-900 underline-offset-4 hover:underline focus-visible:ring-neutral-400",
};
const sizes = {
default: "h-10 px-4 py-2 text-sm",
sm: "h-8 px-3 text-xs",
lg: "h-12 px-6 text-base",
};
return (
<button
ref={ref}
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button };