'城市详情'

This commit is contained in:
eric
2026-03-08 23:20:16 -05:00
parent e36d533a31
commit 41487dc22e
53 changed files with 3417 additions and 1206 deletions

View File

@@ -0,0 +1,37 @@
import Footer from "../../components/Footer";
export const metadata = {
title: "关于我们 | 游牧中国",
description: "认识游牧中国背后的团队,了解我们的使命与愿景。",
};
export default function AboutPage() {
return (
<>
<main className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<section className="max-w-4xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-6 flex items-center gap-2">
<span className="w-1 h-10 bg-[#ff4d4f] rounded-full" />
关于我们
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 leading-relaxed mb-8">
游牧中国(NomadCNA)是中国数字游民社区,致力于连接全球华人远程工作者与旅居者。
我们相信地点自由、工作自由,让每个人都能在探索世界的同时创造价值。
</p>
<div className="prose prose-gray dark:prose-invert max-w-none">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mt-10 mb-4">我们的使命</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
打造中国最活跃的数字游民社区,提供城市攻略、线下聚会、远程工作资源,
帮助更多人开启游牧式工作与生活。
</p>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mt-10 mb-4">联系我们</h2>
<p className="text-gray-600 dark:text-gray-400">
如有合作或咨询,欢迎通过社群或邮件与我们联系。
</p>
</div>
</section>
</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,152 @@
"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 = () => {
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
};
const handleDislike = () => {
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
};
const badgeText = activeTab === "dating" ? "你们都在寻找 🌹 约会" : "你们都在寻找 🤝 交友";
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<div className="flex justify-center pt-6 pb-4 px-4">
<div className="inline-flex rounded-full bg-white dark:bg-gray-900 p-1 shadow-sm border border-gray-100 dark:border-gray-800">
<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 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
}`}
>
交友
</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 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
}`}
>
约会
</button>
</div>
</div>
<div className="flex flex-col md:flex-row gap-6 max-w-7xl mx-auto px-4 pb-12">
<aside className="hidden md:block w-72 flex-shrink-0">
<div className="sticky top-24">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">约会匹配 💕</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 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 dark:bg-gray-900 border border-gray-100 dark:border-gray-800 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 dark:text-gray-100 truncate">{match.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{match.location}</p>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 px-2 py-1 rounded-full flex-shrink-0">{match.timeAgo}</span>
</div>
))}
</div>
</div>
</aside>
<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`}>
<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>
<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>
<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>
<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>
<p className="text-white/95 text-sm leading-relaxed text-center mb-8 px-2">{currentProfile.bio}</p>
<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>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">{currentIndex + 1} / {MOCK_PROFILES.length}</p>
</div>
</main>
<div className="hidden lg:block w-72 flex-shrink-0" />
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import Footer from "../../components/Footer";
export const metadata = {
title: "招聘 | 游牧中国",
description: "加入游牧中国团队,远程工作,地点自由。",
};
export default function JobsPage() {
return (
<>
<main className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<section className="max-w-4xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-6 flex items-center gap-2">
<span className="w-1 h-10 bg-[#ff4d4f] rounded-full" />
招聘
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 leading-relaxed mb-8">
加入游牧中国团队,享受远程工作与地点自由。我们正在寻找热爱数字游民文化的伙伴,
一起打造更好的社区体验。
</p>
<div className="prose prose-gray dark:prose-invert max-w-none">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mt-10 mb-4">开放职位</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
我们目前暂无开放职位,但欢迎将简历发送至我们的邮箱,我们会将您纳入人才库,
有合适机会时第一时间联系。
</p>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mt-10 mb-4">工作方式</h2>
<p className="text-gray-600 dark:text-gray-400">
全员远程,异步协作,支持全球旅居。我们相信结果导向,而非坐班时长。
</p>
</div>
</section>
</main>
<Footer />
</>
);
}

506
app/[locale]/join/page.tsx Normal file
View File

@@ -0,0 +1,506 @@
"use client";
import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react";
import { Link } from "@/i18n/navigation";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
const genderOptions = ["男", "女"];
const singleOptions = ["单身", "恋爱中", "已婚"];
const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"];
const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i));
interface FormData {
nickname: string;
gender: string;
single: string;
profession: string;
intro: string;
wechat: string;
education: string;
graduationYear: string;
phone: string;
}
interface MediaInfo {
preview: string;
url: string;
type: "image" | "video";
}
export default function JoinPage() {
const [form, setForm] = useState<FormData>({
nickname: "",
gender: "",
single: "",
profession: "",
intro: "",
wechat: "",
education: "",
graduationYear: "",
phone: "",
});
const [media, setMedia] = useState<MediaInfo | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
const [payChannel, setPayChannel] = useState<PayChannel>("alipay");
useEffect(() => {
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
setSubmitted(true);
}
}, []);
usePayStatusPoll(() => setSubmitted(true));
useEffect(() => {
if (typeof window === "undefined") return;
const env = getPayEnv();
setPayEnv(env);
setPayChannel(getRecommendedChannel(env));
}, []);
const set = (key: keyof FormData, value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const res = await fetch("/api/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, media: media?.url }),
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "提交失败,请稍后重试");
}
const userId = data.user_id;
if (!userId) {
setSubmitted(true);
return;
}
setSubmitting(false);
setPayRedirecting(true);
const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
const env = getPayEnv();
const channel = mustUseWxPay(env) ? "wxpay" : payChannel;
const device = getDeviceFromEnv(env);
redirectToPay({
user_id: userId,
return_url: returnUrl,
channel,
device,
useJoinConfig: true,
});
return;
} catch (err) {
setError(err instanceof Error ? err.message : "网络错误,请重试");
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-gray-900 p-10 text-center shadow-lg border border-gray-100 dark:border-gray-800">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
✅
</div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">申请已提交</h2>
<p className="mt-3 text-slate-500 dark:text-slate-400">
我们会尽快审核你的信息
<br />
审核通过后将通过微信邀请你入群
</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
← 返回首页
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#f0f4f8] dark:bg-gray-950">
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">
<div className="overflow-hidden bg-white dark:bg-gray-900 shadow-sm sm:rounded-t-2xl">
<div className="relative h-40 overflow-hidden bg-gradient-to-br from-sky-400 via-cyan-500 to-teal-400 sm:h-52">
<div className="absolute inset-0 flex flex-col items-center justify-center text-white">
<div className="text-6xl sm:text-7xl">🌍</div>
<div className="mt-2 text-sm font-medium opacity-80">
游牧中国 · 数字游民社区
</div>
</div>
<div className="absolute -top-10 -left-10 h-40 w-40 rounded-full bg-white/10 blur-2xl" />
<div className="absolute -right-10 -bottom-10 h-48 w-48 rounded-full bg-white/10 blur-2xl" />
</div>
<div className="px-5 py-5 text-center sm:px-8 sm:py-7">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100 sm:text-2xl">
🌍 数字游民社区报名
</h1>
<p className="mt-3 whitespace-pre-line text-sm leading-relaxed text-slate-400 dark:text-slate-500 sm:text-base">
{"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"}
</p>
</div>
</div>
<form
onSubmit={handleSubmit}
className="mt-0 bg-white dark:bg-gray-900 px-5 pb-8 pt-2 shadow-sm sm:mt-5 sm:rounded-2xl sm:px-8 sm:py-8 sm:shadow-md border border-gray-100 dark:border-gray-800"
>
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="🌟 昵称">
<input
type="text"
maxLength={140}
placeholder="请输入您的昵称"
value={form.nickname}
onChange={(e) => set("nickname", e.target.value)}
required
className="form-input"
/>
</Field>
<Field label="👤 性别">
<select
value={form.gender}
onChange={(e) => set("gender", e.target.value)}
required
className={`form-input appearance-none ${form.gender ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled>请选择</option>
{genderOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
<ChevronDown />
</Field>
</div>
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="💕 感情状态">
<select
value={form.single}
onChange={(e) => set("single", e.target.value)}
required
className={`form-input appearance-none ${form.single ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled>请选择</option>
{singleOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
<ChevronDown />
</Field>
<Field label="💼 职业">
<input
type="text"
maxLength={140}
placeholder="您的职业或专业"
value={form.profession}
onChange={(e) => set("profession", e.target.value)}
required
className="form-input"
/>
</Field>
</div>
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="💬 微信号">
<input
type="text"
maxLength={140}
placeholder="用于拉群"
value={form.wechat}
onChange={(e) => set("wechat", e.target.value)}
required
className="form-input"
/>
</Field>
<Field label="📱 手机号">
<input
type="tel"
maxLength={11}
pattern="^\d{11}$"
placeholder="请输入11位手机号"
value={form.phone}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, "").slice(0, 11);
set("phone", v);
}}
required
className="form-input"
/>
</Field>
</div>
<div className="border-b border-slate-100 dark:border-gray-700 py-4 sm:border-b-0 sm:py-5">
<label className="mb-2 block text-base font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
📝 自我介绍
</label>
<div className="relative">
<textarea
maxLength={500}
rows={5}
placeholder="分享您的经历、兴趣或加入原因,越详细越好哦~"
value={form.intro}
onChange={(e) => set("intro", e.target.value)}
className="form-input min-h-[120px] resize-none sm:min-h-[140px]"
/>
<span className="absolute right-3 bottom-3 text-xs text-slate-300 dark:text-slate-500">
{form.intro.length}/500
</span>
</div>
</div>
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="🎓 学历">
<select
value={form.education}
onChange={(e) => set("education", e.target.value)}
className={`form-input appearance-none ${form.education ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled>请选择</option>
{educationOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
<ChevronDown />
</Field>
<Field label="📅 毕业时间">
<select
value={form.graduationYear}
onChange={(e) => set("graduationYear", e.target.value)}
className={`form-input appearance-none ${form.graduationYear ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled>请选择</option>
{graduationYears.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
<ChevronDown />
</Field>
</div>
<div className="py-4 sm:py-5">
<label className="mb-1 block text-[15px] font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
📷 头像 / 自拍视频
<span className="ml-2 text-xs font-normal text-slate-400 dark:text-slate-500">(可选)</span>
</label>
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500">
支持图片(JPG/PNG)或短视频(MP4),最大 20MB
</p>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
className="hidden"
onChange={async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 20 * 1024 * 1024) {
alert("文件大小不能超过 20MB");
return;
}
const isVideo = file.type.startsWith("video/");
const preview = URL.createObjectURL(file);
setMedia({ preview, url: "", type: isVideo ? "video" : "image" });
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload-media", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "上传失败");
}
setMedia((m) => (m ? { ...m, url: data.url } : null));
} catch (err) {
alert(err instanceof Error ? err.message : "上传失败");
URL.revokeObjectURL(preview);
setMedia(null);
if (fileRef.current) fileRef.current.value = "";
} finally {
setUploading(false);
}
}}
/>
{media ? (
<div className="relative inline-block">
{uploading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-black/40 text-sm font-medium text-white">
上传中…
</div>
)}
{media.type === "image" ? (
<img
src={media.preview}
alt="preview"
className="h-32 w-32 rounded-xl border border-slate-200 dark:border-gray-700 object-cover sm:h-40 sm:w-40"
/>
) : (
<video
src={media.preview}
className="h-32 w-32 rounded-xl border border-slate-200 dark:border-gray-700 object-cover sm:h-40 sm:w-40"
muted
playsInline
controls
/>
)}
<button
type="button"
disabled={uploading}
onClick={() => {
URL.revokeObjectURL(media.preview);
setMedia(null);
if (fileRef.current) fileRef.current.value = "";
}}
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white shadow-md transition-colors hover:bg-red-600 disabled:opacity-50"
>
✕
</button>
</div>
) : (
<button
type="button"
disabled={uploading}
onClick={() => fileRef.current?.click()}
className="flex h-32 w-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 dark:border-gray-700 text-slate-400 dark:text-slate-500 transition-colors hover:border-sky-300 hover:text-sky-500 disabled:opacity-50 sm:h-40 sm:w-40"
>
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
</svg>
<span className="text-xs">{uploading ? "上传中…" : "点击上传"}</span>
</button>
)}
</div>
<div className="mt-6 rounded-xl border border-slate-100 dark:border-gray-700 bg-slate-50/50 dark:bg-gray-800/50 px-4 py-4 sm:px-5">
<p className="mb-3 text-sm font-bold text-slate-700 dark:text-slate-300">💳 支付方式</p>
{payEnv === "wechat" ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
当前在微信内打开,将使用 <span className="font-medium text-emerald-600">微信支付</span>
</p>
) : (
<div className="flex gap-3">
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50 dark:has-[:checked]:bg-sky-900/20">
<input
type="radio"
name="payChannel"
value="alipay"
checked={payChannel === "alipay"}
onChange={() => setPayChannel("alipay")}
className="h-4 w-4 border-slate-300 text-sky-500"
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">支付宝</span>
</label>
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-emerald-500 has-[:checked]:bg-emerald-50 dark:has-[:checked]:bg-emerald-900/20">
<input
type="radio"
name="payChannel"
value="wxpay"
checked={payChannel === "wxpay"}
onChange={() => setPayChannel("wxpay")}
className="h-4 w-4 border-slate-300 text-emerald-500"
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">微信支付</span>
</label>
</div>
)}
</div>
<div className="mt-8 sm:mt-10">
{error && (
<p className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-3 text-center text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
<button
type="submit"
disabled={submitting || uploading || payRedirecting}
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:py-4"
>
{uploading ? "媒体上传中…" : payRedirecting ? "跳转支付中…" : submitting ? "提交中…" : "提交申请"}
</button>
</div>
</form>
<div className="mt-6 hidden text-center sm:block">
<Link href="/" className="text-sm text-slate-400 dark:text-slate-500 transition-colors hover:text-[#ff4d4f]">
← 返回首页
</Link>
</div>
<div className="mt-4 pb-6 text-center sm:hidden">
<Link href="/" className="text-sm text-slate-400 dark:text-slate-500 transition-colors hover:text-[#ff4d4f]">
← 返回首页
</Link>
</div>
</div>
</div>
);
}
function Field({
label,
children,
noBorder,
}: {
label: string;
children: React.ReactNode;
noBorder?: boolean;
}) {
return (
<div
className={`relative flex items-center gap-3 py-3.5 sm:flex-col sm:items-stretch sm:gap-0 sm:py-4 ${
noBorder ? "" : "border-b border-slate-100 dark:border-gray-700 sm:border-b-0"
}`}
>
<label className="w-[90px] shrink-0 text-[15px] font-bold text-slate-700 dark:text-slate-300 sm:mb-2 sm:w-auto sm:text-[17px]">
{label}
</label>
<div className="relative min-w-0 flex-1">{children}</div>
</div>
);
}
function ChevronDown() {
return (
<svg
className="pointer-events-none absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 text-slate-400 sm:right-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { Link } from "@/i18n/navigation";
/**
* 支付完成回跳页
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
*/
export default function JoinPaidPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-gray-900 p-10 text-center shadow-lg border border-gray-100 dark:border-gray-800">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
✅
</div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">申请已提交</h2>
<p className="mt-3 text-slate-500 dark:text-slate-400">
我们会尽快审核你的信息
<br />
审核通过后将通过微信邀请你入群
</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
← 返回首页
</Link>
</div>
</div>
);
}

40
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { LocaleProvider } from "../context/LocaleContext";
import type { Locale } from "../context/LocaleContext";
import { getThemeMessages } from "@/app/lib/theme-data";
import Navbar from "../components/Navbar";
const LOCALES: Locale[] = ["zh", "en"];
export const metadata: Metadata = {
title: "游牧中国 - 数字游民的理想目的地",
description:
"发现全球最适合远程工作和生活的城市,加入中国数字游民社区,探索远程办公新方式",
};
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!LOCALES.includes(locale as Locale)) {
notFound();
}
const messages = await getThemeMessages(locale as Locale);
return (
<LocaleProvider locale={locale as Locale} messages={messages}>
<Navbar />
{children}
</LocaleProvider>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState, useMemo } from "react";
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;
}
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 },
];
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}`;
}
function MeetupCard({ meetup }: { meetup: Meetup }) {
const formattedDate = formatMeetupDate(meetup.date, meetup.time);
return (
<article className="group bg-white dark:bg-gray-900 rounded-2xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2),0_0_0_1px_rgba(255,255,255,0.05)] hover:shadow-[0_12px_28px_rgba(0,0,0,0.12),0_0_0_1px_rgba(0,0,0,0.05)] dark:hover:shadow-[0_12px_28px_rgba(0,0,0,0.3)] hover:-translate-y-2 transition-all duration-300 cursor-pointer flex flex-col h-full">
<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>
<div className="flex-1 flex flex-col p-4 sm:p-5">
<div className="flex items-center justify-between gap-3 mb-2">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{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>
<p className="text-sm text-gray-600 dark:text-gray-400 font-medium mb-0.5">@ {meetup.venue}</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mb-3">{meetup.address}</p>
<p className="text-sm text-gray-600 dark:text-gray-400 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>
<div className="flex items-center gap-1 mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<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 dark:border-gray-800 shadow-sm object-cover" title={a.name} />
))}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">已报名成员</span>
</div>
</div>
</article>
);
}
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] dark:bg-gray-950">
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-10 py-8 sm:py-12">
<header className="mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
线下聚会
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
与全球数字游民面对面交流,拓展人脉,分享经验
</p>
</header>
<div className="flex gap-1 p-1 bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-100 dark:border-gray-800 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 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"}`}>
即将举办
</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 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"}`}>
往期聚会
</button>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6">
{activeTab === "upcoming" ? "共" : "共"}
<span className="font-semibold text-gray-700 dark:text-gray-300 mx-1">{totalCount}</span>
{activeTab === "upcoming" ? "场即将举办的聚会" : "场往期聚会"}
</p>
<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>
{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 dark:text-gray-400 text-base sm:text-lg">
{activeTab === "upcoming" ? "暂无即将举办的聚会" : "暂无往期聚会记录"}
</p>
</div>
)}
</main>
</div>
);
}

