This commit is contained in:
eric
2026-03-07 12:33:14 -06:00
parent 8b034e255b
commit 9bb8bcdf0b
28 changed files with 7231 additions and 78 deletions

189
app/components/CityCard.tsx Normal file
View File

@@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import { City } from "../data/cities";
function ratingToPercent(rating: string): number {
const m: Record<string, number> = {
极好: 95,
很好: 80,
: 60,
一般: 40,
: 20,
};
return m[rating] || 50;
}
function getBarColor(p: number): string {
if (p >= 60) return "#22c55e";
if (p >= 35) return "#eab308";
return "#ef4444";
}
function ScoreBar({
icon,
label,
percent,
animate,
}: {
icon: string;
label: string;
percent: number;
animate: boolean;
}) {
return (
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-white/80 text-xs sm:text-sm w-14 sm:w-16 shrink-0 flex items-center gap-1">
<span>{icon}</span>
<span className="hidden sm:inline">{label}</span>
</span>
<div className="flex-1 h-2 sm:h-2.5 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700 ease-out"
style={{
width: animate ? `${percent}%` : "0%",
backgroundColor: getBarColor(percent),
}}
/>
</div>
</div>
);
}
export default function CityCard({
city,
rank,
}: {
city: City;
rank: number;
}) {
const [hovered, setHovered] = useState(false);
const scores = [
{
icon: "⭐",
label: "综合",
percent: (city.overallScore / 5) * 100,
},
{
icon: "💵",
label: "费用",
percent: Math.max(15, 100 - city.costPerMonth / 200),
},
{
icon: "📡",
label: "网速",
percent: Math.min(95, (city.internetSpeed / 120) * 100),
},
{
icon: "👍",
label: "好评",
percent: ratingToPercent(city.liked),
},
{
icon: "👮",
label: "安全",
percent: ratingToPercent(city.safety),
},
];
return (
<div
className="city-card"
style={{
background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Icon watermark */}
<span className="absolute bottom-8 right-2 text-[70px] sm:text-[90px] opacity-[0.12] select-none pointer-events-none leading-none">
{city.icon}
</span>
{/* ===== Default state ===== */}
<div
className={`absolute inset-0 z-10 flex flex-col justify-between p-3 sm:p-4 transition-opacity duration-300 ${hovered ? "opacity-0" : "opacity-100"}`}
>
{/* Top row */}
<div className="flex items-start justify-between">
<span className="text-white/70 text-base sm:text-xl font-bold">
{rank}
</span>
<span className="text-[10px] sm:text-xs bg-white/20 backdrop-blur-sm rounded-full px-1.5 sm:px-2 py-0.5 sm:py-1 text-white flex items-center gap-0.5">
📡 {city.internetSpeed}Mbps
</span>
</div>
{/* Center: city name + country */}
<div className="text-center -mt-2">
<h3 className="text-xl sm:text-[28px] font-bold text-white drop-shadow-md tracking-wide">
{city.name}
</h3>
<p className="text-xs sm:text-sm text-white/60 mt-0.5">
{city.countryEmoji} {city.country}
</p>
</div>
{/* Bottom: temp + cost + nomads */}
<div>
<div className="flex items-center gap-1 mb-1.5">
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
👥 {city.nomadsNow}
</span>
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
🌡 {city.temperature}°C
</span>
</div>
<div className="flex items-end justify-between text-white text-xs sm:text-sm">
<span className="text-white/80 text-[10px] sm:text-xs line-clamp-1 max-w-[60%]">
{city.description}
</span>
<span className="font-semibold shrink-0">
¥{city.costPerMonth.toLocaleString()}/
</span>
</div>
</div>
</div>
{/* ===== Hover overlay (desktop only) ===== */}
<div
className={`absolute inset-0 z-20 bg-[#2a2a2a]/95 hidden sm:flex flex-col justify-between p-4 sm:p-5 transition-opacity duration-300 ${hovered ? "opacity-100" : "opacity-0 pointer-events-none"}`}
>
{/* Top icons */}
<div className="flex items-center justify-between">
<button className="text-white/50 hover:text-red-400 transition-colors text-base">
</button>
<button
className="text-white/30 hover:text-white text-sm transition-colors"
onClick={(e) => {
e.stopPropagation();
setHovered(false);
}}
>
</button>
</div>
{/* Score bars */}
<div className="space-y-2.5 sm:space-y-3">
{scores.map((s) => (
<ScoreBar
key={s.label}
icon={s.icon}
label={s.label}
percent={s.percent}
animate={hovered}
/>
))}
</div>
{/* Bottom: city info small */}
<div className="text-center text-white/40 text-xs">
{city.name} · {city.countryEmoji} {city.country}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { allTags } from "../data/cities";
interface FilterBarProps {
activeFilter: string;
onFilterChange: (filter: string) => void;
sortBy: string;
onSortChange: (sort: string) => void;
resultCount: number;
}
const sortOptions = [
{ value: "score", label: "综合评分" },
{ value: "cost-asc", label: "费用最低" },
{ value: "cost-desc", label: "费用最高" },
{ value: "internet", label: "网速最快" },
{ value: "safety", label: "最安全" },
{ value: "nomads", label: "游民最多" },
{ value: "temperature", label: "最温暖" },
];
const tagIcons: Record<string, string> = {
: "🌍",
: "🔥",
便: "💰",
: "📡",
: "☀️",
: "🛡️",
: "🏖️",
: "🏔️",
: "🌆",
: "🍜",
: "🎨",
线: "🏙️",
线: "⭐",
: "🏡",
: "🏛️",
: "🤝",
: "🏄",
};
export default function FilterBar({
activeFilter,
onFilterChange,
sortBy,
onSortChange,
resultCount,
}: FilterBarProps) {
return (
<div className="mb-6" id="cities">
{/* Top bar: result count + sort */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-gray-900">
</h2>
<span className="text-sm text-gray-400">
{resultCount}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 hidden sm:block">:</span>
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value)}
className="text-sm bg-white border border-gray-200 rounded-lg px-3 py-1.5 text-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-200 cursor-pointer"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* Filter tags */}
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-2">
{allTags.map((tag) => (
<button
key={tag}
onClick={() => onFilterChange(tag)}
className={`filter-btn shrink-0 flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium border transition-all ${
activeFilter === tag
? "active border-gray-900"
: "border-gray-200 text-gray-600 hover:border-gray-300"
}`}
>
<span className="text-xs">{tagIcons[tag] || "🏷️"}</span>
<span>{tag}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import Link from "next/link";
const avatarPhotos = [
"https://i.pravatar.cc/150?img=32",
"https://i.pravatar.cc/150?img=33",
"https://i.pravatar.cc/150?img=25",
"https://i.pravatar.cc/150?img=52",
"https://i.pravatar.cc/150?img=44",
"https://i.pravatar.cc/150?img=55",
"https://i.pravatar.cc/150?img=47",
"https://i.pravatar.cc/150?img=57",
"https://i.pravatar.cc/150?img=48",
"https://i.pravatar.cc/150?img=59",
"https://i.pravatar.cc/150?img=49",
];
const features = [
{ icon: "🍹", text: "每年在 30+ 城市参加线下聚会" },
{ icon: "❤️", text: "结识新朋友,约会交友" },
{ icon: "🧪", text: "研究目的地,找到最适合居住和工作的地方" },
{ icon: "🌎", text: "追踪你的旅行,记录你去过的地方" },
{ icon: "💬", text: "加入社群聊天,在旅途中找到你的归属" },
];
export default function HeroSection() {
const [email, setEmail] = useState("");
const handleJoin = (e: React.FormEvent) => {
e.preventDefault();
if (email.trim()) {
alert(`欢迎加入游牧中国!我们将发送确认邮件到 ${email}`);
}
};
return (
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-orange-50/70 via-white to-[#fafafa]" />
<div
className="absolute top-0 right-0 w-[500px] h-[500px] -translate-y-1/3 translate-x-1/4 opacity-40 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(249,115,22,0.25) 0%, transparent 70%)",
}}
/>
<div
className="absolute bottom-0 left-0 w-[350px] h-[350px] translate-y-1/3 -translate-x-1/4 opacity-30 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(139,92,246,0.2) 0%, transparent 70%)",
}}
/>
<div className="relative max-w-[1400px] mx-auto px-5 sm:px-10 py-10 sm:py-14 md:py-20">
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-10 xl:gap-16">
{/* Left Column */}
<div className="flex-1 min-w-0">
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-orange-700 bg-orange-100 px-3 py-1.5 rounded-full mb-5">
🏆 · 2024
</span>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight mb-5">
<span className="hero-gradient-text"></span>
<br />
<span className="text-gray-900"></span>
</h1>
<p className="text-base sm:text-lg text-gray-500 max-w-2xl mb-7 leading-relaxed">
</p>
<div className="space-y-2.5 mb-8">
{features.map((f) => (
<div key={f.text} className="flex items-start gap-2.5">
<span className="text-base sm:text-lg shrink-0 mt-0.5">
{f.icon}
</span>
<span className="text-sm sm:text-base text-gray-600 underline decoration-gray-300 underline-offset-2">
{f.text}
</span>
</div>
))}
</div>
<div className="flex items-center gap-3 mb-8">
<div className="flex -space-x-2">
{avatarPhotos.slice(0, 8).map((src, i) => (
<img
key={i}
src={src}
alt=""
className="w-8 h-8 rounded-full border-2 border-white shadow-sm object-cover"
/>
))}
</div>
<span className="text-sm text-gray-500">
<strong className="text-gray-900">5,000+</strong>
</span>
</div>
<div className="pt-7 border-t border-gray-100">
<p className="text-xs text-gray-400 uppercase tracking-wider mb-3">
</p>
<div className="flex flex-wrap items-center gap-x-8 gap-y-2">
{["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"].map(
(name) => (
<span
key={name}
className="text-sm font-semibold text-gray-300 hover:text-gray-400 transition-colors cursor-default"
>
{name}
</span>
),
)}
</div>
</div>
</div>
{/* Right Column: Video + Email */}
<div className="lg:w-[360px] xl:w-[400px] shrink-0 mt-10 lg:mt-4">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
<div className="relative h-48 sm:h-56 bg-gradient-to-br from-emerald-400 via-teal-500 to-cyan-600 flex items-center justify-center cursor-pointer group overflow-hidden">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-4 left-4 w-20 h-16 rounded-lg bg-white/20" />
<div className="absolute bottom-6 right-6 w-24 h-14 rounded-lg bg-white/15" />
<div className="absolute top-1/2 left-1/3 w-16 h-16 rounded-full bg-white/10" />
</div>
<div className="absolute bottom-0 left-0 right-0 h-1/3 bg-gradient-to-t from-black/20 to-transparent" />
<button className="relative w-16 h-16 rounded-full bg-white/95 flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
<svg
className="w-7 h-7 text-emerald-600 ml-1"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</button>
</div>
<form onSubmit={handleJoin} className="p-5">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="输入你的邮箱..."
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
/>
<button
type="submit"
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98]"
>
</button>
<p className="text-xs text-gray-400 text-center mt-3">
</p>
</form>
</div>
<div className="mt-4 flex flex-wrap gap-2 justify-center">
<Link
href="/meetups"
className="text-xs text-gray-400 hover:text-gray-600 bg-white rounded-full px-3 py-1.5 border border-gray-100 transition-colors"
>
🍹 线
</Link>
<Link
href="/dating"
className="text-xs text-gray-400 hover:text-gray-600 bg-white rounded-full px-3 py-1.5 border border-gray-100 transition-colors"
>
</Link>
<Link
href="/join"
className="text-xs text-gray-400 hover:text-gray-600 bg-white rounded-full px-3 py-1.5 border border-gray-100 transition-colors"
>
💬
</Link>
</div>
</div>
</div>
</div>
</section>
);
}

110
app/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
const navLinks = [
{ label: "城市", icon: "🏙️", href: "/" },
{ label: "旅行", icon: "✈️", href: "/meetups" },
{ label: "社区", icon: "💬", href: "/dating" },
{ label: "探索", icon: "🎒", href: "/#community" },
];
export default function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
function isActive(href: string) {
if (href.startsWith("/#")) return false;
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
}
return (
<nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-lg border-b border-gray-100">
<div className="max-w-[1600px] mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 shrink-0">
<span className="text-2xl">🌏</span>
<span className="text-lg font-bold text-gray-900 tracking-tight">
</span>
</Link>
{/* Center Nav */}
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href}
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-colors ${
isActive(link.href)
? "text-gray-900 bg-gray-100"
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
}`}
>
<span>{link.icon}</span>
<span>{link.label}</span>
</Link>
))}
</div>
{/* Right Side */}
<div className="flex items-center gap-2 sm:gap-3">
<button className="hidden sm:block text-sm text-gray-600 hover:text-gray-900 px-3 py-2 rounded-full hover:bg-gray-50 transition-colors">
</button>
<Link
href="/join"
className="text-sm bg-[#ff4d4f] text-white px-4 py-2 rounded-full hover:bg-[#ff7a45] transition-colors font-medium"
>
</Link>
{/* Mobile hamburger */}
<button
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
{/* Mobile Menu */}
{mobileOpen && (
<div className="md:hidden border-t border-gray-100 bg-white px-4 py-3 space-y-1">
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href}
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium ${
isActive(link.href)
? "text-gray-900 bg-gray-50"
: "text-gray-500 hover:bg-gray-50"
}`}
onClick={() => setMobileOpen(false)}
>
<span className="text-lg">{link.icon}</span>
<span>{link.label}</span>
</Link>
))}
<div className="pt-2 border-t border-gray-100">
<button className="w-full text-left px-4 py-3 text-sm text-gray-600 hover:bg-gray-50 rounded-xl">
</button>
</div>
</div>
)}
</nav>
);
}

