'城市详情'
This commit is contained in:
37
app/[locale]/about/page.tsx
Normal file
37
app/[locale]/about/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
152
app/[locale]/dating/page.tsx
Normal file
152
app/[locale]/dating/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/[locale]/jobs/page.tsx
Normal file
37
app/[locale]/jobs/page.tsx
Normal 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
506
app/[locale]/join/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/[locale]/join/paid/page.tsx
Normal file
31
app/[locale]/join/paid/page.tsx
Normal 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
40
app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
app/[locale]/meetups/page.tsx
Normal file
134
app/[locale]/meetups/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
114
app/api/join/route.ts
Normal 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
97
app/api/pay/route.ts
Normal 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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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 });
|
||||
}
|
||||
}
|
||||
23
app/api/pay/status/route.ts
Normal file
23
app/api/pay/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
39
app/api/upload-media/route.ts
Normal file
39
app/api/upload-media/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
179
app/components/AuthModal.tsx
Normal file
179
app/components/AuthModal.tsx
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
298
app/components/CityModal.tsx
Normal file
298
app/components/CityModal.tsx
Normal 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")} > {region} > {city.country} > {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
97
app/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
47
app/components/ThemeToggle.tsx
Normal file
47
app/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
app/context/LocaleContext.tsx
Normal file
100
app/context/LocaleContext.tsx
Normal 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 };
|
||||
68
app/context/ThemeContext.tsx
Normal file
68
app/context/ThemeContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
5
app/lib/env.ts
Normal 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
22
app/lib/locale-link.tsx
Normal 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
51
app/lib/minio/client.ts
Normal 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
2
app/lib/minio/config.ts
Normal 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
8
app/lib/minio/index.ts
Normal 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
39
app/lib/payEnv.ts
Normal 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
53
app/lib/payment/client.ts
Normal 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";
|
||||
}
|
||||
2
app/lib/payment/config.ts
Normal file
2
app/lib/payment/config.ts
Normal 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
5
app/lib/payment/index.ts
Normal 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
11
app/lib/payment/types.ts
Normal 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";
|
||||
}
|
||||
74
app/lib/payment/usePayStatusPoll.ts
Normal file
74
app/lib/payment/usePayStatusPoll.ts
Normal 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;
|
||||
}
|
||||
91
app/lib/pocketbase/auth.ts
Normal file
91
app/lib/pocketbase/auth.ts
Normal 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);
|
||||
}
|
||||
2
app/lib/pocketbase/config.ts
Normal file
2
app/lib/pocketbase/config.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { getPocketBaseConfig } from "@/config/services.config";
|
||||
export type { PocketBaseConfig } from "@/config/services.config";
|
||||
4
app/lib/pocketbase/constants.ts
Normal file
4
app/lib/pocketbase/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const PB_STORAGE_KEYS = {
|
||||
token: "pb_token",
|
||||
user: "pb_user",
|
||||
} as const;
|
||||
11
app/lib/pocketbase/index.ts
Normal file
11
app/lib/pocketbase/index.ts
Normal 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
15
app/lib/theme-data.ts
Normal 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>;
|
||||
}
|
||||
@@ -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
12
config/index.ts
Normal 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
94
config/services.config.ts
Normal 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
15
config/site.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 游牧中国站点配置
|
||||
*/
|
||||
|
||||
export const SITE_CONFIG = {
|
||||
meta: {
|
||||
title: "游牧中国 - 数字游民的理想目的地",
|
||||
description:
|
||||
"发现全球最适合远程工作和生活的城市,加入中国数字游民社区,探索远程办公新方式",
|
||||
},
|
||||
};
|
||||
|
||||
export function getSiteConfig() {
|
||||
return SITE_CONFIG;
|
||||
}
|
||||
9
i18n/navigation.ts
Normal file
9
i18n/navigation.ts
Normal 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
5
i18n/routing.ts
Normal 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
138
messages/en.json
Normal 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
138
messages/zh.json
Normal 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
29
middleware.ts
Normal 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|.*\\..*).*)"],
|
||||
};
|
||||
@@ -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
210
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user