View File

@@ -1,13 +1,14 @@
"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";
import { Link } from "@/i18n/navigation";
import HeroSection from "../components/HeroSection";
import FilterBar from "../components/FilterBar";
import CityCard from "../components/CityCard";
import CityModal from "../components/CityModal";
import Footer from "../components/Footer";
import { cities, type City } 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" },
@@ -63,6 +64,7 @@ function safetyOrder(s: string): number {
export default function Home() {
const [activeFilter, setActiveFilter] = useState("全部");
const [sortBy, setSortBy] = useState("score");
const [cityModal, setCityModal] = useState<City | null>(null);
const filteredCities = useMemo(() => {
let result = [...cities];
@@ -91,35 +93,33 @@ export default function Home() {
}, [activeFilter, sortBy]);
return (
<div className="min-h-screen bg-[#fafafa]">
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<HeroSection />
{/* Stats Strip */}
<div className="border-y border-gray-100 bg-white">
<div className="border-y border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
<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>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">18</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">个城市</p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 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>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">5,000+</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">位游民</p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 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>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">8+</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">场聚会/月</p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 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>
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">¥3,000</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">起/月生活费</p>
</div>
</div>
</div>
{/* Filter Bar */}
<div className="px-3 sm:px-5 md:px-10 pt-5">
<FilterBar
activeFilter={activeFilter}
@@ -130,16 +130,15 @@ export default function Home() {
/>
</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 className="text-[10px] sm:text-sm text-gray-600 dark:text-gray-400 mb-0.5">
加入数字游民微信社群
</p>
<p className="text-[9px] sm:text-xs text-gray-400 mb-1.5 sm:mb-4">
<p className="text-[9px] sm:text-xs text-gray-400 dark:text-gray-500 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">
@@ -157,10 +156,10 @@ export default function Home() {
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"
className="w-6 h-6 sm:w-10 sm:h-10 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
/>
))}
<div className="w-full mt-0.5 text-[9px] sm:text-xs text-gray-400 text-center">
<div className="w-full mt-0.5 text-[9px] sm:text-xs text-gray-400 dark:text-gray-500 text-center">
本月 86 人加入
</div>
</div>
@@ -168,14 +167,14 @@ export default function Home() {
<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]">
<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 dark:text-gray-300 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
<div className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
🗓 3月15日
</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 className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
📍 深圳南山
</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 className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
👥 12人
</div>
<div className="flex -space-x-1 mt-auto">
@@ -184,7 +183,7 @@ export default function Home() {
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"
className="w-4 h-4 sm:w-7 sm:h-7 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
/>
))}
</div>
@@ -192,29 +191,32 @@ export default function Home() {
</Link>
{filteredCities.map((city, i) => (
<CityCard key={city.id} city={city} rank={i + 1} />
<CityCard
key={city.id}
city={city}
rank={i + 1}
onClick={() => setCityModal(city)}
/>
))}
</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 className="text-gray-500 dark:text-gray-400 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"
className="mt-4 text-sm text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 px-5 py-2 rounded-full hover:bg-gray-300 dark:hover:bg-gray-600 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">
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-5 sm:mb-6 flex items-center gap-2">
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
热门路线
</h2>
@@ -255,10 +257,10 @@ export default function Home() {
<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">
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">
{route.title}
</h3>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
{route.duration}
</span>
</div>
@@ -266,18 +268,18 @@ export default function Home() {
{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>
<span className="font-medium text-gray-700 dark:text-gray-300">{city}</span>
{i < route.cities.length - 1 && (
<span className="text-gray-300 mx-1">→</span>
<span className="text-gray-300 dark:text-gray-600 mx-1">→</span>
)}
</span>
))}
</div>
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
{route.desc}
</p>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-900">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{route.cost}
</span>
<span className="text-xs text-[#ff4d4f] font-medium group-hover:translate-x-1 transition-transform">
@@ -290,22 +292,20 @@ export default function Home() {
</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">
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 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 className="font-bold text-gray-900 dark:text-gray-100 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">
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
8场/月
</span>
</div>
@@ -315,11 +315,11 @@ export default function Home() {
<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>
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">{m.city}</p>
<p className="text-[11px] text-gray-400 dark:text-gray-500">{m.date}</p>
</div>
</div>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
{m.count}人
</span>
</div>
@@ -327,21 +327,20 @@ export default function Home() {
</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"
className="block w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 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 className="font-bold text-gray-900 dark:text-gray-100 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">
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
{travelingNow.length}人
</span>
</div>
@@ -359,62 +358,59 @@ export default function Home() {
</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 className="font-bold text-gray-900 dark:text-gray-100 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"}`}>
<span className={`text-xs font-bold mt-0.5 w-4 shrink-0 ${i < 3 ? "text-[#ff4d4f]" : "text-gray-300 dark:text-gray-600"}`}>
{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">
<p className="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors line-clamp-1 leading-snug">
{t.title}
</p>
<p className="text-[11px] text-gray-400 mt-0.5">
<p className="text-[11px] text-gray-400 dark:text-gray-500 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 className="w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 transition-colors">
查看更多话题 →
</button>
</div>
</div>
{/* 全球旅行保险 */}
<div className="community-card bg-gradient-to-br from-emerald-50 to-teal-50">
<div className="community-card bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-950/30 dark:to-teal-950/30">
<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>
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">旅行保险</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 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">
<p className="text-xs text-emerald-700 dark:text-emerald-400 bg-emerald-100/50 dark:bg-emerald-900/30 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 className="w-full text-center text-sm font-semibold text-emerald-700 dark:text-emerald-400 hover:text-emerald-800 dark:hover:text-emerald-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-emerald-200 dark:border-emerald-800 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="community-card bg-gradient-to-br from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30">
<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>
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">社区聊天</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">本月 3,200+ 条消息</p>
</div>
</div>
<div className="flex items-center gap-2 mb-4">
@@ -424,26 +420,25 @@ export default function Home() {
key={m.name}
src={m.photo}
alt=""
className="w-7 h-7 rounded-full border-2 border-white shadow-sm object-cover"
className="w-7 h-7 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
/>
))}
</div>
<span className="text-xs text-gray-400">在线中</span>
<span className="text-xs text-gray-400 dark:text-gray-500">在线中</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 className="w-full text-center text-sm font-semibold text-violet-700 dark:text-violet-400 hover:text-violet-800 dark:hover:text-violet-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-violet-200 dark:border-violet-800 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="community-card bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30">
<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">
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">落地指南</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
租房 · 共享办公 · 本地攻略
</p>
</div>
@@ -452,13 +447,13 @@ export default function Home() {
{["🏯 大理", "🐼 成都", "🌃 深圳", "⛲ 杭州"].map((tag) => (
<span
key={tag}
className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5"
className="text-[11px] bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 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 className="w-full text-center text-sm font-semibold text-amber-700 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-amber-200 dark:border-amber-800 rounded-full py-2 transition-colors">
查看攻略 →
</button>
</div>
@@ -466,62 +461,12 @@ export default function Home() {
</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>
<CityModal
city={cityModal}
isOpen={!!cityModal}
onClose={() => setCityModal(null)}
/>
<Footer />
</div>
);
}

114
app/api/join/route.ts Normal file
View File

@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
function getOrCreateUserId(): string {
return `user${Date.now()}`;
}
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL;
const password = process.env.POCKETBASE_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (!res.ok) return null;
const data = await res.json();
return data?.token ?? null;
}
export async function POST(request: NextRequest) {
try {
const userId = getOrCreateUserId();
const body = await request.json();
const formData = body && typeof body === "object" ? body : {};
const {
nickname,
gender,
single,
profession,
intro,
wechat,
education,
graduationYear,
phone,
media: mediaUrl,
} = formData;
const record: Record<string, string | number> = {
nickname: nickname || "",
occupation: profession || "",
reason: intro || "",
single: single || "",
wechatId: wechat || "",
gender: gender || "",
education: education || "",
gradYear: graduationYear || "",
phone: phone || "",
user_id: userId,
amount: 0,
order_id: "",
type: "",
};
if (mediaUrl && typeof mediaUrl === "string") {
(record as Record<string, string>)["media"] = mediaUrl;
}
const pb = getPocketBaseConfig();
const collection = pb.joinCollection;
const doCreate = async (authToken?: string) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authToken) headers["Authorization"] = authToken;
return fetch(`${pb.url}/api/collections/${collection}/records`, {
method: "POST",
headers,
body: JSON.stringify(record),
});
};
let res = await doCreate();
if (res.status === 403) {
const token = await getAdminToken();
if (token) res = await doCreate(token);
}
if (!res.ok) {
const errText = await res.text();
console.error("PocketBase create error:", res.status, errText);
let errMsg = "提交失败,请稍后重试";
try {
const errJson = JSON.parse(errText);
if (errJson?.message) errMsg = errJson.message;
if (errJson?.data && typeof errJson.data === "object") {
const details = Object.entries(errJson.data)
.map(([k, v]) => `${k}: ${(v as { message?: string })?.message || v}`)
.join("; ");
if (details) errMsg += ` (${details})`;
}
} catch {
if (errText.length < 200) errMsg = errText;
}
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
}
return NextResponse.json({ ok: true, user_id: userId });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Join API error:", e);
return NextResponse.json(
{ ok: false, error: `提交失败: ${msg}` },
{ status: 500 }
);
}
}

97
app/api/pay/route.ts Normal file
View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig } from "@/config/services.config";
import { SITE_URL } from "@/app/lib/env";
async function createPayOrder(
user_id: string,
return_url: string,
total_fee?: number,
type?: string,
channel = "alipay"
) {
const config = getPaymentConfig();
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
const payload = {
user_id,
total_fee: Number(total_fee) || config.defaultAmount,
type: type || config.defaultType,
channel,
return_url,
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${config.apiUrl}/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.status !== "ok") {
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
}
const params = data.params || data.xorpay_params || {};
const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl;
if (!params || typeof params !== "object") {
throw new Error("payjsapi 未返回支付参数");
}
return { params: JSON.parse(JSON.stringify(params)), payUrl };
}
export async function POST(request: NextRequest) {
try {
let body: Record<string, string | number>;
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
body = await request.json();
} else {
const form = await request.formData();
body = Object.fromEntries(form.entries()) as Record<string, string>;
}
const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim() || `${SITE_URL}/zh/join/paid`;
const total_fee = Number(body?.total_fee) || 80;
const type = String(body?.type || "meetup");
const channel = String(body?.channel || "alipay");
const device = String(body?.device || "pc");
if (!user_id) {
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
}
const { params, payUrl } = await createPayOrder(user_id, return_url, total_fee, type, channel);
const wantHtml = String(body?.html) === "1" || (request.headers.get("accept") || "").includes("text/html");
if (wantHtml) {
const escapeAttr = (s: string) =>
String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const inputs = Object.entries(params as Record<string, unknown>)
.filter(([, v]) => v != null && String(v).trim() !== "")
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(String(v))}" />`)
.join("\n");
const orderId = String((params as Record<string, unknown>)?.out_trade_no || "").trim();
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>跳转支付...</title></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(payUrl)}" method="POST">${inputs}</form><script>document.getElementById("f").submit();</script></body></html>`;
const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
if (orderId) {
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
}
return res;
}
return NextResponse.json({ ok: true, pay_url: payUrl, params, provider: "zpay" });
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
console.error("Pay API error:", err);
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig } from "@/config/services.config";
export async function GET(request: NextRequest) {
const orderId = request.nextUrl.searchParams.get("order_id");
if (!orderId) {
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
}
const config = getPaymentConfig();
if (!config.apiUrl) {
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
}
try {
const res = await fetch(
`${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
{ cache: "no-store" }
);
const data = await res.json().catch(() => ({}));
return NextResponse.json({ ok: true, paid: !!data?.paid });
} catch (e) {
return NextResponse.json({ ok: false, paid: false, error: String(e) }, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { uploadBuffer, generateObjectKey } from "@/app/lib/minio";
const MAX_SIZE = 20 * 1024 * 1024; // 20MB
export async function POST(request: NextRequest) {
try {
const form = await request.formData();
const file = form.get("file");
const fileObj =
file != null && typeof file === "object" && "size" in file
? (file as File | Blob)
: null;
if (!fileObj || fileObj.size === 0) {
return NextResponse.json({ ok: false, error: "请选择文件" }, { status: 400 });
}
if (fileObj.size > MAX_SIZE) {
return NextResponse.json({ ok: false, error: "文件大小不能超过 20MB" }, { status: 400 });
}
const ext =
(fileObj instanceof File ? fileObj.name : "").split(".").pop() ||
(fileObj.type?.startsWith("video/") ? "mp4" : "jpg");
const objectKey = generateObjectKey(ext);
const buffer = Buffer.from(await fileObj.arrayBuffer());
const contentType = (fileObj as File).type || "application/octet-stream";
const { url } = await uploadBuffer(buffer, objectKey, contentType);
return NextResponse.json({ ok: true, url });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("Upload media error:", e);
return NextResponse.json({ ok: false, error: `上传失败: ${msg}` }, { status: 500 });
}
}

View File

@@ -0,0 +1,179 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { createPortal } from "react-dom";
import {
pbLogin,
pbRegister,
pbSaveAuth,
} from "@/app/lib/pocketbase";
import { useTranslation } from "@/i18n/navigation";
interface AuthModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (email: string) => void;
}
export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) {
const { t } = useTranslation("auth");
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
let result;
if (mode === "register") {
if (password !== passwordConfirm) {
setError(t("passwordMismatch"));
setLoading(false);
return;
}
if (password.length < 8) {
setError(t("passwordMinLength"));
setLoading(false);
return;
}
result = await pbRegister(email, password, passwordConfirm);
} else {
result = await pbLogin(email, password);
}
pbSaveAuth(result.token, result.record);
onSuccess(email);
onClose();
setEmail("");
setPassword("");
setPasswordConfirm("");
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
};
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!isOpen || !mounted) return null;
const modalContent = (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<div
className="fixed inset-0 bg-black/50"
onClick={onClose}
aria-hidden
/>
<div className="flex min-h-svh items-center justify-center p-4 py-8">
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900 dark:border dark:border-gray-700 shrink-0">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={t("close")}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
{mode === "login" ? t("login") : t("register")}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{mode === "login" ? t("loginHint") : t("registerHint")}
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("email")}</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("password")}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === "register" ? "至少 8 位" : "••••••••"}
required
minLength={mode === "register" ? 8 : 1}
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
{mode === "register" && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("passwordConfirm")}</label>
<input
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
placeholder="再次输入密码"
required
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
)}
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
>
{loading ? "处理中…" : mode === "login" ? t("login") : t("register")}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
{mode === "login" ? (
<>
{t("noAccount")}{" "}
<button
type="button"
onClick={() => {
setMode("register");
setError(null);
}}
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
>
{t("signUpNow")}
</button>
</>
) : (
<>
{t("hasAccount")}{" "}
<button
type="button"
onClick={() => {
setMode("login");
setError(null);
}}
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
>
{t("goLogin")}
</button>
</>
)}
</p>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}

View File

@@ -53,9 +53,11 @@ function ScoreBar({
export default function CityCard({
city,
rank,
onClick,
}: {
city: City;
rank: number;
onClick?: () => void;
}) {
const [hovered, setHovered] = useState(false);
@@ -89,7 +91,11 @@ export default function CityCard({
return (
<div
className="city-card"
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
className="city-card cursor-pointer"
style={{
background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`,
}}
@@ -161,6 +167,7 @@ export default function CityCard({
e.stopPropagation();
setHovered(false);
}}
type="button"
>
✕
</button>

View File

@@ -0,0 +1,298 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "@/i18n/navigation";
import { City } from "@/app/data/cities";
const CITY_COORDS: Record<string, [number, number]> = {
大理: [25.7, 100.2],
成都: [30.6, 104.1],
深圳: [22.5, 114.1],
上海: [31.2, 121.5],
杭州: [30.3, 120.2],
厦门: [24.5, 118.1],
北京: [39.9, 116.4],
广州: [23.1, 113.3],
重庆: [29.6, 106.6],
昆明: [25.0, 102.7],
三亚: [18.3, 109.5],
丽江: [26.9, 100.2],
西安: [34.3, 108.9],
南京: [32.1, 118.8],
长沙: [28.2, 112.9],
青岛: [36.1, 120.4],
苏州: [31.3, 120.6],
珠海: [22.3, 113.6],
};
const CITY_IMAGES: Record<string, string> = {
大理: "https://images.unsplash.com/photo-1590856029826-c7a73142bbf1?w=1200",
成都: "https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=1200",
深圳: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=1200",
上海: "https://images.unsplash.com/photo-1542051841857-5f90071e7989?w=1200",
杭州: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200",
厦门: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
北京: "https://images.unsplash.com/photo-1508804185872-d7badad00f7d?w=1200",
广州: "https://images.unsplash.com/photo-1596422846543-75c6fc197f07?w=1200",
重庆: "https://images.unsplash.com/photo-1563841930606-67e2cce48b78?w=1200",
昆明: "https://images.unsplash.com/photo-1589553416260-f586c8f1514f?w=1200",
三亚: "https://images.unsplash.com/photo-1590523277543-a94d2e4eb00b?w=1200",
丽江: "https://images.unsplash.com/photo-1548013146-72479768bada?w=1200",
西安: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
南京: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
长沙: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
青岛: "https://images.unsplash.com/photo-1552465011-b4e21bf6e79a?w=1200",
苏州: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
珠海: "https://images.unsplash.com/photo-1590523277543-a94d2e4eb00b?w=1200",
};
type ScoreLabel = "great" | "good" | "okay" | "bad";
function scoreToLabel(score: number): ScoreLabel {
if (score >= 4.2) return "great";
if (score >= 3.5) return "good";
if (score >= 2.5) return "okay";
return "bad";
}
interface CityModalProps {
city: City | null;
isOpen: boolean;
onClose: () => void;
}
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
const { t } = useTranslation("cityModal");
const [activeTab, setActiveTab] = useState("scores");
const [mounted, setMounted] = useState(false);
const [favorited, setFavorited] = useState(false);
useEffect(() => setMounted(true), []);
if (!isOpen || !city || !mounted) return null;
const coverImage = city.coverImage ?? CITY_IMAGES[city.name] ?? `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`;
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + Math.floor(Math.random() * 500);
const likedCount = city.likedCount ?? Math.floor(reviewsCount * 0.55);
const dislikedCount = city.dislikedCount ?? Math.floor(reviewsCount * 0.08);
const qualityOfLife = (city.qualityOfLife?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
const familyScore = (city.familyScore?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore - 0.2);
const communityScore = (city.communityScore?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore + 0.1);
const nomadPercent = city.nomadPercent ?? Math.min(5, Math.ceil((city.nomadsNow / 500) * 2));
const funLabel = (city.funLabel?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
const feelsLike = city.feelsLikeTemp ?? city.temperature + 2;
const acPercent = city.acPercent ?? Math.min(95, 60 + city.temperature);
const [lat, lng] = city.lat && city.lng ? [city.lat, city.lng] : (CITY_COORDS[city.name] ?? [30, 120]);
const region = t("region");
const internetInfo = getInternetInfo(city.internetSpeed, t);
const tempInfo = getTempInfo(city.temperature, feelsLike, t);
const costLabel = getCostLabel(city.costPerMonth, t);
const humidityLabel = getHumidityLabel(city.humidity, t);
const acLabel = getAcLabel(acPercent, t);
const tabs = [
{ id: "guide", labelKey: "tabs.guide" },
{ id: "pros", labelKey: "tabs.pros" },
{ id: "reviews", labelKey: "tabs.reviews" },
{ id: "cost", labelKey: "tabs.cost" },
{ id: "people", labelKey: "tabs.people" },
{ id: "chat", labelKey: "tabs.chat" },
{ id: "photos", labelKey: "tabs.photos" },
{ id: "weather", labelKey: "tabs.weather" },
{ id: "trends", labelKey: "tabs.trends" },
{ id: "demographics", labelKey: "tabs.demographics" },
{ id: "scores", labelKey: "tabs.scores" },
];
const scoresList = [
{ icon: "🚩", labelKey: "scores.nomadScore", value: `${Math.round(city.overallScore)}/5 (${t("scores.rank")} #${city.id})`, good: true },
{ icon: "❤️", labelKey: "scores.likedByMembers", value: `${likedCount} ${t("scores.likedIt")} ${dislikedCount} ${t("scores.dislikedIt")}`, good: null },
{ icon: "⭐", labelKey: "scores.qualityOfLife", value: t(`scores.scoreLabels.${qualityOfLife}`), good: qualityOfLife !== "bad" },
{ icon: "👨‍👩‍👧", labelKey: "scores.familyScore", value: t(`scores.scoreLabels.${familyScore}`), good: familyScore !== "bad" },
{ icon: "👥", labelKey: "scores.communityScore", value: t(`scores.scoreLabels.${communityScore}`), good: communityScore !== "bad" },
{ icon: "🏙️", labelKey: "scores.nomadDensity", value: `${nomadPercent}% ${t("scores.nomadHere")}`, good: true },
{ icon: "💰", labelKey: "scores.cost", value: `${costLabel}: ¥${city.costPerMonth.toLocaleString()}/${t("scores.perMonth")}`, good: city.costPerMonth <= 6000 },
{ icon: "📡", labelKey: "scores.internet", value: internetInfo.text, good: internetInfo.isGood },
{ icon: "😊", labelKey: "scores.fun", value: t(`scores.scoreLabels.${funLabel}`), good: funLabel !== "bad" },
{ icon: "🌡️", labelKey: "scores.temperature", value: tempInfo.text, good: tempInfo.isGood },
{ icon: "💧", labelKey: "scores.humidity", value: `${humidityLabel}: ${city.humidity}%`, good: true },
{ icon: "❄️", labelKey: "scores.acPercent", value: `${acLabel}: ${acPercent}%`, good: acPercent >= 70 },
];
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.2},${lat - 0.15},${lng + 0.2},${lat + 0.15}&layer=mapnik&marker=${lat},${lng}`;
const modalContent = (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-4 md:p-6">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div
className="relative z-10 flex flex-col w-full max-w-5xl max-h-[90vh] rounded-2xl overflow-hidden shadow-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header with cover image */}
<div className="relative h-36 sm:h-44 md:h-52 shrink-0">
{typeof coverImage === "string" && coverImage.startsWith("http") ? (
<img
src={coverImage}
alt={city.name}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div
className="absolute inset-0 w-full h-full"
style={{ background: coverImage as string }}
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
<button
onClick={onClose}
className="absolute right-3 top-3 sm:right-4 sm:top-4 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white transition-colors"
aria-label={t("close")}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="absolute bottom-0 left-0 right-0 p-3 sm:p-4 flex flex-wrap items-end justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white drop-shadow-lg">
{city.name}
</h1>
<p className="text-white/90 text-sm mt-0.5">
{city.countryEmoji} {city.country}
</p>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-white/90 text-xs sm:text-sm">
{reviewsCount.toLocaleString()} {t("reviews")}
</span>
<button
onClick={() => setFavorited(!favorited)}
className={`rounded-full px-3 py-1.5 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium transition-colors ${
favorited
? "bg-[#ff4d4f] text-white"
: "bg-white/20 text-white hover:bg-white/30"
}`}
>
{favorited ? t("favorited") : t("favorite")}
</button>
</div>
</div>
</div>
{/* Breadcrumb */}
<div className="shrink-0 px-3 sm:px-4 py-2 border-b border-gray-100 dark:border-gray-800">
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
{t("breadcrumbPrefix")} &gt; {region} &gt; {city.country} &gt; {city.name}
</p>
</div>
{/* Tabs */}
<div className="shrink-0 border-b border-gray-100 dark:border-gray-800 overflow-x-auto">
<div className="flex gap-1 min-w-max px-3 sm:px-4 py-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium whitespace-nowrap rounded-lg transition-colors ${
activeTab === tab.id
? "text-[#ff4d4f] border-b-2 border-[#ff4d4f] -mb-[2px]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
</div>
{/* Content - scrollable */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
{activeTab === "scores" ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<div className="space-y-2 sm:space-y-3">
{scoresList.map((s) => (
<div
key={s.labelKey}
className="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50"
>
<span className="text-lg sm:text-xl w-7 sm:w-8 shrink-0">{s.icon}</span>
<span className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">
{t(s.labelKey)}
</span>
<span
className={`text-xs font-medium px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-full shrink-0 ${
s.good === null
? "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
: s.good
? "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-400"
}`}
>
{String(s.value)}
</span>
</div>
))}
</div>
<div className="rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 h-64 sm:h-72 lg:h-80">
<iframe
title={`${city.name} ${t("map")}`}
src={mapUrl}
className="w-full h-full"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</div>
) : (
<div className="py-6 sm:py-8 text-center text-gray-500 dark:text-gray-400">
<p className="text-lg">{city.icon}</p>
<p className="mt-2 text-sm sm:text-base">
「{tabs.find((x) => x.id === activeTab) ? t(tabs.find((x) => x.id === activeTab)!.labelKey) : activeTab}」{t("comingSoon")}
</p>
</div>
)}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
function getInternetInfo(speed: number, t: (k: string) => string): { text: string; isGood: boolean } {
const avg = t("scores.avg");
if (speed >= 80) return { text: `${t("scores.fast")}: ${speed} Mbps (${avg})`, isGood: true };
if (speed >= 50) return { text: `${t("scores.medium")}: ${speed} Mbps (${avg})`, isGood: false };
return { text: `${t("scores.slow")}: ${speed} Mbps (${avg})`, isGood: false };
}
function getTempInfo(temp: number, feels: number, t: (k: string) => string): { text: string; isGood: boolean } {
const feelsLike = t("scores.feelsLike");
if (temp >= 32 || feels >= 38) return { text: `${t("scores.tooHot")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: false };
if (temp >= 25) return { text: `${t("scores.warm")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: true };
if (temp >= 15) return { text: `${t("scores.comfortable")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: true };
return { text: `${t("scores.cold")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: false };
}
function getCostLabel(cost: number, t: (k: string) => string): string {
if (cost <= 4000) return t("scores.affordable");
if (cost <= 7000) return t("scores.moderate");
return t("scores.expensive");
}
function getHumidityLabel(h: number, t: (k: string) => string): string {
if (h <= 60) return t("scores.comfy");
if (h <= 80) return t("scores.moderate");
return t("scores.humid");
}
function getAcLabel(pct: number, t: (k: string) => string): string {
if (pct >= 85) return t("scores.veryHigh");
if (pct >= 70) return t("scores.high");
return t("scores.normal");
}

97
app/components/Footer.tsx Normal file
View File

@@ -0,0 +1,97 @@
"use client";
import { Link, useTranslation } from "@/i18n/navigation";
const footerSections = [
{
titleKey: "community" as const,
links: [
{ labelKey: "join" as const, href: "/join" },
{ labelKey: "meetups" as const, href: "/meetups" },
{ labelKey: "dating" as const, href: "/dating" },
{ labelKey: "map" as const, href: "#" },
],
},
{
titleKey: "tools" as const,
links: [
{ labelKey: "climate" as const, href: "#" },
{ labelKey: "cost" as const, href: "#" },
{ labelKey: "coworking" as const, href: "#" },
{ labelKey: "compare" as const, href: "#" },
],
},
{
titleKey: "resources" as const,
links: [
{ labelKey: "guide" as const, href: "#" },
{ labelKey: "cityGuide" as const, href: "#" },
{ labelKey: "stories" as const, href: "#" },
{ labelKey: "faq" as const, href: "#" },
],
},
{
titleKey: "about" as const,
links: [
{ labelKey: "aboutUs" as const, href: "/about" },
{ labelKey: "recruit" as const, href: "/jobs" },
{ labelKey: "terms" as const, href: "#" },
{ labelKey: "privacy" as const, href: "#" },
{ labelKey: "contact" as const, href: "#" },
],
},
];
export default function Footer() {
const { t } = useTranslation("footer");
return (
<footer className="border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
<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">
{footerSections.map((section) => (
<div key={section.titleKey}>
<h4 className="font-bold text-gray-900 dark:text-gray-100 text-sm mb-3">
{t(section.titleKey)}
</h4>
<ul className="space-y-2 text-sm text-gray-500 dark:text-gray-400">
{section.links.map((link) => (
<li key={link.labelKey}>
{link.href.startsWith("/") ? (
<Link
href={link.href}
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{t(`links.${link.labelKey}`)}
</Link>
) : (
<a
href={link.href}
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{t(`links.${link.labelKey}`)}
</a>
)}
</li>
))}
</ul>
</div>
))}
</div>
<div className="pt-8 border-t border-gray-200 dark:border-gray-800 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 dark:text-gray-100">游牧中国</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
{t("copyright")}
</p>
<div className="flex items-center gap-4 text-xs text-gray-400 dark:text-gray-500">
<span>CNY ¥</span>
<span>°C</span>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Link } from "@/i18n/navigation";
const avatarPhotos = [
"https://i.pravatar.cc/150?img=32",
@@ -37,7 +37,7 @@ export default function HeroSection() {
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 inset-0 bg-gradient-to-b from-orange-50/70 via-white to-[#fafafa] dark:from-gray-950 dark:via-gray-900 dark:to-gray-950" />
<div
className="absolute top-0 right-0 w-[500px] h-[500px] -translate-y-1/3 translate-x-1/4 opacity-40 rounded-full"
style={{

View File

@@ -1,19 +1,36 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
const navLinks = [
{ label: "城市", icon: "🏙️", href: "/" },
{ label: "旅行", icon: "✈️", href: "/meetups" },
{ label: "社区", icon: "💬", href: "/dating" },
{ label: "探索", icon: "🎒", href: "/#community" },
{ labelKey: "cities" as const, icon: "🏙️", href: "/" },
{ labelKey: "travel" as const, icon: "✈️", href: "/meetups" },
{ labelKey: "community" as const, icon: "💬", href: "/dating" },
{ labelKey: "explore" as const, icon: "🎒", href: "/#community" },
];
export default function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const { t } = useTranslation("common");
const { t: tNav } = useTranslation("nav");
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
useEffect(() => {
setUserEmail(getStoredUserEmail());
}, []);
const handleLogout = () => {
pbLogout();
setUserEmail(null);
};
function isActive(href: string) {
if (href.startsWith("/#")) return false;
@@ -22,49 +39,72 @@ export default function Navbar() {
}
return (
<nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-lg border-b border-gray-100">
<nav className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg border-b border-gray-100 dark:border-gray-800">
<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 className="text-lg font-bold text-gray-900 dark:text-gray-100 tracking-tight">
{t("siteName")}
</span>
</Link>
{/* Center Nav */}
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.label}
key={link.labelKey}
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"
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<span>{link.icon}</span>
<span>{link.label}</span>
<span>{tNav(link.labelKey)}</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">
登录
{userEmail ? (
<div className="flex items-center gap-2">
<span className="hidden sm:block max-w-[120px] truncate text-sm text-gray-600 dark:text-gray-400">
{userEmail}
</span>
<button
onClick={handleLogout}
className="rounded-full border border-gray-200 dark:border-gray-700 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("logout")}
</button>
</div>
) : (
<button
onClick={() => setAuthOpen(true)}
className="hidden sm:block text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-full hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("loginRegister")}
</button>
)}
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
title={locale === "zh" ? "EN" : "中文"}
>
{locale === "zh" ? "EN" : "中文"}
</button>
<ThemeToggle />
<Link
href="/join"
className="text-sm bg-[#ff4d4f] text-white px-4 py-2 rounded-full hover:bg-[#ff7a45] transition-colors font-medium"
>
加入我们 →
{t("explore")}
</Link>
{/* Mobile hamburger */}
<button
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100"
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? (
@@ -80,31 +120,53 @@ export default function Navbar() {
</div>
</div>
{/* Mobile Menu */}
{mobileOpen && (
<div className="md:hidden border-t border-gray-100 bg-white px-4 py-3 space-y-1">
<div className="md:hidden border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 py-3 space-y-1">
{navLinks.map((link) => (
<Link
key={link.label}
key={link.labelKey}
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"
? "text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => setMobileOpen(false)}
>
<span className="text-lg">{link.icon}</span>
<span>{link.label}</span>
<span>{tNav(link.labelKey)}</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
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="block w-full text-left px-4 py-3 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
>
{locale === "zh" ? "EN" : "中文"}
</button>
{userEmail ? (
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 py-2.5 rounded-lg bg-gray-50 dark:bg-gray-800">
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
<button onClick={() => { handleLogout(); setMobileOpen(false); }} className="text-sm font-medium text-[#ff4d4f]">
{t("logout")}
</button>
</div>
) : (
<button
onClick={() => { setMobileOpen(false); setAuthOpen(true); }}
className="w-full text-left px-4 py-3 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
>
{t("loginRegister")}
</button>
</div>
)}
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={(email) => setUserEmail(email)}
/>
</nav>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useTheme } from "../context/ThemeContext";
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
type="button"
onClick={toggleTheme}
className="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label={theme === "dark" ? "切换到日间模式" : "切换到夜间模式"}
title={theme === "dark" ? "日间模式" : "夜间模式"}
>
{theme === "dark" ? (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
) : (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
)}
</button>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import {
createContext,
useContext,
useCallback,
type ReactNode,
} from "react";
export type Locale = "zh" | "en";
const LOCALES: Locale[] = ["zh", "en"];
const DEFAULT_LOCALE: Locale = "zh";
type Messages = Record<string, unknown>;
type LocaleContextValue = {
locale: Locale;
messages: Messages;
t: (key: string) => string;
setLocale: (locale: Locale) => void;
};
const LocaleContext = createContext<LocaleContextValue | null>(null);
function getNested(obj: unknown, path: string): string | undefined {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : undefined;
}
export function LocaleProvider({
locale,
messages,
children,
}: {
locale: Locale;
messages: Messages;
children: ReactNode;
}) {
const t = useCallback(
(key: string) => {
const value = getNested(messages, key);
return value ?? key;
},
[messages]
);
const setLocale = useCallback((newLocale: Locale) => {
const path = window.location.pathname;
const newPath = path.replace(/^\/(zh|en)/, `/${newLocale}`);
window.location.href = newPath || `/${newLocale}`;
}, []);
const value: LocaleContextValue = {
locale,
messages,
t,
setLocale,
};
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
export function useLocale() {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
return ctx.locale;
}
export function useTranslation(namespace?: string) {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error("useTranslation must be used within LocaleProvider");
return {
t: (key: string) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
return ctx.t(fullKey);
},
};
}
export function useLocaleRouter() {
const locale = useLocale();
return {
replace: (pathname: string, opts?: { locale?: Locale }) => {
const newLocale = opts?.locale ?? locale;
const cleanPath = pathname.replace(/^\/(zh|en)/, "") || "/";
const newPath = `/${newLocale}${cleanPath === "/" ? "" : cleanPath}`;
window.location.href = newPath;
},
};
}
export { LOCALES, DEFAULT_LOCALE };

View File

@@ -0,0 +1,68 @@
"use client";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
const STORAGE_KEY = "theme";
export type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const hasDark = document.documentElement.classList.contains("dark");
setThemeState(hasDark ? "dark" : "light");
}, []);
useEffect(() => {
if (!mounted) return;
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem(STORAGE_KEY, theme);
}, [theme, mounted]);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === "light" ? "dark" : "light"));
}, []);
const value: ThemeContextValue = {
theme,
setTheme,
toggleTheme,
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

View File

@@ -16,6 +16,21 @@ export interface City {
gradientTo: string;
icon: string;
tags: string[];
/** 城市详情弹窗 */
coverImage?: string;
reviewsCount?: number;
likedCount?: number;
dislikedCount?: number;
qualityOfLife?: "Great" | "Good" | "Okay" | "Bad";
familyScore?: "Great" | "Good" | "Okay" | "Bad";
communityScore?: "Great" | "Good" | "Okay" | "Bad";
nomadPercent?: number;
funLabel?: "Great" | "Good" | "Okay" | "Bad";
feelsLikeTemp?: number;
acPercent?: number;
lat?: number;
lng?: number;
region?: string;
}
export const cities: City[] = [
@@ -37,6 +52,20 @@ export const cities: City[] = [
gradientTo: "#0891b2",
icon: "🏯",
tags: ["热门", "便宜", "宜居", "山城", "文化", "友好社区"],
coverImage: "https://images.unsplash.com/photo-1590856029826-c7a73142bbf1?w=1200",
reviewsCount: 2842,
likedCount: 198,
dislikedCount: 32,
qualityOfLife: "Good",
familyScore: "Okay",
communityScore: "Great",
nomadPercent: 2,
funLabel: "Great",
feelsLikeTemp: 18,
acPercent: 65,
lat: 25.7,
lng: 100.2,
region: "亚洲",
},
{
id: 2,
@@ -56,6 +85,20 @@ export const cities: City[] = [
gradientTo: "#65a30d",
icon: "🐼",
tags: ["热门", "美食", "都市", "新一线", "文化", "友好社区"],
coverImage: "https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=1200",
reviewsCount: 3654,
likedCount: 312,
dislikedCount: 28,
qualityOfLife: "Great",
familyScore: "Good",
communityScore: "Great",
nomadPercent: 3,
funLabel: "Great",
feelsLikeTemp: 20,
acPercent: 85,
lat: 30.6,
lng: 104.1,
region: "亚洲",
},
{
id: 3,
@@ -75,6 +118,20 @@ export const cities: City[] = [
gradientTo: "#0284c7",
icon: "🌃",
tags: ["热门", "高速网络", "都市", "一线城市", "安全"],
coverImage: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=1200",
reviewsCount: 4242,
likedCount: 355,
dislikedCount: 24,
qualityOfLife: "Good",
familyScore: "Good",
communityScore: "Great",
nomadPercent: 4,
funLabel: "Good",
feelsLikeTemp: 28,
acPercent: 95,
lat: 22.5,
lng: 114.1,
region: "亚洲",
},
{
id: 4,

View File

@@ -1,304 +0,0 @@
"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>
);
}

View File

@@ -1,10 +1,17 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: #fafafa;
--foreground: #111827;
}
.dark {
--background: #111827;
--foreground: #f9fafb;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -110,6 +117,10 @@ html {
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.dark .right-item {
background: #1f2937;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.right-item:hover {
animation: pulse 0.6s ease;
@@ -189,6 +200,12 @@ html {
.filter-btn.active:hover {
background: #1f2937;
}
.dark .filter-btn.active {
background: #374151;
}
.dark .filter-btn.active:hover {
background: #4b5563;
}
/* ==================== Community Cards ==================== */
.community-card {
@@ -202,6 +219,13 @@ html {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.dark .community-card {
background: #1f2937;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.dark .community-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.08);
}
/* ==================== Utilities ==================== */
.hide-scrollbar::-webkit-scrollbar {
@@ -225,3 +249,71 @@ html {
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ==================== Form Inputs (join page) ==================== */
.form-input {
width: 100%;
border: 0;
background: transparent;
font-size: 15px;
line-height: 1.5;
color: #1e293b;
outline: none;
padding: 0;
}
.form-input::placeholder {
color: #b2b2b2;
}
@media (min-width: 640px) {
.form-input {
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 0.65rem 0.875rem;
font-size: 15px;
background: #f8fafc;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
border-color: #0ea5e9;
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
background: #fff;
}
textarea.form-input {
padding: 0.75rem 0.875rem;
}
select.form-input {
padding-right: 2.25rem;
cursor: pointer;
}
}
.dark .form-input {
color: #f1f5f9;
}
.dark .form-input::placeholder {
color: #94a3b8;
}
@media (min-width: 640px) {
.dark .form-input {
border-color: #475569;
background: #1e293b;
}
.dark .form-input:focus {
border-color: #0ea5e9;
background: #334155;
}
}
/* ==================== Animations ==================== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out;
}

View File

@@ -1,280 +0,0 @@
"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>
);
}

View File

@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navbar from "./components/Navbar";
import { ThemeProvider } from "./context/ThemeContext";
import { getSiteConfig } from "@/config";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,24 +14,50 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "游牧中国 - 数字游民的理想目的地",
description:
"发现全球最适合远程工作和生活的城市,加入中国数字游民社区,探索远程办公新方式",
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export const metadata: Metadata = {
title: getSiteConfig().meta.title,
description: getSiteConfig().meta.description,
};
function ThemeScript() {
return (
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
`,
}}
/>
);
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<html lang="zh-CN" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Navbar />
{children}
<ThemeScript />
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);

5
app/lib/env.ts Normal file
View File

@@ -0,0 +1,5 @@
const isDev = process.env.NODE_ENV === "development";
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ||
(isDev ? "http://localhost:3000" : "https://nomadcna.cn");

22
app/lib/locale-link.tsx Normal file
View File

@@ -0,0 +1,22 @@
"use client";
import NextLink from "next/link";
import { usePathname as useNextPathname } from "next/navigation";
import { useLocale } from "../context/LocaleContext";
type LocaleLinkProps = React.ComponentProps<typeof NextLink>;
export function LocaleLink({ href, ...props }: LocaleLinkProps) {
const locale = useLocale();
const hrefStr = typeof href === "string" ? href : href.pathname ?? "/";
const localeHref = hrefStr.startsWith("/")
? `/${locale}${hrefStr === "/" ? "" : hrefStr}`
: hrefStr;
return <NextLink href={localeHref} {...props} />;
}
export function usePathname() {
const path = useNextPathname();
const match = path?.match(/^\/(zh|en)(\/.*)?$/);
return match ? (match[2] ?? "/") : path ?? "/";
}

51
app/lib/minio/client.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* MinIO 客户端封装 - 上传、下载
*/
import * as Minio from "minio";
import { getMinioConfig } from "./config";
export function getMinioClient(): Minio.Client {
const cfg = getMinioConfig();
return new Minio.Client({
endPoint: cfg.endPoint,
port: cfg.port,
useSSL: cfg.useSSL,
accessKey: cfg.accessKey,
secretKey: cfg.secretKey,
pathStyle: true,
region: cfg.region,
});
}
export interface UploadResult {
url: string;
objectKey: string;
}
export async function uploadBuffer(
buffer: Buffer,
objectKey: string,
contentType: string = "application/octet-stream"
): Promise<UploadResult> {
const cfg = getMinioConfig();
if (!cfg.enabled) {
throw new Error("MinIO 存储未启用");
}
const client = getMinioClient();
await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
"Content-Type": contentType,
});
const url = `${cfg.publicUrl}/${objectKey}`;
return { url, objectKey };
}
export function generateObjectKey(ext: string): string {
const cfg = getMinioConfig();
const prefix = cfg.uploadPrefix.replace(/\/$/, "");
return `${prefix}/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
}

2
app/lib/minio/config.ts Normal file
View File

@@ -0,0 +1,2 @@
export { getMinioConfig } from "@/config/services.config";
export type { MinioConfig } from "@/config/services.config";

8
app/lib/minio/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export { getMinioConfig } from "./config";
export type { MinioConfig } from "./config";
export {
getMinioClient,
uploadBuffer,
generateObjectKey,
} from "./client";
export type { UploadResult } from "./client";

39
app/lib/payEnv.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* 支付环境检测:PC / H5 / 微信浏览器
*/
export type PayEnv = "pc" | "h5" | "wechat";
export type PayChannel = "alipay" | "wxpay";
export function isWeChat(): boolean {
if (typeof navigator === "undefined") return false;
return /MicroMessenger/i.test(navigator.userAgent);
}
export function isAlipay(): boolean {
if (typeof navigator === "undefined") return false;
return /AlipayClient|Alipay/i.test(navigator.userAgent);
}
export function isMobile(): boolean {
if (typeof navigator === "undefined") return false;
return /AppleWebKit.*Mobile|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
}
export function getPayEnv(): PayEnv {
if (isWeChat()) return "wechat";
if (isMobile()) return "h5";
return "pc";
}
export function getRecommendedChannel(env: PayEnv): PayChannel {
if (env === "wechat") return "wxpay";
return "alipay";
}
export function mustUseWxPay(env: PayEnv): boolean {
return env === "wechat";
}

53
app/lib/payment/client.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* ZPay 支付客户端封装 - 发起支付、跳转支付页
*/
import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config";
import type { PayChannel, PayEnv } from "./types";
export function redirectToPay(params: {
user_id: string;
return_url: string;
total_fee?: number;
type?: string;
channel: PayChannel;
device: "pc" | "h5" | "wechat";
useJoinConfig?: boolean;
}): void {
const baseConfig = getPaymentConfig();
const joinConfig = params.useJoinConfig ? getJoinPaymentConfig() : null;
const total_fee = params.total_fee ?? joinConfig?.amount ?? baseConfig.defaultAmount;
const type = params.type ?? joinConfig?.type ?? baseConfig.defaultType;
const payForm = document.createElement("form");
payForm.method = "POST";
payForm.action = "/api/pay";
payForm.target = "_self";
payForm.style.display = "none";
const fields: [string, string][] = [
["user_id", params.user_id],
["return_url", params.return_url],
["total_fee", String(total_fee)],
["type", type],
["channel", params.channel],
["device", params.device],
["html", "1"],
];
fields.forEach(([k, v]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = k;
input.value = v;
payForm.appendChild(input);
});
document.body.appendChild(payForm);
payForm.submit();
}
export function getDeviceFromEnv(env: PayEnv): "pc" | "h5" | "wechat" {
if (env === "wechat") return "wechat";
return env === "pc" ? "pc" : "h5";
}

View File

@@ -0,0 +1,2 @@
export { getPaymentConfig } from "@/config/services.config";
export type { PaymentConfig } from "@/config/services.config";

5
app/lib/payment/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { redirectToPay, getDeviceFromEnv } from "./client";
export { getPayEnv, getRecommendedChannel, mustUseWxPay } from "../payEnv";
export { usePayStatusPoll } from "./usePayStatusPoll";
export type { PayChannel, PayEnv, CreatePayParams } from "./types";
export { getPaymentConfig } from "./config";

11
app/lib/payment/types.ts Normal file
View File

@@ -0,0 +1,11 @@
export type PayChannel = "alipay" | "wxpay";
export type PayEnv = "pc" | "h5" | "wechat";
export interface CreatePayParams {
user_id: string;
return_url: string;
total_fee?: number;
type?: string;
channel?: PayChannel;
device?: "pc" | "h5" | "wechat";
}

View File

@@ -0,0 +1,74 @@
/**
* 微信 H5 支付完成后可能不跳转,通过 cookie 中的订单号轮询检测支付状态
*/
import { useEffect, useRef, useState } from "react";
const COOKIE_NAME = "join_pay_order";
const POLL_INTERVAL = 2000;
const MAX_POLLS = 150;
const MAX_AGE_MS = 15 * 60 * 1000;
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function clearCookie(name: string): void {
if (typeof document === "undefined") return;
document.cookie = `${name}=; path=/; max-age=0`;
}
function parsePayOrderCookie(raw: string | null): [string | null, boolean] {
if (!raw?.trim()) return [null, false];
const parts = raw.split("|");
const orderId = parts[0]?.trim() || raw.trim();
const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
if (!orderId) return [null, false];
if (Number.isNaN(ts)) return [orderId, false];
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false];
return [orderId, true];
}
export function usePayStatusPoll(onPaid: () => void): boolean {
const [polling, setPolling] = useState(false);
const onPaidRef = useRef(onPaid);
onPaidRef.current = onPaid;
useEffect(() => {
const raw = getCookie(COOKIE_NAME);
const [orderId, isValid] = parsePayOrderCookie(raw);
if (!orderId || !isValid) {
if (raw) clearCookie(COOKIE_NAME);
return;
}
setPolling(true);
let count = 0;
const timer = setInterval(async () => {
count++;
if (count > MAX_POLLS) {
clearInterval(timer);
clearCookie(COOKIE_NAME);
setPolling(false);
return;
}
try {
const res = await fetch(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`);
const data = await res.json();
if (data?.ok && data?.paid) {
clearInterval(timer);
clearCookie(COOKIE_NAME);
setPolling(false);
onPaidRef.current();
}
} catch {
// 继续轮询
}
}, POLL_INTERVAL);
return () => clearInterval(timer);
}, []);
return polling;
}