245
app/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,245 @@
const meetups = [
{ city: "清迈", emoji: "🇹🇭", date: "3月9日 周一", attendees: 12 },
{ city: "曼谷", emoji: "🇹🇭", date: "3月12日 周四", attendees: 8 },
{ city: "巴厘岛", emoji: "🇮🇩", date: "3月14日 周六", attendees: 15 },
{ city: "大理", emoji: "🇨🇳", date: "3月16日 周一", attendees: 6 },
{ city: "里斯本", emoji: "🇵🇹", date: "3月20日 周五", attendees: 23 },
];
const travelingNow = [
{ name: "小明", bg: "#f43f5e", destination: "清迈" },
{ name: "雨晴", bg: "#8b5cf6", destination: "巴厘岛" },
{ name: "阿杰", bg: "#3b82f6", destination: "曼谷" },
{ name: "小燕", bg: "#10b981", destination: "东京" },
{ name: "大伟", bg: "#f59e0b", destination: "里斯本" },
{ name: "佳琪", bg: "#ec4899", destination: "墨西哥城" },
{ name: "志远", bg: "#06b6d4", destination: "柏林" },
{ name: "心怡", bg: "#ef4444", destination: "首尔" },
{ name: "凯文", bg: "#84cc16", destination: "巴塞罗那" },
{ name: "子豪", bg: "#7c3aed", destination: "成都" },
{ name: "浩然", bg: "#0d9488", destination: "大理" },
{ name: "雅琪", bg: "#ea580c", destination: "台北" },
{ name: "天翔", bg: "#6366f1", destination: "布达佩斯" },
{ name: "美玲", bg: "#d946ef", destination: "梅德林" },
{ name: "俊杰", bg: "#14b8a6", destination: "普吉岛" },
{ name: "晓华", bg: "#f97316", destination: "吉隆坡" },
];
const newMembers = [
{ name: "林", bg: "#3b82f6" },
{ name: "张", bg: "#8b5cf6" },
{ name: "陈", bg: "#f43f5e" },
{ name: "周", bg: "#10b981" },
{ name: "吴", bg: "#f59e0b" },
{ name: "杨", bg: "#ec4899" },
{ name: "赵", bg: "#06b6d4" },
{ name: "黄", bg: "#ef4444" },
{ name: "孙", bg: "#84cc16" },
{ name: "马", bg: "#7c3aed" },
{ name: "朱", bg: "#0d9488" },
{ name: "胡", bg: "#ea580c" },
{ name: "郭", bg: "#6366f1" },
{ name: "何", bg: "#d946ef" },
{ name: "高", bg: "#14b8a6" },
{ name: "梁", bg: "#f97316" },
{ name: "郑", bg: "#a855f7" },
{ name: "罗", bg: "#22c55e" },
{ name: "谢", bg: "#e11d48" },
{ name: "韩", bg: "#0ea5e9" },
];
const hotTopics = [
{ title: "清迈最佳共享办公空间推荐 Top 10", views: 2340 },
{ title: "2026年数字游民签证政策汇总", views: 1890 },
{ title: "远程工作者的跨国税务规划指南", views: 1560 },
{ title: "东南亚 vs 拉美:哪个更适合游民?", views: 1230 },
{ title: "新手游民必备工具与装备清单", views: 980 },
];
export default function Sidebar() {
return (
<aside className="w-[320px] shrink-0 hidden lg:block">
<div className="sticky top-20 space-y-4 sidebar-scroll max-h-[calc(100vh-100px)] overflow-y-auto pr-1">
{/* Insurance / Visa Promo */}
<div className="rounded-2xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">🛡</span>
<div>
<h3 className="font-bold text-gray-900 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5">
</p>
</div>
</div>
<p className="text-xs text-emerald-700 bg-emerald-100/50 rounded-lg px-3 py-2 mb-3">
195+ ¥15/
</p>
<button className="w-full text-center text-sm font-semibold text-emerald-700 hover:text-emerald-800 bg-white border border-emerald-200 rounded-full py-2 transition-colors hover:bg-emerald-50">
</button>
<p className="text-[10px] text-gray-400 text-center mt-2">广</p>
</div>
{/* Upcoming Meetups */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5">
🍹
</h3>
<span className="text-[11px] text-gray-400">8/</span>
</div>
<div className="space-y-3">
{meetups.map((m) => (
<div
key={m.city + m.date}
className="flex items-center justify-between py-2 border-b border-gray-50 last:border-0 last:pb-0"
>
<div className="flex items-center gap-2.5">
<span className="text-lg">{m.emoji}</span>
<div>
<p className="text-sm font-medium text-gray-800">
{m.city}
</p>
<p className="text-[11px] text-gray-400">{m.date}</p>
</div>
</div>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
{m.attendees}
</span>
</div>
))}
</div>
<button className="w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
</button>
</div>
{/* Traveling Now */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5">
🛩
</h3>
<span className="text-[11px] text-gray-400">
{travelingNow.length}
</span>
</div>
<div className="flex flex-wrap gap-2">
{travelingNow.map((m) => (
<div key={m.name} className="group relative">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-xs text-white font-medium shadow-sm cursor-pointer hover:scale-110 transition-transform avatar-online"
style={{ backgroundColor: m.bg }}
title={`${m.name} · ${m.destination}`}
>
{m.name.charAt(0)}
</div>
</div>
))}
</div>
</div>
{/* New Members */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5">
👋
</h3>
<span className="text-[11px] text-gray-400">86/</span>
</div>
<div className="flex flex-wrap gap-2">
{newMembers.map((m) => (
<div
key={m.name}
className="w-9 h-9 rounded-full flex items-center justify-center text-xs text-white font-medium shadow-sm cursor-pointer hover:scale-110 transition-transform"
style={{ backgroundColor: m.bg }}
>
{m.name}
</div>
))}
</div>
</div>
{/* Hot Topics */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-sm p-5">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5 mb-4">
🔥
</h3>
<div className="space-y-3">
{hotTopics.map((t, i) => (
<a
key={t.title}
href="#"
className="flex items-start gap-2.5 group"
>
<span className="text-xs font-bold text-gray-300 mt-0.5 w-4 shrink-0">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 group-hover:text-gray-900 transition-colors line-clamp-1 leading-snug">
{t.title}
</p>
<p className="text-[11px] text-gray-400 mt-0.5">
{t.views.toLocaleString()}
</p>
</div>
</a>
))}
</div>
<button className="w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
</button>
</div>
{/* Chat CTA */}
<div className="rounded-2xl bg-gradient-to-br from-violet-50 to-purple-50 border border-violet-100 p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">💬</span>
<div>
<h3 className="font-bold text-gray-900 text-sm">
</h3>
<p className="text-xs text-gray-500 mt-0.5">
3,200+
</p>
</div>
</div>
<button className="w-full text-center text-sm font-semibold text-violet-700 hover:text-violet-800 bg-white border border-violet-200 rounded-full py-2 transition-colors hover:bg-violet-50">
</button>
</div>
{/* Visa Service */}
<div className="rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-100 p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">🏛</span>
<div>
<h3 className="font-bold text-gray-900 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5">
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
<span className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5">
🇹🇭
</span>
<span className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5">
🇵🇹
</span>
<span className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5">
🇪🇸 西
</span>
<span className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5">
🇮🇩
</span>
</div>
<button className="w-full text-center text-sm font-semibold text-amber-700 hover:text-amber-800 bg-white border border-amber-200 rounded-full py-2 transition-colors hover:bg-amber-50">
</button>
<p className="text-[10px] text-gray-400 text-center mt-2">广</p>
</div>
</div>
</aside>
);
}

384
app/data/cities.ts Normal file
View File

@@ -0,0 +1,384 @@
export interface City {
id: number;
name: string;
country: string;
countryEmoji: string;
overallScore: number;
costPerMonth: number;
internetSpeed: number;
safety: string;
liked: string;
temperature: number;
humidity: number;
nomadsNow: number;
description: string;
gradientFrom: string;
gradientTo: string;
icon: string;
tags: string[];
}
export const cities: City[] = [
{
id: 1,
name: "大理",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.5,
costPerMonth: 3000,
internetSpeed: 50,
safety: "很好",
liked: "极好",
temperature: 16,
humidity: 60,
nomadsNow: 520,
description: "苍山洱海,文艺气息浓厚的慢生活之城",
gradientFrom: "#2563eb",
gradientTo: "#0891b2",
icon: "🏯",
tags: ["热门", "便宜", "宜居", "山城", "文化", "友好社区"],
},
{
id: 2,
name: "成都",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.4,
costPerMonth: 4500,
internetSpeed: 80,
safety: "很好",
liked: "极好",
temperature: 17,
humidity: 75,
nomadsNow: 680,
description: "天府之国,美食与慢节奏的完美融合",
gradientFrom: "#059669",
gradientTo: "#65a30d",
icon: "🐼",
tags: ["热门", "美食", "都市", "新一线", "文化", "友好社区"],
},
{
id: 3,
name: "深圳",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.1,
costPerMonth: 8000,
internetSpeed: 120,
safety: "极好",
liked: "很好",
temperature: 23,
humidity: 70,
nomadsNow: 850,
description: "科技创新之城,创业者的乐园",
gradientFrom: "#1d4ed8",
gradientTo: "#0284c7",
icon: "🌃",
tags: ["热门", "高速网络", "都市", "一线城市", "安全"],
},
{
id: 4,
name: "上海",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.0,
costPerMonth: 10000,
internetSpeed: 110,
safety: "极好",
liked: "好",
temperature: 17,
humidity: 65,
nomadsNow: 1200,
description: "国际大都市,东西方文化交汇的中心",
gradientFrom: "#b45309",
gradientTo: "#78716c",
icon: "🏙️",
tags: ["热门", "高速网络", "都市", "一线城市", "文化"],
},
{
id: 5,
name: "杭州",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.3,
costPerMonth: 6000,
internetSpeed: 100,
safety: "极好",
liked: "很好",
temperature: 17,
humidity: 70,
nomadsNow: 560,
description: "西湖之畔,互联网重镇与诗画江南",
gradientFrom: "#047857",
gradientTo: "#0d9488",
icon: "⛲",
tags: ["热门", "高速网络", "都市", "新一线", "宜居", "文化"],
},
{
id: 6,
name: "厦门",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.1,
costPerMonth: 5500,
internetSpeed: 70,
safety: "很好",
liked: "很好",
temperature: 21,
humidity: 75,
nomadsNow: 320,
description: "海滨花园城市,鼓浪屿的浪漫与闽南风情",
gradientFrom: "#ea580c",
gradientTo: "#0284c7",
icon: "🌊",
tags: ["海滨", "温暖", "美食", "宜居", "文化"],
},
{
id: 7,
name: "北京",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 9000,
internetSpeed: 100,
safety: "极好",
liked: "好",
temperature: 14,
humidity: 50,
nomadsNow: 780,
description: "千年古都,政治文化中心的深厚底蕴",
gradientFrom: "#dc2626",
gradientTo: "#b91c1c",
icon: "🏛️",
tags: ["都市", "一线城市", "文化", "高速网络", "历史名城"],
},
{
id: 8,
name: "广州",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.9,
costPerMonth: 6500,
internetSpeed: 90,
safety: "很好",
liked: "好",
temperature: 23,
humidity: 80,
nomadsNow: 480,
description: "食在广州,千年商都的烟火气",
gradientFrom: "#f97316",
gradientTo: "#ef4444",
icon: "🌺",
tags: ["热门", "美食", "都市", "一线城市", "温暖"],
},
{
id: 9,
name: "重庆",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.7,
costPerMonth: 4000,
internetSpeed: 75,
safety: "很好",
liked: "好",
temperature: 18,
humidity: 80,
nomadsNow: 350,
description: "8D魔幻山城火锅与夜景的不夜城",
gradientFrom: "#ef4444",
gradientTo: "#c2410c",
icon: "🌶️",
tags: ["便宜", "美食", "山城", "都市", "文化"],
},
{
id: 10,
name: "昆明",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.2,
costPerMonth: 3500,
internetSpeed: 55,
safety: "好",
liked: "很好",
temperature: 16,
humidity: 55,
nomadsNow: 380,
description: "四季如春的春城,通往东南亚的门户",
gradientFrom: "#d946ef",
gradientTo: "#059669",
icon: "🌸",
tags: ["便宜", "温暖", "宜居", "友好社区", "户外运动"],
},
{
id: 11,
name: "三亚",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 5000,
internetSpeed: 60,
safety: "好",
liked: "好",
temperature: 26,
humidity: 85,
nomadsNow: 250,
description: "中国的热带天堂,椰风海韵的度假胜地",
gradientFrom: "#0ea5e9",
gradientTo: "#06b6d4",
icon: "🏖️",
tags: ["海滨", "温暖", "户外运动"],
},
{
id: 12,
name: "丽江",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.0,
costPerMonth: 3200,
internetSpeed: 45,
safety: "好",
liked: "好",
temperature: 14,
humidity: 50,
nomadsNow: 280,
description: "玉龙雪山下的纳西古城,灵魂栖息地",
gradientFrom: "#6366f1",
gradientTo: "#7c3aed",
icon: "🏔️",
tags: ["便宜", "文化", "山城", "宜居", "历史名城"],
},
{
id: 13,
name: "西安",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 4200,
internetSpeed: 70,
safety: "很好",
liked: "好",
temperature: 15,
humidity: 55,
nomadsNow: 230,
description: "十三朝古都,历史与美食交织的城市",
gradientFrom: "#78716c",
gradientTo: "#a16207",
icon: "🗿",
tags: ["便宜", "美食", "文化", "历史名城"],
},
{
id: 14,
name: "南京",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.9,
costPerMonth: 5500,
internetSpeed: 85,
safety: "很好",
liked: "好",
temperature: 16,
humidity: 70,
nomadsNow: 400,
description: "六朝古都,紫金山下的文化名城",
gradientFrom: "#7c3aed",
gradientTo: "#059669",
icon: "🍃",
tags: ["文化", "都市", "新一线", "历史名城"],
},
{
id: 15,
name: "长沙",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.6,
costPerMonth: 4500,
internetSpeed: 75,
safety: "好",
liked: "好",
temperature: 17,
humidity: 75,
nomadsNow: 290,
description: "娱乐之都,夜生活与美食的不眠城",
gradientFrom: "#f59e0b",
gradientTo: "#16a34a",
icon: "🎪",
tags: ["美食", "都市", "新一线", "友好社区"],
},
{
id: 16,
name: "青岛",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.7,
costPerMonth: 4800,
internetSpeed: 65,
safety: "很好",
liked: "好",
temperature: 13,
humidity: 65,
nomadsNow: 220,
description: "红瓦绿树碧海蓝天,啤酒飘香的海滨城市",
gradientFrom: "#2563eb",
gradientTo: "#ca8a04",
icon: "🍺",
tags: ["海滨", "美食", "宜居", "户外运动"],
},
{
id: 17,
name: "苏州",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.0,
costPerMonth: 5000,
internetSpeed: 80,
safety: "极好",
liked: "很好",
temperature: 17,
humidity: 70,
nomadsNow: 260,
description: "上有天堂下有苏杭,古典园林与现代新城",
gradientFrom: "#0d9488",
gradientTo: "#64748b",
icon: "🏡",
tags: ["文化", "宜居", "安全", "历史名城"],
},
{
id: 18,
name: "珠海",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.9,
costPerMonth: 5200,
internetSpeed: 70,
safety: "很好",
liked: "好",
temperature: 23,
humidity: 75,
nomadsNow: 180,
description: "浪漫之城,毗邻澳门的海滨花园",
gradientFrom: "#0891b2",
gradientTo: "#6366f1",
icon: "🎡",
tags: ["海滨", "温暖", "宜居", "安全"],
},
];
export const allTags = [
"全部",
"热门",
"便宜",
"高速网络",
"温暖",
"安全",
"海滨",
"山城",
"都市",
"美食",
"文化",
"一线城市",
"新一线",
"宜居",
"历史名城",
"友好社区",
"户外运动",
];

304
app/dating/page.tsx Normal file
View File

@@ -0,0 +1,304 @@
"use client";
import { useState } from "react";
type TabType = "friends" | "dating";
interface Profile {
id: string;
name: string;
age: number;
location: string;
tags: string[];
bio: string;
gradient: string;
initial: string;
avatarColor: string;
photo: string;
}
interface MatchCard {
id: string;
name: string;
location: string;
timeAgo: string;
initial: string;
color: string;
photo: string;
}
const MOCK_PROFILES: Profile[] = [
{
id: "1",
name: "林晓雨",
age: 28,
location: "大理",
tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"],
bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。",
gradient: "from-rose-400 via-pink-400 to-fuchsia-400",
initial: "林",
avatarColor: "bg-rose-500",
photo: "https://i.pravatar.cc/150?img=1",
},
{
id: "2",
name: "陈浩然",
age: 32,
location: "成都",
tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"],
bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。",
gradient: "from-amber-400 via-orange-400 to-red-400",
initial: "陈",
avatarColor: "bg-amber-500",
photo: "https://i.pravatar.cc/150?img=3",
},
{
id: "3",
name: "王思琪",
age: 26,
location: "深圳",
tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"],
bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。",
gradient: "from-emerald-400 via-teal-400 to-cyan-400",
initial: "王",
avatarColor: "bg-emerald-500",
photo: "https://i.pravatar.cc/150?img=5",
},
{
id: "4",
name: "张明远",
age: 30,
location: "杭州",
tags: ["MBA学历", "创业中", "自由职业者", "徒步爱好者"],
bio: "正在杭州创业,做远程团队协作工具。周末喜欢去西湖徒步,偶尔品鉴龙井茶。",
gradient: "from-violet-400 via-purple-400 to-indigo-400",
initial: "张",
avatarColor: "bg-violet-500",
photo: "https://i.pravatar.cc/150?img=7",
},
{
id: "5",
name: "李雅婷",
age: 27,
location: "厦门",
tags: ["本科学历", "UI设计师", "数字游民", "摄影爱好者"],
bio: "在厦门远程做UI设计业余时间探索闽南美食。喜欢用相机记录生活寻找一起探店的朋友。",
gradient: "from-rose-500 via-red-400 to-orange-500",
initial: "李",
avatarColor: "bg-rose-500",
photo: "https://i.pravatar.cc/150?img=9",
},
{
id: "6",
name: "刘子轩",
age: 33,
location: "昆明",
tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"],
bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。",
gradient: "from-blue-400 via-indigo-400 to-violet-500",
initial: "刘",
avatarColor: "bg-blue-500",
photo: "https://i.pravatar.cc/150?img=11",
},
];
const MOCK_MATCHES: MatchCard[] = [
{ id: "m1", name: "周雨桐", location: "杭州", timeAgo: "2天前", initial: "周", color: "bg-pink-400", photo: "https://i.pravatar.cc/150?img=29" },
{ id: "m2", name: "吴俊杰", location: "成都", timeAgo: "1周前", initial: "吴", color: "bg-amber-400", photo: "https://i.pravatar.cc/150?img=34" },
{ id: "m3", name: "郑诗涵", location: "上海", timeAgo: "3周前", initial: "郑", color: "bg-emerald-400", photo: "https://i.pravatar.cc/150?img=36" },
{ id: "m4", name: "孙宇航", location: "深圳", timeAgo: "1月前", initial: "孙", color: "bg-violet-400", photo: "https://i.pravatar.cc/150?img=38" },
{ id: "m5", name: "赵梦琪", location: "厦门", timeAgo: "1年前", initial: "赵", color: "bg-rose-400", photo: "https://i.pravatar.cc/150?img=41" },
];
export default function DatingPage() {
const [activeTab, setActiveTab] = useState<TabType>("friends");
const [currentIndex, setCurrentIndex] = useState(0);
const currentProfile = MOCK_PROFILES[currentIndex];
const hasMoreProfiles = currentIndex < MOCK_PROFILES.length - 1;
const handleLike = () => {
if (hasMoreProfiles) {
setCurrentIndex((i) => i + 1);
} else {
setCurrentIndex(0);
}
};
const handleDislike = () => {
if (hasMoreProfiles) {
setCurrentIndex((i) => i + 1);
} else {
setCurrentIndex(0);
}
};
const badgeText =
activeTab === "dating"
? "你们都在寻找 🌹 约会"
: "你们都在寻找 🤝 交友";
return (
<div className="min-h-screen bg-[#fafafa]">
{/* Tabs */}
<div className="flex justify-center pt-6 pb-4 px-4">
<div className="inline-flex rounded-full bg-white p-1 shadow-sm border border-gray-100">
<button
onClick={() => setActiveTab("friends")}
className={`px-6 py-2.5 rounded-full text-sm font-medium transition-all duration-200 ${
activeTab === "friends"
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
</button>
<button
onClick={() => setActiveTab("dating")}
className={`px-6 py-2.5 rounded-full text-sm font-medium transition-all duration-200 ${
activeTab === "dating"
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
</button>
</div>
</div>
<div className="flex flex-col md:flex-row gap-6 max-w-7xl mx-auto px-4 pb-12">
{/* Left Sidebar - hidden on mobile */}
<aside className="hidden md:block w-72 flex-shrink-0">
<div className="sticky top-24">
<h2 className="text-lg font-semibold text-gray-900 mb-1">
💕
</h2>
<p className="text-sm text-gray-500 mb-4"></p>
<div className="space-y-3">
{MOCK_MATCHES.map((match) => (
<div
key={match.id}
className="flex items-center gap-3 p-3 rounded-xl bg-white border border-gray-100 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
>
<img
src={match.photo}
alt={match.name}
className="w-12 h-12 rounded-full object-cover shadow-sm flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{match.name}
</p>
<p className="text-xs text-gray-500 truncate">
{match.location}
</p>
</div>
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded-full flex-shrink-0">
{match.timeAgo}
</span>
</div>
))}
</div>
</div>
</aside>
{/* Center - Main Card */}
<main className="flex-1 min-w-0 flex flex-col items-center">
<div className="w-full max-w-md mx-auto">
<div
className={`relative rounded-2xl overflow-hidden bg-gradient-to-br ${currentProfile.gradient} p-8 pb-6 shadow-xl border border-white/20`}
>
{/* Top right: badge + report */}
<div className="absolute top-4 right-4 flex flex-col items-end gap-1">
<span className="px-3 py-1.5 rounded-full bg-white/90 text-gray-800 text-xs font-medium backdrop-blur-sm">
{badgeText}
</span>
<a
href="#"
className="text-[#ff4d4f] hover:text-red-600 text-sm font-medium"
>
</a>
</div>
{/* Avatar */}
<div className="flex justify-center mt-12 mb-6">
<img
src={currentProfile.photo}
alt={currentProfile.name}
className="w-28 h-28 rounded-full shadow-lg border-4 border-white/50 object-cover"
/>
</div>
{/* Name, age, location */}
<div className="text-center mb-4">
<h3 className="text-xl font-bold text-white drop-shadow-sm">
{currentProfile.name}{currentProfile.age}
</h3>
<p className="text-white/90 text-sm mt-1">
📍 {currentProfile.location}
</p>
</div>
{/* Tags */}
<div className="mb-4">
<p className="text-white/90 text-xs font-medium mb-2">
:
</p>
<div className="flex flex-wrap gap-2 justify-center">
{currentProfile.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 rounded-full bg-white/80 text-gray-800 text-xs font-medium"
>
{tag}
</span>
))}
</div>
</div>
{/* Bio */}
<p className="text-white/95 text-sm leading-relaxed text-center mb-8 px-2">
{currentProfile.bio}
</p>
{/* Action buttons */}
<div className="flex items-center justify-center gap-8">
<div className="flex flex-col items-center gap-2">
<button
onClick={handleDislike}
className="w-16 h-16 rounded-full bg-white shadow-lg flex items-center justify-center text-[#ff4d4f] hover:bg-red-50 hover:scale-105 active:scale-95 transition-all"
aria-label="不感兴趣"
>
<span className="text-2xl"></span>
</button>
<span className="text-xs text-white/80 font-medium">
</span>
</div>
<div className="flex flex-col items-center gap-2">
<button
onClick={handleLike}
className="w-16 h-16 rounded-full bg-[#22c55e] shadow-lg flex items-center justify-center text-white hover:bg-[#16a34a] hover:scale-105 active:scale-95 transition-all"
aria-label="喜欢"
>
<span className="text-2xl">💚</span>
</button>
<span className="text-xs text-white/80 font-medium"></span>
</div>
</div>
</div>
{/* Progress indicator */}
<p className="text-center text-sm text-gray-500 mt-4">
{currentIndex + 1} / {MOCK_PROFILES.length}
</p>
</div>
</main>
{/* Right spacer for desktop balance */}
<div className="hidden lg:block w-72 flex-shrink-0" />
</div>
</div>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

227
app/globals.css Normal file
View File

@@ -0,0 +1,227 @@
@import "tailwindcss";
:root {
--background: #fafafa;
--foreground: #111827;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans, system-ui), "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
html {
scroll-behavior: smooth;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
/* ==================== Items Grid (mobile-first) ==================== */
.items-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 180px;
gap: 8px;
padding: 8px;
}
@media (min-width: 640px) {
.items-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 260px;
gap: 20px;
padding: 16px 20px;
}
}
@media (min-width: 1024px) {
.items-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 280px;
gap: 30px;
padding: 20px 40px;
}
}
@media (min-width: 1440px) {
.items-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 35px;
}
}
/* Pin right-items to the LAST explicit column */
.items-grid .right-item:nth-child(1) {
grid-column: -2 / -1;
grid-row: 1;
}
.items-grid .right-item:nth-child(2) {
grid-column: -2 / -1;
grid-row: 2;
}
.items-grid .right-item:nth-child(3) {
grid-column: -2 / -1;
grid-row: 3;
}
/* ==================== City Card ==================== */
.city-card {
height: 100%;
border-radius: 15px;
position: relative;
overflow: hidden;
cursor: pointer;
color: white;
box-sizing: border-box;
min-width: 0;
}
.city-card:hover {
animation: pulse 0.6s ease;
}
/* ==================== Right Item ==================== */
.right-item {
height: 100%;
border-radius: 15px;
position: relative;
overflow: hidden;
width: 100%;
min-width: 0;
box-sizing: border-box;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.right-item:hover {
animation: pulse 0.6s ease;
}
.right-item-header {
width: 100%;
height: 38px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(90deg, #ff4d4f, #ff7a45);
color: #fff;
}
@media (min-width: 640px) {
.right-item-header {
height: 42px;
font-size: 15px;
}
}
/* ==================== Hover / Pulse Animation ==================== */
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.03);
}
100% {
transform: scale(1);
}
}
/* ==================== Hero Gradient Text ==================== */
@keyframes gradient-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
.hero-gradient-text {
background: linear-gradient(
135deg,
#f97316,
#ec4899,
#8b5cf6,
#3b82f6,
#f97316
);
background-size: 300% 300%;
animation: gradient-shift 6s ease infinite;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ==================== Filter Buttons ==================== */
.filter-btn {
transition: all 0.15s ease;
}
.filter-btn:hover {
background: #f3f4f6;
}
.filter-btn.active {
background: #111827;
color: white;
}
.filter-btn.active:hover {
background: #1f2937;
}
/* ==================== Community Cards ==================== */
.community-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.03);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.community-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
/* ==================== Utilities ==================== */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

280
app/join/page.tsx Normal file
View File

@@ -0,0 +1,280 @@
"use client";
import { useState } from "react";
type FormState = {
nickname: string;
gender: string;
city: string;
occupation: string;
education: string;
wechat: string;
phone: string;
intro: string;
};
const OCCUPATIONS = [
"自由职业者",
"远程工程师",
"设计师",
"内容创作者",
"创业者",
"其他",
] as const;
const EDUCATIONS = ["高中", "大专", "本科", "硕士", "博士"] as const;
const initialForm: FormState = {
nickname: "",
gender: "",
city: "",
occupation: "",
education: "",
wechat: "",
phone: "",
intro: "",
};
export default function JoinPage() {
const [form, setForm] = useState<FormState>(initialForm);
const updateField = (field: keyof FormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.nickname.trim()) {
alert("请填写昵称");
return;
}
if (!form.wechat.trim()) {
alert("请填写微信号");
return;
}
alert("提交成功!我们将通过微信联系你。");
setForm(initialForm);
};
return (
<div className="min-h-screen bg-[#fafafa]">
{/* Top Section: Gradient Hero */}
<section className="relative overflow-hidden bg-gradient-to-br from-orange-400 via-orange-500 to-rose-500 px-4 pt-12 pb-16 sm:pt-16 sm:pb-20">
<div className="mx-auto max-w-2xl text-center">
<h1 className="text-3xl font-bold tracking-tight text-white drop-shadow-sm sm:text-4xl md:text-5xl">
</h1>
<p className="mt-3 text-base text-white/95 sm:text-lg md:text-xl">
</p>
</div>
</section>
{/* Form Card */}
<section className="relative z-10 mx-auto -mt-8 max-w-lg px-4 pb-12 sm:-mt-10 sm:px-6">
<div className="rounded-2xl bg-white p-6 shadow-lg sm:p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{/* 昵称 */}
<div>
<label
htmlFor="nickname"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
<span className="text-[#ff4d4f]">*</span>
</label>
<input
id="nickname"
type="text"
value={form.nickname}
onChange={(e) => updateField("nickname", e.target.value)}
required
placeholder="请输入你的昵称"
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 placeholder-gray-400 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
/>
</div>
{/* 性别 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
</label>
<div className="flex gap-6">
{["男", "女", "其他"].map((g) => (
<label
key={g}
className="flex cursor-pointer items-center gap-2"
>
<input
type="radio"
name="gender"
value={g}
checked={form.gender === g}
onChange={(e) => updateField("gender", e.target.value)}
className="h-4 w-4 border-gray-300 text-[#ff4d4f] focus:ring-[#ff4d4f]/20"
/>
<span className="text-sm text-gray-700">{g}</span>
</label>
))}
</div>
</div>
{/* 当前所在城市 */}
<div>
<label
htmlFor="city"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<input
id="city"
type="text"
value={form.city}
onChange={(e) => updateField("city", e.target.value)}
placeholder="如:清迈、曼谷、大理"
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 placeholder-gray-400 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
/>
</div>
{/* 职业 */}
<div>
<label
htmlFor="occupation"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<select
id="occupation"
value={form.occupation}
onChange={(e) => updateField("occupation", e.target.value)}
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
>
<option value=""></option>
{OCCUPATIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</div>
{/* 学历 */}
<div>
<label
htmlFor="education"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<select
id="education"
value={form.education}
onChange={(e) => updateField("education", e.target.value)}
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
>
<option value=""></option>
{EDUCATIONS.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
</div>
{/* 微信号 */}
<div>
<label
htmlFor="wechat"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
<span className="text-[#ff4d4f]">*</span>
</label>
<input
id="wechat"
type="text"
value={form.wechat}
onChange={(e) => updateField("wechat", e.target.value)}
required
placeholder="用于联系你加入社群"
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 placeholder-gray-400 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
/>
</div>
{/* 手机号 */}
<div>
<label
htmlFor="phone"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<div className="flex rounded-xl border border-gray-200 bg-white transition-colors focus-within:border-[#ff4d4f] focus-within:ring-2 focus-within:ring-[#ff4d4f]/20">
<span className="flex items-center border-r border-gray-200 px-4 py-3 text-sm text-gray-500">
+86
</span>
<input
id="phone"
type="tel"
value={form.phone}
onChange={(e) => updateField("phone", e.target.value)}
placeholder="选填"
className="flex-1 rounded-r-xl border-0 px-4 py-3 text-gray-900 placeholder-gray-400 focus:ring-0"
/>
</div>
</div>
{/* 简单介绍自己 */}
<div>
<label
htmlFor="intro"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<textarea
id="intro"
rows={3}
value={form.intro}
onChange={(e) => updateField("intro", e.target.value)}
placeholder="简单介绍一下你的背景、兴趣或加入原因..."
className="w-full resize-none rounded-xl border border-gray-200 px-4 py-3 text-gray-900 placeholder-gray-400 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
/>
</div>
{/* Submit */}
<div className="pt-2">
<button
type="submit"
className="w-full rounded-xl bg-[#ff4d4f] px-4 py-3.5 font-semibold text-white transition-colors hover:bg-[#e64547] active:bg-[#cc3d3f]"
>
</button>
<p className="mt-3 text-center text-xs text-gray-500">
24
</p>
</div>
</form>
</div>
</section>
{/* Bottom: Trust Indicators */}
<section className="border-t border-gray-200 bg-white px-4 py-10">
<div className="mx-auto flex max-w-lg flex-wrap items-center justify-center gap-8 sm:gap-12">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-gray-900">2,860+</span>
<span className="text-sm text-gray-600"></span>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-gray-900">30+</span>
<span className="text-sm text-gray-600"></span>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-gray-900"> 8+</span>
<span className="text-sm text-gray-600"></span>
</div>
</div>
</section>
</div>
);
}

37
app/layout.tsx Normal file
View File

@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navbar from "./components/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "游牧中国 - 数字游民的理想目的地",
description:
"发现全球最适合远程工作和生活的城市,加入中国数字游民社区,探索远程办公新方式",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Navbar />
{children}
</body>
</html>
);
}

441
app/meetups/page.tsx Normal file
View File

@@ -0,0 +1,441 @@
"use client";
import { useState, useMemo } from "react";
/* ===== Types ===== */
interface Attendee {
name: string;
photo: string;
}
interface Meetup {
id: string;
city: string;
emoji: string;
date: string;
time: string;
venue: string;
address: string;
description: string;
rsvpCount: number;
gradientFrom: string;
gradientTo: string;
attendees: Attendee[];
isUpcoming: boolean;
}
/* ===== Mock Data ===== */
const allMeetups: Meetup[] = [
{
id: "1",
city: "大理",
emoji: "🏯",
date: "2026-03-15",
time: "14:00",
venue: "大理古城人民路咖啡馆",
address: "大理市大理古城人民路中段",
description: "苍山脚下,洱海边,与大理的慢生活游民们一起喝咖啡、晒太阳、聊人生。",
rsvpCount: 12,
gradientFrom: "#3b82f6",
gradientTo: "#4f46e5",
attendees: [
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=1" },
{ name: "张明", photo: "https://i.pravatar.cc/150?img=2" },
{ name: "陈静", photo: "https://i.pravatar.cc/150?img=3" },
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=4" },
{ name: "吴芳", photo: "https://i.pravatar.cc/150?img=5" },
],
isUpcoming: true,
},
{
id: "2",
city: "成都",
emoji: "🐼",
date: "2026-03-18",
time: "19:00",
venue: "方所书店",
address: "成都市锦江区中纱帽街8号远洋太古里",
description: "天府之国游民聚会,在文艺书店里品茶聊天,探讨成都的慢生活与远程工作可能性。",
rsvpCount: 9,
gradientFrom: "#22c55e",
gradientTo: "#10b981",
attendees: [
{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=6" },
{ name: "赵磊", photo: "https://i.pravatar.cc/150?img=7" },
{ name: "黄薇", photo: "https://i.pravatar.cc/150?img=8" },
{ name: "孙浩", photo: "https://i.pravatar.cc/150?img=9" },
],
isUpcoming: true,
},
{
id: "3",
city: "深圳",
emoji: "🌃",
date: "2026-03-20",
time: "19:30",
venue: "WeWork 卓越前海壹号",
address: "深圳市南山区前海深港合作区卓越前海壹号",
description: "深圳数字游民线下见面会,探讨创业、远程工作与湾区发展机遇。",
rsvpCount: 15,
gradientFrom: "#ff4d4f",
gradientTo: "#ff7a45",
attendees: [
{ name: "李娜", photo: "https://i.pravatar.cc/150?img=10" },
{ name: "王强", photo: "https://i.pravatar.cc/150?img=11" },
{ name: "刘洋", photo: "https://i.pravatar.cc/150?img=12" },
{ name: "陈思", photo: "https://i.pravatar.cc/150?img=13" },
{ name: "郑凯", photo: "https://i.pravatar.cc/150?img=14" },
{ name: "何琳", photo: "https://i.pravatar.cc/150?img=15" },
],
isUpcoming: true,
},
{
id: "4",
city: "上海",
emoji: "🏙️",
date: "2026-03-22",
time: "19:00",
venue: "裸心社 新天地",
address: "上海市黄浦区马当路388号复兴SOHO广场",
description: "魔都数字游民月度聚会,在时尚共享空间交流国际化远程工作与创业心得。",
rsvpCount: 18,
gradientFrom: "#ec4899",
gradientTo: "#ef4444",
attendees: [
{ name: "许婷", photo: "https://i.pravatar.cc/150?img=16" },
{ name: "马超", photo: "https://i.pravatar.cc/150?img=17" },
{ name: "罗敏", photo: "https://i.pravatar.cc/150?img=18" },
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=19" },
{ name: "张明", photo: "https://i.pravatar.cc/150?img=20" },
],
isUpcoming: true,
},
{
id: "5",
city: "杭州",
emoji: "⛲",
date: "2026-03-25",
time: "18:00",
venue: "梦想小镇·良仓咖啡",
address: "杭州市余杭区梦想小镇互联网村",
description: "西湖畔数字游民聚会,在创业氛围浓厚的梦想小镇交流产品与运营经验。",
rsvpCount: 11,
gradientFrom: "#06b6d4",
gradientTo: "#0d9488",
attendees: [
{ name: "陈静", photo: "https://i.pravatar.cc/150?img=21" },
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=22" },
{ name: "吴芳", photo: "https://i.pravatar.cc/150?img=23" },
{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=24" },
],
isUpcoming: true,
},
{
id: "6",
city: "厦门",
emoji: "🌊",
date: "2026-03-28",
time: "15:00",
venue: "鼓浪屿海角8号咖啡馆",
address: "厦门市思明区鼓浪屿龙头路",
description: "海岛慢生活聚会,在鼓浪屿的文艺咖啡馆里结识热爱海边远程工作的游民朋友。",
rsvpCount: 7,
gradientFrom: "#0ea5e9",
gradientTo: "#0284c7",
attendees: [
{ name: "赵磊", photo: "https://i.pravatar.cc/150?img=25" },
{ name: "黄薇", photo: "https://i.pravatar.cc/150?img=26" },
{ name: "孙浩", photo: "https://i.pravatar.cc/150?img=27" },
],
isUpcoming: true,
},
{
id: "7",
city: "北京",
emoji: "🏛️",
date: "2026-03-30",
time: "19:00",
venue: "氪空间 望京SOHO",
address: "北京市朝阳区望京街10号望京SOHO T3",
description: "首都数字游民聚会,在望京科技圈交流北漂远程工作与创业故事。",
rsvpCount: 14,
gradientFrom: "#6366f1",
gradientTo: "#4f46e5",
attendees: [
{ name: "李娜", photo: "https://i.pravatar.cc/150?img=28" },
{ name: "王强", photo: "https://i.pravatar.cc/150?img=29" },
{ name: "刘洋", photo: "https://i.pravatar.cc/150?img=30" },
{ name: "陈思", photo: "https://i.pravatar.cc/150?img=31" },
{ name: "郑凯", photo: "https://i.pravatar.cc/150?img=32" },
],
isUpcoming: true,
},
{
id: "8",
city: "昆明",
emoji: "🌸",
date: "2026-04-02",
time: "14:00",
venue: "翠湖边上咖啡馆",
address: "昆明市五华区翠湖东路",
description: "春城数字游民聚会,在四季如春的昆明享受慢节奏,交流旅居与远程工作心得。",
rsvpCount: 8,
gradientFrom: "#f472b6",
gradientTo: "#ec4899",
attendees: [
{ name: "何琳", photo: "https://i.pravatar.cc/150?img=33" },
{ name: "许婷", photo: "https://i.pravatar.cc/150?img=34" },
{ name: "马超", photo: "https://i.pravatar.cc/150?img=35" },
{ name: "罗敏", photo: "https://i.pravatar.cc/150?img=36" },
],
isUpcoming: true,
},
{
id: "9",
city: "广州",
emoji: "🌺",
date: "2026-04-05",
time: "19:00",
venue: "TIMETABLE 天德广场",
address: "广州市天河区临江大道395号天德广场",
description: "羊城数字游民聚会,在珠江新城的高端共享空间探讨粤港澳大湾区远程工作机遇。",
rsvpCount: 12,
gradientFrom: "#f59e0b",
gradientTo: "#ea580c",
attendees: [
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=37" },
{ name: "张明", photo: "https://i.pravatar.cc/150?img=38" },
{ name: "陈静", photo: "https://i.pravatar.cc/150?img=39" },
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=40" },
{ name: "吴芳", photo: "https://i.pravatar.cc/150?img=41" },
],
isUpcoming: true,
},
/* ===== 往期聚会 ===== */
{
id: "10",
city: "南京",
emoji: "🍃",
date: "2026-03-05",
time: "19:00",
venue: "先锋书店 五台山店",
address: "南京市鼓楼区广州路173号五台山体育馆地下",
description: "金陵数字游民聚会,在南京最具文艺气息的书店里交流古都文化与远程工作。",
rsvpCount: 6,
gradientFrom: "#84cc16",
gradientTo: "#65a30d",
attendees: [
{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=42" },
{ name: "赵磊", photo: "https://i.pravatar.cc/150?img=43" },
],
isUpcoming: false,
},
{
id: "11",
city: "重庆",
emoji: "🌶️",
date: "2026-03-02",
time: "18:30",
venue: "洪崖洞观景咖啡馆",
address: "重庆市渝中区嘉滨路88号洪崖洞",
description: "山城数字游民聚会在魔幻8D城市里结识热爱火锅与远程工作的游民朋友。",
rsvpCount: 10,
gradientFrom: "#dc2626",
gradientTo: "#b91c1c",
attendees: [
{ name: "黄薇", photo: "https://i.pravatar.cc/150?img=44" },
{ name: "孙浩", photo: "https://i.pravatar.cc/150?img=45" },
{ name: "李娜", photo: "https://i.pravatar.cc/150?img=46" },
{ name: "王强", photo: "https://i.pravatar.cc/150?img=47" },
],
isUpcoming: false,
},
{
id: "12",
city: "长沙",
emoji: "🎪",
date: "2026-02-28",
time: "19:00",
venue: "梅溪湖创新中心 星巴克",
address: "长沙市岳麓区梅溪湖国际新城",
description: "星城数字游民聚会,在网红城市长沙交流新媒体、直播与远程工作新业态。",
rsvpCount: 8,
gradientFrom: "#f97316",
gradientTo: "#ea580c",
attendees: [
{ name: "刘洋", photo: "https://i.pravatar.cc/150?img=48" },
{ name: "陈思", photo: "https://i.pravatar.cc/150?img=49" },
{ name: "郑凯", photo: "https://i.pravatar.cc/150?img=50" },
],
isUpcoming: false,
},
];
/* ===== Date formatting ===== */
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
function formatMeetupDate(dateStr: string, time: string): string {
const d = new Date(dateStr + "T" + time);
const weekday = weekdays[d.getDay()];
const year = d.getFullYear();
const month = d.getMonth() + 1;
const day = d.getDate();
const timeFormatted = time.slice(0, 5);
return `${weekday}, ${year}${month}${day}日, ${timeFormatted}`;
}
/* ===== Meetup Card Component ===== */
function MeetupCard({ meetup }: { meetup: Meetup }) {
const formattedDate = formatMeetupDate(meetup.date, meetup.time);
return (
<article className="group bg-white rounded-2xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] hover:shadow-[0_12px_28px_rgba(0,0,0,0.12),0_0_0_1px_rgba(0,0,0,0.05)] hover:-translate-y-2 transition-all duration-300 cursor-pointer flex flex-col h-full">
{/* Gradient header */}
<div
className="h-24 sm:h-28 flex flex-col justify-end p-4 text-white"
style={{
background: `linear-gradient(135deg, ${meetup.gradientFrom}, ${meetup.gradientTo})`,
}}
>
<p className="text-xs sm:text-sm text-white/90 font-medium">
{formattedDate}
</p>
</div>
{/* Content */}
<div className="flex-1 flex flex-col p-4 sm:p-5">
{/* City + RSVP badge */}
<div className="flex items-center justify-between gap-3 mb-2">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900">
{meetup.emoji} {meetup.city}
</h3>
<span className="shrink-0 bg-[#ff4d4f] text-white text-xs font-semibold px-2.5 py-1 rounded-full">
{meetup.rsvpCount}
</span>
</div>
{/* Venue */}
<p className="text-sm text-gray-600 font-medium mb-0.5">
@ {meetup.venue}
</p>
<p className="text-xs text-gray-500 mb-3">{meetup.address}</p>
{/* Description */}
<p className="text-sm text-gray-600 line-clamp-2 flex-1 mb-2">
{meetup.description}
</p>
<button className="text-sm font-medium text-[#ff4d4f] hover:text-[#ff7a45] transition-colors text-left w-fit">
</button>
{/* Attendee avatars */}
<div className="flex items-center gap-1 mt-4 pt-4 border-t border-gray-100">
<div className="flex -space-x-2">
{meetup.attendees.map((a) => (
<img
key={a.name}
src={a.photo}
alt={a.name}
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full border-2 border-white shadow-sm object-cover"
title={a.name}
/>
))}
</div>
<span className="text-xs text-gray-400 ml-1">
</span>
</div>
</div>
</article>
);
}
/* ===== Page Component ===== */
export default function MeetupsPage() {
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
const upcomingMeetups = useMemo(
() => allMeetups.filter((m) => m.isUpcoming),
[]
);
const previousMeetups = useMemo(
() => allMeetups.filter((m) => !m.isUpcoming),
[]
);
const displayedMeetups =
activeTab === "upcoming" ? upcomingMeetups : previousMeetups;
const totalCount =
activeTab === "upcoming"
? upcomingMeetups.length
: previousMeetups.length;
return (
<div className="min-h-screen bg-[#fafafa]">
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-10 py-8 sm:py-12">
{/* Page header */}
<header className="mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 flex items-center gap-2">
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
线
</h1>
<p className="text-gray-500 mt-1 text-sm sm:text-base">
</p>
</header>
{/* Tabs */}
<div className="flex gap-1 p-1 bg-white rounded-xl shadow-sm border border-gray-100 mb-8 inline-flex">
<button
onClick={() => setActiveTab("upcoming")}
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "upcoming"
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => setActiveTab("previous")}
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "previous"
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
}`}
>
</button>
</div>
{/* Subtitle with count */}
<p className="text-gray-500 text-sm mb-6">
{activeTab === "upcoming" ? "共" : "共"}
<span className="font-semibold text-gray-700 mx-1">
{totalCount}
</span>
{activeTab === "upcoming" ? "场即将举办的聚会" : "场往期聚会"}
</p>
{/* Meetup cards grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
{displayedMeetups.map((meetup) => (
<MeetupCard key={meetup.id} meetup={meetup} />
))}
</div>
{/* Empty state */}
{displayedMeetups.length === 0 && (
<div className="text-center py-16 sm:py-24">
<span className="text-5xl sm:text-6xl mb-4 block">🍹</span>
<p className="text-gray-500 text-base sm:text-lg">
{activeTab === "upcoming"
? "暂无即将举办的聚会"
: "暂无往期聚会记录"}
</p>
</div>
)}
</main>
</div>
);
}

527
app/page.tsx Normal file
View File

@@ -0,0 +1,527 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import HeroSection from "./components/HeroSection";
import FilterBar from "./components/FilterBar";
import CityCard from "./components/CityCard";
import { cities } from "./data/cities";
/* ===== Avatar photos ===== */
const memberPhotos = [
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=5" },
{ name: "张浩", photo: "https://i.pravatar.cc/150?img=3" },
{ name: "陈悦", photo: "https://i.pravatar.cc/150?img=9" },
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=7" },
{ name: "吴婷", photo: "https://i.pravatar.cc/150?img=1" },
{ name: "杨帅", photo: "https://i.pravatar.cc/150?img=12" },
{ name: "赵琪", photo: "https://i.pravatar.cc/150?img=16" },
{ name: "黄磊", photo: "https://i.pravatar.cc/150?img=11" },
{ name: "孙莉", photo: "https://i.pravatar.cc/150?img=20" },
];
const travelingNow = [
{ name: "小明", photo: "https://i.pravatar.cc/150?img=52", dest: "大理" },
{ name: "雨晴", photo: "https://i.pravatar.cc/150?img=44", dest: "成都" },
{ name: "阿杰", photo: "https://i.pravatar.cc/150?img=53", dest: "深圳" },
{ name: "小燕", photo: "https://i.pravatar.cc/150?img=47", dest: "杭州" },
{ name: "大伟", photo: "https://i.pravatar.cc/150?img=55", dest: "厦门" },
{ name: "佳琪", photo: "https://i.pravatar.cc/150?img=48", dest: "昆明" },
{ name: "志远", photo: "https://i.pravatar.cc/150?img=57", dest: "丽江" },
{ name: "心怡", photo: "https://i.pravatar.cc/150?img=49", dest: "三亚" },
{ name: "凯文", photo: "https://i.pravatar.cc/150?img=59", dest: "上海" },
{ name: "子豪", photo: "https://i.pravatar.cc/150?img=60", dest: "北京" },
{ name: "浩然", photo: "https://i.pravatar.cc/150?img=61", dest: "广州" },
{ name: "雅琪", photo: "https://i.pravatar.cc/150?img=21", dest: "南京" },
{ name: "天翔", photo: "https://i.pravatar.cc/150?img=67", dest: "长沙" },
{ name: "美玲", photo: "https://i.pravatar.cc/150?img=23", dest: "苏州" },
{ name: "俊杰", photo: "https://i.pravatar.cc/150?img=68", dest: "青岛" },
{ name: "晓华", photo: "https://i.pravatar.cc/150?img=26", dest: "珠海" },
];
const meetups = [
{ city: "大理", emoji: "🏯", date: "3月9日", count: 12 },
{ city: "成都", emoji: "🐼", date: "3月12日", count: 8 },
{ city: "深圳", emoji: "🌃", date: "3月14日", count: 15 },
{ city: "厦门", emoji: "🌊", date: "3月16日", count: 6 },
{ city: "杭州", emoji: "⛲", date: "3月20日", count: 23 },
];
const hotTopics = [
{ title: "大理最佳共享办公空间推荐 Top 10", views: 2340 },
{ title: "2026年数字游民友好城市排名", views: 1890 },
{ title: "远程工作者的税务规划指南", views: 1560 },
{ title: "成都 vs 大理:哪个更适合游民?", views: 1230 },
{ title: "新手游民必备工具与装备清单", views: 980 },
];
function safetyOrder(s: string): number {
const o: Record<string, number> = { 极好: 5, 很好: 4, : 3, 一般: 2, : 1 };
return o[s] || 0;
}
export default function Home() {
const [activeFilter, setActiveFilter] = useState("全部");
const [sortBy, setSortBy] = useState("score");
const filteredCities = useMemo(() => {
let result = [...cities];
if (activeFilter !== "全部") {
result = result.filter((c) => c.tags.includes(activeFilter));
}
result.sort((a, b) => {
switch (sortBy) {
case "cost-asc":
return a.costPerMonth - b.costPerMonth;
case "cost-desc":
return b.costPerMonth - a.costPerMonth;
case "internet":
return b.internetSpeed - a.internetSpeed;
case "safety":
return safetyOrder(b.safety) - safetyOrder(a.safety);
case "nomads":
return b.nomadsNow - a.nomadsNow;
case "temperature":
return b.temperature - a.temperature;
default:
return b.overallScore - a.overallScore;
}
});
return result;
}, [activeFilter, sortBy]);
return (
<div className="min-h-screen bg-[#fafafa]">
<HeroSection />
{/* Stats Strip */}
<div className="border-y border-gray-100 bg-white">
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-4 flex flex-wrap items-center justify-center gap-6 sm:gap-12 text-center">
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900">18</span>
<p className="text-xs text-gray-400 mt-0.5"></p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900">5,000+</span>
<p className="text-xs text-gray-400 mt-0.5"></p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900">8+</span>
<p className="text-xs text-gray-400 mt-0.5">/</p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900">¥3,000</span>
<p className="text-xs text-gray-400 mt-0.5">/</p>
</div>
</div>
</div>
{/* Filter Bar */}
<div className="px-3 sm:px-5 md:px-10 pt-5">
<FilterBar
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
sortBy={sortBy}
onSortChange={setSortBy}
resultCount={filteredCities.length}
/>
</div>
{/* Mixed Grid */}
<div className="items-grid">
<Link href="/join" className="right-item block">
<div className="right-item-header"></div>
<div className="flex flex-col items-center justify-center text-center p-2 sm:p-5 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
<span className="text-2xl sm:text-5xl mb-1 sm:mb-3">💬</span>
<p className="text-[10px] sm:text-sm text-gray-600 mb-0.5">
</p>
<p className="text-[9px] sm:text-xs text-gray-400 mb-1.5 sm:mb-4">
3,200+ 线
</p>
<span className="bg-[#ff4d4f] text-white px-2.5 sm:px-6 py-1 sm:py-2 rounded-lg text-[10px] sm:text-sm font-semibold whitespace-nowrap">
</span>
</div>
</Link>
<Link href="/dating" className="right-item block">
<div className="right-item-header"></div>
<div className="p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
{memberPhotos.map((m) => (
<img
key={m.name}
src={m.photo}
alt={m.name}
title={m.name}
className="w-6 h-6 sm:w-10 sm:h-10 rounded-full border-2 border-white shadow-sm object-cover"
/>
))}
<div className="w-full mt-0.5 text-[9px] sm:text-xs text-gray-400 text-center">
86
</div>
</div>
</Link>
<Link href="/meetups" className="right-item block">
<div className="right-item-header"></div>
<div className="p-1.5 sm:p-4 flex flex-col gap-0.5 sm:gap-2.5 text-[10px] sm:text-sm text-gray-700 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
<div className="flex items-center gap-1 bg-[#f5f6f7] rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-[#333]">
🗓 315
</div>
<div className="flex items-center gap-1 bg-[#f5f6f7] rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-[#333]">
📍
</div>
<div className="flex items-center gap-1 bg-[#f5f6f7] rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-[#333]">
👥 12
</div>
<div className="flex -space-x-1 mt-auto">
{memberPhotos.slice(0, 5).map((m) => (
<img
key={m.name}
src={m.photo}
alt=""
className="w-4 h-4 sm:w-7 sm:h-7 rounded-full border-2 border-white shadow-sm object-cover"
/>
))}
</div>
</div>
</Link>
{filteredCities.map((city, i) => (
<CityCard key={city.id} city={city} rank={i + 1} />
))}
</div>
{/* Empty State */}
{filteredCities.length === 0 && (
<div className="text-center py-16 sm:py-20">
<span className="text-4xl sm:text-5xl mb-4 block">🏜</span>
<p className="text-gray-500 text-base sm:text-lg">
</p>
<button
onClick={() => setActiveFilter("全部")}
className="mt-4 text-sm text-gray-700 bg-gray-200 px-5 py-2 rounded-full hover:bg-gray-300 transition-colors"
>
</button>
</div>
)}
{/* Popular Routes */}
<section className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 pt-8 sm:pt-12 pb-4">
<h2 className="text-lg sm:text-xl font-bold text-gray-900 mb-5 sm:mb-6 flex items-center gap-2">
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
线
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{[
{
title: "云南慢生活线",
cities: ["昆明", "大理", "丽江"],
emojis: ["🌸", "🏯", "🏔️"],
duration: "3-6个月",
cost: "¥3,200/月起",
desc: "从春城昆明出发,沿着滇西北一路感受最纯粹的慢生活",
gradient: "from-purple-500 to-pink-500",
},
{
title: "科技创业线",
cities: ["深圳", "杭州", "上海"],
emojis: ["🌃", "⛲", "🏙️"],
duration: "2-4个月",
cost: "¥7,000/月起",
desc: "穿越中国三大科技中心,连接最前沿的创业生态",
gradient: "from-blue-500 to-cyan-500",
},
{
title: "美食文化线",
cities: ["成都", "重庆", "长沙"],
emojis: ["🐼", "🌶️", "🎪"],
duration: "2-3个月",
cost: "¥4,000/月起",
desc: "巴蜀到湘楚,用味蕾丈量中国最火辣的城市",
gradient: "from-orange-500 to-red-500",
},
].map((route) => (
<div
key={route.title}
className="community-card group cursor-pointer"
>
<div className={`h-2 bg-gradient-to-r ${route.gradient}`} />
<div className="p-4 sm:p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-900 text-sm">
{route.title}
</h3>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
{route.duration}
</span>
</div>
<div className="flex items-center gap-1 mb-3">
{route.cities.map((city, i) => (
<span key={city} className="flex items-center gap-0.5 text-sm">
<span>{route.emojis[i]}</span>
<span className="font-medium text-gray-700">{city}</span>
{i < route.cities.length - 1 && (
<span className="text-gray-300 mx-1"></span>
)}
</span>
))}
</div>
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
{route.desc}
</p>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-900">
{route.cost}
</span>
<span className="text-xs text-[#ff4d4f] font-medium group-hover:translate-x-1 transition-transform">
</span>
</div>
</div>
</div>
))}
</div>
</section>
{/* Community Section */}
<section id="community" className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 py-8 sm:py-12">
<h2 className="text-lg sm:text-xl font-bold text-gray-900 mb-5 sm:mb-6 flex items-center gap-2">
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{/* 近期聚会 */}
<div className="community-card">
<div className="p-4 sm:p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5">
🍹
</h3>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
8/
</span>
</div>
<div className="space-y-2.5">
{meetups.map((m) => (
<div key={m.city} className="flex items-center justify-between py-1.5">
<div className="flex items-center gap-2">
<span className="text-base">{m.emoji}</span>
<div>
<p className="text-sm font-medium text-gray-800">{m.city}</p>
<p className="text-[11px] text-gray-400">{m.date}</p>
</div>
</div>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
{m.count}
</span>
</div>
))}
</div>
<Link
href="/meetups"
className="block w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 transition-colors"
>
</Link>
</div>
</div>
{/* 正在旅行 */}
<div className="community-card">
<div className="p-4 sm:p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5">
🛩
</h3>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
{travelingNow.length}
</span>
</div>
<div className="flex flex-wrap gap-2">
{travelingNow.map((m) => (
<img
key={m.name}
src={m.photo}
alt={m.name}
title={`${m.name} · ${m.dest}`}
className="w-9 h-9 rounded-full shadow-sm cursor-pointer hover:scale-110 transition-transform object-cover"
/>
))}
</div>
</div>
</div>
{/* 热门讨论 */}
<div className="community-card">
<div className="p-4 sm:p-5">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5 mb-4">
🔥
</h3>
<div className="space-y-3">
{hotTopics.map((t, i) => (
<a key={t.title} href="#" className="flex items-start gap-2.5 group">
<span className={`text-xs font-bold mt-0.5 w-4 shrink-0 ${i < 3 ? "text-[#ff4d4f]" : "text-gray-300"}`}>
{i + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 group-hover:text-gray-900 transition-colors line-clamp-1 leading-snug">
{t.title}
</p>
<p className="text-[11px] text-gray-400 mt-0.5">
{t.views.toLocaleString()}
</p>
</div>
</a>
))}
</div>
<button className="w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 transition-colors">
</button>
</div>
</div>
{/* 全球旅行保险 */}
<div className="community-card bg-gradient-to-br from-emerald-50 to-teal-50">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">🛡</span>
<div>
<h3 className="font-bold text-gray-900 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
</div>
<p className="text-xs text-emerald-700 bg-emerald-100/50 rounded-lg px-3 py-2 mb-3">
+ ¥15/
</p>
<button className="w-full text-center text-sm font-semibold text-emerald-700 hover:text-emerald-800 bg-white/80 hover:bg-white border border-emerald-200 rounded-full py-2 transition-colors">
</button>
</div>
</div>
{/* 社区聊天 */}
<div className="community-card bg-gradient-to-br from-violet-50 to-purple-50">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">💬</span>
<div>
<h3 className="font-bold text-gray-900 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5"> 3,200+ </p>
</div>
</div>
<div className="flex items-center gap-2 mb-4">
<div className="flex -space-x-2">
{memberPhotos.slice(0, 6).map((m) => (
<img
key={m.name}
src={m.photo}
alt=""
className="w-7 h-7 rounded-full border-2 border-white shadow-sm object-cover"
/>
))}
</div>
<span className="text-xs text-gray-400">线</span>
</div>
<button className="w-full text-center text-sm font-semibold text-violet-700 hover:text-violet-800 bg-white/80 hover:bg-white border border-violet-200 rounded-full py-2 transition-colors">
</button>
</div>
</div>
{/* 签证/落户 */}
<div className="community-card bg-gradient-to-br from-amber-50 to-orange-50">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">🏛</span>
<div>
<h3 className="font-bold text-gray-900 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5">
· ·
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{["🏯 大理", "🐼 成都", "🌃 深圳", "⛲ 杭州"].map((tag) => (
<span
key={tag}
className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5"
>
{tag}
</span>
))}
</div>
<button className="w-full text-center text-sm font-semibold text-amber-700 hover:text-amber-800 bg-white/80 hover:bg-white border border-amber-200 rounded-full py-2 transition-colors">
</button>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-gray-200 bg-white">
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-10 sm:py-12">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 mb-8">
<div>
<h4 className="font-bold text-gray-900 text-sm mb-3"></h4>
<ul className="space-y-2 text-sm text-gray-500">
<li><Link href="/join" className="hover:text-gray-700"></Link></li>
<li><Link href="/meetups" className="hover:text-gray-700">线</Link></li>
<li><Link href="/dating" className="hover:text-gray-700"></Link></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
</ul>
</div>
<div>
<h4 className="font-bold text-gray-900 text-sm mb-3"></h4>
<ul className="space-y-2 text-sm text-gray-500">
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
</ul>
</div>
<div>
<h4 className="font-bold text-gray-900 text-sm mb-3"></h4>
<ul className="space-y-2 text-sm text-gray-500">
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
</ul>
</div>
<div>
<h4 className="font-bold text-gray-900 text-sm mb-3"></h4>
<ul className="space-y-2 text-sm text-gray-500">
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700">使</a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
</ul>
</div>
</div>
<div className="pt-8 border-t border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-xl">🌏</span>
<span className="text-sm font-bold text-gray-900"></span>
</div>
<p className="text-xs text-gray-400">
© 2024-2026 NomadCNA. All rights reserved.
</p>
<div className="flex items-center gap-4 text-xs text-gray-400">
<span>CNY ¥</span>
<span>°C</span>
</div>
</div>
</div>
</footer>
</div>
);
}