View File

@@ -0,0 +1,91 @@
/**
* PocketBase 认证服务 - 邮箱登录、注册
*/
import { getPocketBaseConfig } from "@/config/services.config";
import { PB_STORAGE_KEYS } from "./constants";
export interface AuthUser {
id: string;
email: string;
[key: string]: unknown;
}
export interface AuthResult {
token: string;
record: AuthUser;
}
function getPBUrl(): string {
return getPocketBaseConfig().url;
}
export async function pbLogin(
identity: string,
password: string
): Promise<AuthResult> {
const url = getPBUrl();
const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity, password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "登录失败");
}
const data = await res.json();
if (!data?.token) throw new Error("登录响应异常");
return { token: data.token, record: data.record };
}
export async function pbRegister(
email: string,
password: string,
passwordConfirm: string
): Promise<AuthResult> {
const url = getPBUrl();
const config = getPocketBaseConfig();
const res = await fetch(
`${url}/api/collections/${config.usersCollection}/records`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, passwordConfirm }),
}
);
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "注册失败");
}
return pbLogin(email, password);
}
export function pbSaveAuth(token: string, record: AuthUser): void {
if (typeof window === "undefined") return;
localStorage.setItem(PB_STORAGE_KEYS.token, token);
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
}
export function pbLogout(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(PB_STORAGE_KEYS.token);
localStorage.removeItem(PB_STORAGE_KEYS.user);
}
export function getStoredUserEmail(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
if (!raw) return null;
const user = JSON.parse(raw);
return user?.email ?? null;
} catch {
return null;
}
}
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(PB_STORAGE_KEYS.token);
}

View File

@@ -0,0 +1,2 @@
export { getPocketBaseConfig } from "@/config/services.config";
export type { PocketBaseConfig } from "@/config/services.config";

View File

@@ -0,0 +1,4 @@
export const PB_STORAGE_KEYS = {
token: "pb_token",
user: "pb_user",
} as const;

View File

@@ -0,0 +1,11 @@
export {
pbLogin,
pbRegister,
pbSaveAuth,
pbLogout,
getStoredUserEmail,
getStoredToken,
} from "./auth";
export type { AuthUser, AuthResult } from "./auth";
export { PB_STORAGE_KEYS } from "./constants";
export { getPocketBaseConfig } from "./config";

15
app/lib/theme-data.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* 多语言消息加载
*/
export type Locale = "zh" | "en";
export async function getThemeMessages(
locale: Locale
): Promise<Record<string, unknown>> {
const mod =
locale === "zh"
? await import("@/messages/zh.json")
: await import("@/messages/en.json");
return mod.default as Record<string, unknown>;
}

View File

@@ -1,441 +0,0 @@
"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>
);
}

12
config/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { SITE_CONFIG, getSiteConfig } from "./site.config";
export {
getPocketBaseConfig,
getPaymentConfig,
getJoinPaymentConfig,
getMinioConfig,
} from "./services.config";
export type {
PocketBaseConfig,
PaymentConfig,
MinioConfig,
} from "./services.config";

94
config/services.config.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* 服务配置 - PocketBase、支付、MinIO
*/
export interface PocketBaseConfig {
enabled: boolean;
url: string;
usersCollection: string;
joinCollection: string;
}
export interface PaymentConfig {
enabled: boolean;
apiUrl: string;
provider: "zpay" | "xorpay";
defaultAmount: number;
defaultType: string;
zpaySubmitUrl: string;
}
export interface MinioConfig {
enabled: boolean;
endPoint: string;
port: number;
useSSL: boolean;
bucket: string;
accessKey: string;
secretKey: string;
region: string;
publicUrl: string;
uploadPrefix: string;
}
const isDev = process.env.NODE_ENV === "development";
export function getPocketBaseConfig(): PocketBaseConfig {
return {
enabled: true,
url:
process.env.NEXT_PUBLIC_POCKETBASE_URL ||
process.env.POCKETBASE_URL ||
"https://pocketbase.hackrobot.cn",
usersCollection: "users",
joinCollection: process.env.POCKETBASE_JOIN_COLLECTION || "solan",
};
}
export function getPaymentConfig(): PaymentConfig {
return {
enabled: true,
apiUrl:
process.env.PAYMENT_API_URL ||
(isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"),
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 80,
defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup",
zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
};
}
/** 获取「加入」场景的支付配置 */
export function getJoinPaymentConfig(): { amount: number; type: string } {
const payment = getPaymentConfig();
return {
amount: Number(process.env.PAYMENT_JOIN_AMOUNT) || payment.defaultAmount,
type: process.env.PAYMENT_JOIN_TYPE || payment.defaultType,
};
}
export function getMinioConfig(themeOverride?: {
bucket?: string;
uploadPrefix?: string;
}): MinioConfig {
const endPoint = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn";
const port = parseInt(process.env.MINIO_PORT || "9035", 10);
const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot";
const publicUrl =
process.env.MINIO_PUBLIC_URL || `https://${endPoint}/${bucket}`;
const uploadPrefix =
themeOverride?.uploadPrefix || process.env.MINIO_UPLOAD_PREFIX || "joins";
return {
enabled: true,
endPoint,
port,
useSSL: process.env.MINIO_USE_SSL === "true",
bucket,
accessKey: process.env.MINIO_ACCESS_KEY || "",
secretKey: process.env.MINIO_SECRET_KEY || "",
region: process.env.MINIO_REGION || "china",
publicUrl: publicUrl.replace(/\/$/, ""),
uploadPrefix,
};
}

15
config/site.config.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* 游牧中国站点配置
*/
export const SITE_CONFIG = {
meta: {
title: "游牧中国 - 数字游民的理想目的地",
description:
"发现全球最适合远程工作和生活的城市,加入中国数字游民社区,探索远程办公新方式",
},
};
export function getSiteConfig() {
return SITE_CONFIG;
}

9
i18n/navigation.ts Normal file
View File

@@ -0,0 +1,9 @@
"use client";
export { LocaleLink as Link } from "@/app/lib/locale-link";
export { usePathname } from "@/app/lib/locale-link";
export {
useLocale,
useLocaleRouter as useRouter,
useTranslation,
} from "@/app/context/LocaleContext";

5
i18n/routing.ts Normal file
View File

@@ -0,0 +1,5 @@
export const routing = {
locales: ["zh", "en"] as const,
defaultLocale: "zh" as const,
localePrefix: "always" as const,
};

138
messages/en.json Normal file
View File

@@ -0,0 +1,138 @@
{
"common": {
"siteName": "Nomad China",
"siteNameShort": "NomadCNA",
"logout": "Logout",
"loginRegister": "Login / Sign up",
"explore": "Explore →",
"toggleMenu": "Toggle menu"
},
"nav": {
"cities": "Cities",
"travel": "Travel",
"community": "Community",
"explore": "Explore"
},
"hero": {
"badge": "China Digital Nomad Community · Since 2024",
"title": "Explore China",
"titleSuffix": "Remote Work, Free Life",
"subtitle": "Join the global Chinese digital nomad community, discover the best cities in China for remote work and living",
"ctaJoin": "Join Nomad China →",
"stats": {
"cities": "Cities",
"nomads": "Nomads",
"meetups": "Meetups/month",
"cost": "Monthly cost from"
}
},
"footer": {
"community": "Community",
"tools": "Tools",
"resources": "Resources",
"about": "About",
"links": {
"join": "Join Community",
"meetups": "Offline Meetups",
"dating": "Dating",
"map": "Member Map",
"climate": "Climate Query",
"cost": "Cost Calculator",
"coworking": "Coworking Search",
"compare": "City Compare",
"guide": "Remote Work Guide",
"cityGuide": "City Guide",
"stories": "Nomad Stories",
"faq": "FAQ",
"aboutUs": "About Us",
"recruit": "Careers",
"terms": "Terms",
"privacy": "Privacy",
"contact": "Contact"
},
"copyright": "© 2024-2026 Nomad China. All rights reserved."
},
"cityModal": {
"breadcrumbPrefix": "Cost of Living",
"reviews": "reviews",
"favorite": "Favorite",
"favorited": "Favorited",
"close": "Close",
"comingSoon": "Coming soon",
"tabs": {
"guide": "Digital Nomad Guide",
"pros": "Pros and Cons",
"reviews": "Reviews",
"cost": "Cost of Living",
"people": "People",
"chat": "Chat",
"photos": "Photos",
"weather": "Weather",
"trends": "Trends",
"demographics": "Demographics",
"scores": "Scores"
},
"scores": {
"nomadScore": "Nomad Score™",
"likedByMembers": "Liked by members",
"qualityOfLife": "Quality of life score",
"familyScore": "Family score",
"communityScore": "Community score",
"nomadDensity": "Overcrowding score",
"cost": "Cost",
"internet": "Internet",
"fun": "Fun",
"temperature": "Temperature (now)",
"humidity": "Humidity (now)",
"acPercent": "% of buildings with AC",
"nomadHere": "is nomad here",
"likedIt": "liked it",
"dislikedIt": "disliked it",
"rank": "Rank",
"perMonth": "mo",
"avg": "avg",
"fast": "Fast",
"medium": "Medium",
"slow": "Slow",
"affordable": "Affordable",
"moderate": "Moderate",
"expensive": "Expensive",
"comfy": "Comfy",
"humid": "Humid",
"veryHigh": "Very high",
"high": "High",
"normal": "Normal",
"tooHot": "Too hot",
"warm": "Warm",
"comfortable": "Comfortable",
"cold": "Cold",
"feelsLike": "feels",
"scoreLabels": {
"great": "Great",
"good": "Good",
"okay": "Okay",
"bad": "Bad"
}
},
"region": "Asia",
"map": "Map"
},
"auth": {
"title": "Login / Sign up",
"login": "Login",
"register": "Register",
"email": "Email",
"password": "Password",
"passwordConfirm": "Confirm Password",
"submit": "Login",
"close": "Close",
"loginHint": "Login with email and password",
"registerHint": "Create new account",
"noAccount": "No account yet?",
"hasAccount": "Already have an account?",
"signUpNow": "Sign up now",
"goLogin": "Go to login",
"passwordMismatch": "Passwords do not match",
"passwordMinLength": "Password must be at least 8 characters"
}
}

138
messages/zh.json Normal file
View File

@@ -0,0 +1,138 @@
{
"common": {
"siteName": "游牧中国",
"siteNameShort": "游牧中国",
"logout": "退出",
"loginRegister": "登录 / 注册",
"explore": "开始探索 →",
"toggleMenu": "切换菜单"
},
"nav": {
"cities": "城市",
"travel": "旅行",
"community": "社区",
"explore": "探索"
},
"hero": {
"badge": "中国数字游民社区 · 始于 2024",
"title": "探索中国",
"titleSuffix": "远程工作,自由生活",
"subtitle": "加入全球华人数字游民社区,发现中国最适合远程工作和生活的城市",
"ctaJoin": "加入游牧中国 →",
"stats": {
"cities": "个城市",
"nomads": "位游民",
"meetups": "场聚会/月",
"cost": "起/月生活费"
}
},
"footer": {
"community": "社区",
"tools": "工具",
"resources": "资源",
"about": "关于",
"links": {
"join": "加入社群",
"meetups": "线下聚会",
"dating": "交友约会",
"map": "成员地图",
"climate": "气候查询",
"cost": "生活成本计算",
"coworking": "共享办公搜索",
"compare": "城市对比",
"guide": "远程工作指南",
"cityGuide": "城市落地攻略",
"stories": "游民故事",
"faq": "常见问题",
"aboutUs": "关于我们",
"recruit": "招聘",
"terms": "使用条款",
"privacy": "隐私政策",
"contact": "联系我们"
},
"copyright": "© 2024-2026 游牧中国 NomadCNA. All rights reserved."
},
"cityModal": {
"breadcrumbPrefix": "生活成本",
"reviews": "条评价",
"favorite": "收藏",
"favorited": "已收藏",
"close": "关闭",
"comingSoon": "内容开发中,敬请期待",
"tabs": {
"guide": "数字游民指南",
"pros": "优缺点",
"reviews": "评价",
"cost": "生活成本",
"people": "人群",
"chat": "聊天",
"photos": "照片",
"weather": "天气",
"trends": "趋势",
"demographics": "人口",
"scores": "评分"
},
"scores": {
"nomadScore": "游民评分™",
"likedByMembers": "成员喜好",
"qualityOfLife": "生活质量",
"familyScore": "家庭友好",
"communityScore": "社区氛围",
"nomadDensity": "游民密度",
"cost": "费用",
"internet": "网络",
"fun": "趣味",
"temperature": "气温(当前)",
"humidity": "湿度(当前)",
"acPercent": "空调普及率",
"nomadHere": "游民在此",
"likedIt": "喜欢",
"dislikedIt": "不喜欢",
"rank": "排名",
"perMonth": "月",
"avg": "平均",
"fast": "快",
"medium": "一般",
"slow": "慢",
"affordable": "实惠",
"moderate": "适中",
"expensive": "较贵",
"comfy": "舒适",
"humid": "潮湿",
"veryHigh": "很高",
"high": "高",
"normal": "一般",
"tooHot": "太热",
"warm": "温暖",
"comfortable": "舒适",
"cold": "偏冷",
"feelsLike": "体感",
"scoreLabels": {
"great": "极好",
"good": "好",
"okay": "一般",
"bad": "差"
}
},
"region": "亚洲",
"map": "地图"
},
"auth": {
"title": "登录 / 注册",
"login": "登录",
"register": "注册",
"email": "邮箱",
"password": "密码",
"passwordConfirm": "确认密码",
"submit": "登录",
"close": "关闭",
"loginHint": "使用邮箱密码登录",
"registerHint": "创建新账号",
"noAccount": "还没有账号?",
"hasAccount": "已有账号?",
"signUpNow": "立即注册",
"goLogin": "去登录",
"passwordMismatch": "两次密码不一致",
"passwordMinLength": "密码至少 8 位"
}
}

29
middleware.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const LOCALES = ["zh", "en"];
const DEFAULT_LOCALE = "zh";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = LOCALES.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
);
if (hasLocale) {
return NextResponse.next();
}
if (pathname.startsWith("/_next") || pathname.startsWith("/api") || pathname.includes(".")) {
return NextResponse.next();
}
const locale = DEFAULT_LOCALE;
const newUrl = new URL(`/${locale}${pathname === "/" ? "" : pathname}`, request.url);
return NextResponse.redirect(newUrl);
}
export const config = {
matcher: ["/((?!_next|api|favicon.ico|.*\\..*).*)"],
};

View File

@@ -9,7 +9,9 @@
"lint": "eslint"
},
"dependencies": {
"minio": "^8.0.7",
"next": "16.1.6",
"pocketbase": "^0.26.8",
"react": "19.2.3",
"react-dom": "19.2.3"
},

210
pnpm-lock.yaml generated
View File

@@ -8,9 +8,15 @@ importers:
.:
dependencies:
minio:
specifier: ^8.0.7
version: 8.0.7
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
pocketbase:
specifier: ^0.26.8
version: 0.26.8
react:
specifier: 19.2.3
version: 19.2.3
@@ -739,6 +745,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -763,6 +772,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
block-stream2@2.1.0:
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -774,11 +786,18 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browser-or-node@2.1.1:
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -857,6 +876,10 @@ packages:
supports-color:
optional: true
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1050,6 +1073,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1063,6 +1089,13 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-xml-builder@1.0.0:
resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==}
fast-xml-parser@5.4.2:
resolution: {integrity: sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==}
hasBin: true
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -1083,6 +1116,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
filter-obj@1.1.0:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -1207,10 +1244,17 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
ipaddr.js@2.3.0:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -1453,6 +1497,9 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -1475,6 +1522,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@@ -1485,6 +1540,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minio@8.0.7:
resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==}
engines: {node: ^16 || ^18 || >=20}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1603,6 +1662,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pocketbase@0.26.8:
resolution: {integrity: sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -1626,6 +1688,10 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1641,6 +1707,10 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -1677,6 +1747,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@@ -1685,6 +1758,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
sax@1.5.0:
resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==}
engines: {node: '>=11.0.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1741,6 +1818,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -1748,6 +1829,16 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stream-chain@2.2.5:
resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==}
stream-json@1.9.1:
resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
@@ -1771,6 +1862,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -1779,6 +1873,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strnum@2.2.0:
resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -1807,6 +1904,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -1878,6 +1978,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -1903,6 +2006,14 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -2603,6 +2714,8 @@ snapshots:
async-function@1.0.0: {}
async@3.2.6: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -2617,6 +2730,10 @@ snapshots:
baseline-browser-mapping@2.10.0: {}
block-stream2@2.1.0:
dependencies:
readable-stream: 3.6.2
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -2630,6 +2747,8 @@ snapshots:
dependencies:
fill-range: 7.1.1
browser-or-node@2.1.1: {}
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.10.0
@@ -2638,6 +2757,8 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-crc32@1.0.0: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -2712,6 +2833,8 @@ snapshots:
dependencies:
ms: 2.1.3
decode-uri-component@0.2.2: {}
deep-is@0.1.4: {}
define-data-property@1.1.4:
@@ -3057,6 +3180,8 @@ snapshots:
esutils@2.0.3: {}
eventemitter3@5.0.4: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -3071,6 +3196,13 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-xml-builder@1.0.0: {}
fast-xml-parser@5.4.2:
dependencies:
fast-xml-builder: 1.0.0
strnum: 2.2.0
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -3087,6 +3219,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
filter-obj@1.1.0: {}
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -3208,12 +3342,16 @@ snapshots:
imurmurhash@0.1.4: {}
inherits@2.0.4: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
hasown: 2.0.2
side-channel: 1.1.0
ipaddr.js@2.3.0: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -3438,6 +3576,8 @@ snapshots:
lodash.merge@4.6.2: {}
lodash@4.17.23: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -3459,6 +3599,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
@@ -3469,6 +3615,22 @@ snapshots:
minimist@1.2.8: {}
minio@8.0.7:
dependencies:
async: 3.2.6
block-stream2: 2.1.0
browser-or-node: 2.1.1
buffer-crc32: 1.0.0
eventemitter3: 5.0.4
fast-xml-parser: 5.4.2
ipaddr.js: 2.3.0
lodash: 4.17.23
mime-types: 2.1.35
query-string: 7.1.3
stream-json: 1.9.1
through2: 4.0.2
xml2js: 0.6.2
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -3591,6 +3753,8 @@ snapshots:
picomatch@4.0.3: {}
pocketbase@0.26.8: {}
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@@ -3615,6 +3779,13 @@ snapshots:
punycode@2.3.1: {}
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
filter-obj: 1.1.0
split-on-first: 1.1.0
strict-uri-encode: 2.0.0
queue-microtask@1.2.3: {}
react-dom@19.2.3(react@19.2.3):
@@ -3626,6 +3797,12 @@ snapshots:
react@19.2.3: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -3679,6 +3856,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -3690,6 +3869,8 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
sax@1.5.0: {}
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -3786,6 +3967,8 @@ snapshots:
source-map-js@1.2.1: {}
split-on-first@1.1.0: {}
stable-hash@0.0.5: {}
stop-iteration-iterator@1.1.0:
@@ -3793,6 +3976,14 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stream-chain@2.2.5: {}
stream-json@1.9.1:
dependencies:
stream-chain: 2.2.5
strict-uri-encode@2.0.0: {}
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.8
@@ -3843,10 +4034,16 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
strnum@2.2.0: {}
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3):
dependencies:
client-only: 0.0.1
@@ -3864,6 +4061,10 @@ snapshots:
tapable@2.3.0: {}
through2@4.0.2:
dependencies:
readable-stream: 3.6.2
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -3979,6 +4180,8 @@ snapshots:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2: {}
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -4026,6 +4229,13 @@ snapshots:
word-wrap@1.2.5: {}
xml2js@0.6.2:
dependencies:
sax: 1.5.0
xmlbuilder: 11.0.1
xmlbuilder@11.0.1: {}
yallist@3.1.1: {}
yocto-queue@0.1.0: {}