This commit is contained in:
root
2026-06-08 10:02:16 +08:00
parent 934b9a4a23
commit b65908e328
24 changed files with 1216 additions and 93 deletions

View File

@@ -0,0 +1,392 @@
"use client";
import { use, useState, type FormEvent } from "react";
import { Link, useLocale } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
import { citySlug, findCityBySlug } from "@/app/lib/city-slugs";
interface CityVolunteerPageProps {
params: Promise<{ slug: string }>;
}
interface VolunteerForm {
applicantName: string;
email: string;
wechat: string;
phone: string;
currentCity: string;
availability: string;
localExperience: string;
motivation: string;
links: string;
roles: string[];
}
const ROLE_OPTIONS = {
zh: ["城市资料维护", "新成员 CoffeeChat", "地陪路线协助", "Citywalk/徒步带队", "同城活动协助", "线上社群答疑"],
en: ["City data updates", "New member CoffeeChat", "Local guide routes", "Citywalk / hiking host", "City event support", "Online community support"],
};
const copy = {
zh: {
notFound: "城市不存在",
back: "返回首页",
kicker: "城市志愿者招募",
titleSuffix: "城市志愿者申请",
subtitle: "帮助维护城市资料、迎新 CoffeeChat、组织同城活动或带 Citywalk。提交后会进入审核状态。",
status: "提交后状态:待审核",
applicantName: "姓名 / 昵称",
applicantNamePlaceholder: "方便社区联系你的称呼",
email: "邮箱",
wechat: "微信",
phone: "手机",
currentCity: "当前所在城市",
availability: "可投入时间",
availabilityPlaceholder: "例如:每周 2 小时 / 周末可带活动",
roles: "想参与的志愿者方向",
localExperience: "你和这个城市的关系",
localExperiencePlaceholder: "例如:已在这里居住 1 年,熟悉咖啡馆、短租和徒步路线。",
motivation: "申请说明",
motivationPlaceholder: "你希望如何帮助这个城市的新成员?",
links: "补充资料链接",
linksPlaceholder: "可选:个人主页、社群资料、作品链接等",
contactHint: "邮箱、微信、手机至少填写一项。",
roleHint: "至少选择一个志愿者方向。",
submit: "提交申请",
submitting: "提交中...",
successTitle: "申请已提交",
successDesc: "当前状态:待审核。审核通过后,社区会把你加入该城市的志愿者协作名单。",
submitAnother: "再提交一份",
viewCity: "返回城市列表",
},
en: {
notFound: "City not found",
back: "Back home",
kicker: "City volunteer recruiting",
titleSuffix: "Volunteer application",
subtitle: "Help maintain city data, welcome new members, support local events, or host citywalks. Applications enter pending review after submission.",
status: "After submission: pending review",
applicantName: "Name / nickname",
applicantNamePlaceholder: "How the community should contact you",
email: "Email",
wechat: "WeChat",
phone: "Phone",
currentCity: "Current city",
availability: "Availability",
availabilityPlaceholder: "e.g. 2 hours weekly / weekends for events",
roles: "Volunteer areas",
localExperience: "Your connection to this city",
localExperiencePlaceholder: "e.g. I have lived here for one year and know cafes, short rentals, and hiking routes.",
motivation: "Application note",
motivationPlaceholder: "How would you help new members in this city?",
links: "Supporting links",
linksPlaceholder: "Optional: profile, community record, portfolio links",
contactHint: "Please provide at least one contact: email, WeChat, or phone.",
roleHint: "Please select at least one volunteer area.",
submit: "Submit application",
submitting: "Submitting...",
successTitle: "Application submitted",
successDesc: "Current status: pending review. Once approved, the community will add you to this city's volunteer roster.",
submitAnother: "Submit another",
viewCity: "Back to cities",
},
};
function getCopy(locale: string) {
return locale === "zh" ? copy.zh : copy.en;
}
const initialForm: VolunteerForm = {
applicantName: "",
email: "",
wechat: "",
phone: "",
currentCity: "",
availability: "",
localExperience: "",
motivation: "",
links: "",
roles: [],
};
export default function CityVolunteerPage({ params }: CityVolunteerPageProps) {
const { slug } = use(params);
const city = findCityBySlug(slug);
const locale = useLocale();
const c = getCopy(locale);
const roleOptions = locale === "zh" ? ROLE_OPTIONS.zh : ROLE_OPTIONS.en;
const [form, setForm] = useState<VolunteerForm>(initialForm);
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!city) {
return (
<main className="min-h-screen bg-[#fafafa] px-4 py-16 dark:bg-gray-950">
<section className="mx-auto max-w-[560px] rounded-2xl border border-gray-100 bg-white p-8 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{c.notFound}</h1>
<Link href="/" className="mt-6 inline-flex rounded-xl bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#ff7a45]">
{c.back}
</Link>
</section>
</main>
);
}
const slugToSubmit = citySlug(city);
const setField = <K extends keyof VolunteerForm>(field: K, value: VolunteerForm[K]) => {
setForm((current) => ({ ...current, [field]: value }));
};
const toggleRole = (role: string) => {
setForm((current) => {
const selected = new Set(current.roles);
if (selected.has(role)) selected.delete(role);
else selected.add(role);
return { ...current, roles: Array.from(selected) };
});
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
if (!form.email.trim() && !form.wechat.trim() && !form.phone.trim()) {
setError(c.contactHint);
return;
}
if (form.roles.length === 0) {
setError(c.roleHint);
return;
}
setSubmitting(true);
try {
await apiFetch(`/api/cities/${slugToSubmit}/volunteer-applications`, {
method: "POST",
body: JSON.stringify({
citySlug: slugToSubmit,
cityName: city.name,
...form,
}),
});
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Submit failed");
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<main className="min-h-screen bg-[#fafafa] px-4 py-12 dark:bg-gray-950 sm:py-20">
<section className="mx-auto max-w-[620px] rounded-2xl border border-gray-100 bg-white p-8 text-center shadow-lg dark:border-gray-800 dark:bg-gray-900 sm:p-12">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-50 text-xl font-bold text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300">
OK
</div>
<p className="mb-2 text-sm font-semibold text-[#ff4d4f]">{city.name}</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{c.successTitle}</h1>
<p className="mt-3 leading-7 text-gray-500 dark:text-gray-400">{c.successDesc}</p>
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
<button
type="button"
onClick={() => {
setSubmitted(false);
setForm(initialForm);
}}
className="rounded-xl border border-gray-200 px-6 py-3 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
{c.submitAnother}
</button>
<Link href="/" className="rounded-xl bg-[#ff4d4f] px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#ff7a45]">
{c.viewCity}
</Link>
</div>
</section>
</main>
);
}
return (
<main className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<section
className="px-4 py-10 text-white sm:px-6 sm:py-14"
style={{ background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})` }}
>
<div className="mx-auto max-w-[920px]">
<Link href="/" className="mb-5 inline-flex text-sm font-medium text-white/80 transition-colors hover:text-white">
{c.back}
</Link>
<p className="text-sm font-semibold text-white/80">{c.kicker}</p>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-normal sm:text-4xl">
{city.name} {c.titleSuffix}
</h1>
<p className="mt-3 max-w-[660px] text-sm leading-7 text-white/85 sm:text-base">{c.subtitle}</p>
</div>
<span className="w-fit rounded-full bg-white/20 px-3 py-1.5 text-sm font-semibold text-white backdrop-blur-sm">
{c.status}
</span>
</div>
</div>
</section>
<section className="mx-auto max-w-[920px] px-4 py-8 sm:px-6 sm:py-10">
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-8">
<div className="grid gap-4 sm:grid-cols-2">
<TextField
label={`${c.applicantName} *`}
value={form.applicantName}
required
placeholder={c.applicantNamePlaceholder}
onChange={(value) => setField("applicantName", value)}
/>
<TextField
label={c.currentCity}
value={form.currentCity}
placeholder={city.name}
onChange={(value) => setField("currentCity", value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<TextField label={c.email} type="email" value={form.email} onChange={(value) => setField("email", value)} />
<TextField label={c.wechat} value={form.wechat} onChange={(value) => setField("wechat", value)} />
<TextField label={c.phone} type="tel" value={form.phone} onChange={(value) => setField("phone", value)} />
</div>
<TextField
label={c.availability}
value={form.availability}
placeholder={c.availabilityPlaceholder}
onChange={(value) => setField("availability", value)}
/>
<fieldset>
<legend className="mb-3 block text-sm font-semibold text-gray-800 dark:text-gray-200">{c.roles} *</legend>
<div className="grid gap-2 sm:grid-cols-2">
{roleOptions.map((role) => {
const checked = form.roles.includes(role);
return (
<label
key={role}
className={`flex min-h-11 cursor-pointer items-center gap-3 rounded-xl border px-4 py-3 text-sm font-medium transition-colors ${
checked
? "border-[#ff4d4f] bg-red-50 text-[#c7252a] dark:border-red-500/70 dark:bg-red-950/20 dark:text-red-200"
: "border-gray-200 text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleRole(role)}
className="h-4 w-4 rounded border-gray-300 text-[#ff4d4f] focus:ring-[#ff4d4f]"
/>
<span>{role}</span>
</label>
);
})}
</div>
</fieldset>
<TextArea
label={`${c.localExperience} *`}
value={form.localExperience}
required
placeholder={c.localExperiencePlaceholder}
onChange={(value) => setField("localExperience", value)}
/>
<TextArea
label={`${c.motivation} *`}
value={form.motivation}
required
placeholder={c.motivationPlaceholder}
onChange={(value) => setField("motivation", value)}
/>
<TextArea
label={c.links}
value={form.links}
rows={3}
placeholder={c.linksPlaceholder}
onChange={(value) => setField("links", value)}
/>
{error && (
<p className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm font-medium text-red-700 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-300">
{error}
</p>
)}
<button
type="submit"
disabled={submitting}
className="min-h-12 w-full rounded-xl bg-[#ff4d4f] px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#ff7a45] disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
>
{submitting ? c.submitting : c.submit}
</button>
</form>
</section>
</main>
);
}
function TextField({
label,
value,
onChange,
placeholder = "",
type = "text",
required = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: string;
required?: boolean;
}) {
return (
<label className="block">
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
<input
type={type}
required={required}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/15 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</label>
);
}
function TextArea({
label,
value,
onChange,
placeholder = "",
rows = 4,
required = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
required?: boolean;
}) {
return (
<label className="block">
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
<textarea
rows={rows}
required={required}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="form-input w-full resize-none rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/15 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</label>
);
}

View File

@@ -5,6 +5,7 @@ import { Link, useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex, avatarForName } from "@/app/data/avatars";
type TabType = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore";
@@ -42,20 +43,20 @@ interface ProfileRecord {
}
const FALLBACK_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" },
{ id: "1", name: "林晓雨", age: 28, location: "大理", tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"], bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。", gradient: "from-rose-400 via-pink-400 to-fuchsia-400", initial: "林", avatarColor: "bg-rose-500", photo: avatarForIndex(0) },
{ id: "2", name: "陈浩然", age: 32, location: "成都", tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"], bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。", gradient: "from-amber-400 via-orange-400 to-red-400", initial: "陈", avatarColor: "bg-amber-500", photo: avatarForIndex(4) },
{ id: "3", name: "王思琪", age: 26, location: "深圳", tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"], bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。", gradient: "from-emerald-400 via-teal-400 to-cyan-400", initial: "王", avatarColor: "bg-emerald-500", photo: avatarForIndex(2) },
{ 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: avatarForIndex(28) },
{ 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: avatarForIndex(12) },
{ id: "6", name: "刘子轩", age: 33, location: "昆明", tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"], bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。", gradient: "from-blue-400 via-indigo-400 to-violet-500", initial: "刘", avatarColor: "bg-blue-500", photo: avatarForIndex(10) },
];
const FALLBACK_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" },
{ id: "m1", name: "周雨桐", location: "杭州", timeAgo: "2天前", initial: "周", color: "bg-pink-400", photo: avatarForIndex(6) },
{ id: "m2", name: "吴俊杰", location: "成都", timeAgo: "1周前", initial: "吴", color: "bg-amber-400", photo: avatarForIndex(30) },
{ id: "m3", name: "郑诗涵", location: "上海", timeAgo: "3周前", initial: "郑", color: "bg-emerald-400", photo: avatarForIndex(15) },
{ id: "m4", name: "孙宇航", location: "深圳", timeAgo: "1月前", initial: "孙", color: "bg-violet-400", photo: avatarForIndex(18) },
{ id: "m5", name: "赵梦琪", location: "厦门", timeAgo: "1年前", initial: "赵", color: "bg-rose-400", photo: avatarForIndex(24) },
];
const TAB_ORDER: TabType[] = ["friends", "dating", "partner", "roommate", "cofounder", "explore"];
@@ -81,7 +82,7 @@ function mapProfile(record: ProfileRecord, index: number): Profile {
gradient: PROFILE_GRADIENTS[index % PROFILE_GRADIENTS.length],
initial,
avatarColor: "bg-[#ff4d4f]",
photo: record.photo || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`,
photo: record.photo || avatarForName(name),
};
}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex, avatarForName } from "@/app/data/avatars";
interface Topic {
id: string;
@@ -37,7 +38,7 @@ const FALLBACK_TOPICS: Topic[] = [
id: "1",
title: "大理最佳共享办公空间推荐 Top 10",
author: "林晓",
avatar: "https://i.pravatar.cc/150?img=5",
avatar: avatarForIndex(0),
views: 2340,
replies: 56,
likes: 128,
@@ -49,7 +50,7 @@ const FALLBACK_TOPICS: Topic[] = [
id: "2",
title: "2026年数字游民友好城市排名",
author: "张浩",
avatar: "https://i.pravatar.cc/150?img=3",
avatar: avatarForIndex(4),
views: 1890,
replies: 42,
likes: 95,
@@ -61,7 +62,7 @@ const FALLBACK_TOPICS: Topic[] = [
id: "3",
title: "远程工作者的税务规划指南",
author: "陈悦",
avatar: "https://i.pravatar.cc/150?img=9",
avatar: avatarForIndex(2),
views: 1560,
replies: 38,
likes: 87,
@@ -73,7 +74,7 @@ const FALLBACK_TOPICS: Topic[] = [
id: "4",
title: "成都 vs 大理:哪个更适合游民?",
author: "周杰",
avatar: "https://i.pravatar.cc/150?img=7",
avatar: avatarForIndex(10),
views: 1230,
replies: 67,
likes: 54,
@@ -85,7 +86,7 @@ const FALLBACK_TOPICS: Topic[] = [
id: "5",
title: "新手游民必备工具与装备清单",
author: "吴婷",
avatar: "https://i.pravatar.cc/150?img=1",
avatar: avatarForIndex(12),
views: 980,
replies: 29,
likes: 76,
@@ -97,7 +98,7 @@ const FALLBACK_TOPICS: Topic[] = [
id: "6",
title: "如何在泰国办理长期签证?",
author: "杨帅",
avatar: "https://i.pravatar.cc/150?img=12",
avatar: avatarForIndex(28),
views: 2150,
replies: 89,
likes: 156,
@@ -109,7 +110,7 @@ const FALLBACK_TOPICS: Topic[] = [
id: "7",
title: "数字游民保险选择指南",
author: "赵琪",
avatar: "https://i.pravatar.cc/150?img=16",
avatar: avatarForIndex(6),
views: 1780,
replies: 45,
likes: 112,
@@ -121,7 +122,7 @@ const FALLBACK_TOPICS: Topic[] = [
id: "8",
title: "东南亚最佳数字游民目的地",
author: "黄磊",
avatar: "https://i.pravatar.cc/150?img=11",
avatar: avatarForIndex(30),
views: 3210,
replies: 78,
likes: 234,
@@ -149,7 +150,7 @@ function mapDiscussion(topic: DiscussionRecord, index: number): Topic {
id: topic.id,
title: topic.title,
author,
avatar: `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(author)}`,
avatar: avatarForName(author),
views: Number(topic.views || 0),
replies: Number(topic.replies || 0),
likes: Math.max(3, Math.round(Number(topic.views || 0) / 30) + Number(topic.replies || 0)),

View File

@@ -16,6 +16,7 @@ import {
WEB_CHAT_PROVIDER_LABEL,
} from "@/app/lib/meetups";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex } from "@/app/data/avatars";
interface Attendee {
name: string;
@@ -77,7 +78,7 @@ const allMeetups: Meetup[] = [
rsvpCount: 36,
gradientFrom: "#0f766e",
gradientTo: "#2563eb",
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" }],
attendees: [{ name: "林晓", photo: avatarForIndex(0) }, { name: "张明", photo: avatarForIndex(4) }, { name: "陈静", photo: avatarForIndex(2) }],
isUpcoming: true,
mode: "online",
accessLevel: "members",
@@ -101,7 +102,7 @@ const allMeetups: Meetup[] = [
rsvpCount: 12,
gradientFrom: "#3b82f6",
gradientTo: "#4f46e5",
attendees: [{ name: "林晓", photo: "https://i.pravatar.cc/150?img=4" }, { name: "张明", photo: "https://i.pravatar.cc/150?img=5" }, { name: "陈静", photo: "https://i.pravatar.cc/150?img=6" }],
attendees: [{ name: "林晓", photo: avatarForIndex(0) }, { name: "张明", photo: avatarForIndex(4) }, { name: "陈静", photo: avatarForIndex(2) }],
isUpcoming: true,
mode: "hybrid",
accessLevel: "vip",
@@ -125,7 +126,7 @@ const allMeetups: Meetup[] = [
rsvpCount: 9,
gradientFrom: "#22c55e",
gradientTo: "#10b981",
attendees: [{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=7" }, { name: "赵磊", photo: "https://i.pravatar.cc/150?img=8" }],
attendees: [{ name: "杨帆", photo: avatarForIndex(28) }, { name: "赵磊", photo: avatarForIndex(10) }],
isUpcoming: true,
mode: "offline",
accessLevel: "public",
@@ -149,7 +150,7 @@ const allMeetups: Meetup[] = [
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" }],
attendees: [{ name: "李娜", photo: avatarForIndex(12) }, { name: "王强", photo: avatarForIndex(18) }, { name: "刘洋", photo: avatarForIndex(30) }],
isUpcoming: true,
mode: "hybrid",
accessLevel: "members",
@@ -173,7 +174,7 @@ const allMeetups: Meetup[] = [
rsvpCount: 54,
gradientFrom: "#7c3aed",
gradientTo: "#0d9488",
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" }],
attendees: [{ name: "许婷", photo: avatarForIndex(6) }, { name: "马超", photo: avatarForIndex(17) }, { name: "罗敏", photo: avatarForIndex(15) }],
isUpcoming: true,
mode: "online",
accessLevel: "public",
@@ -197,7 +198,7 @@ const allMeetups: Meetup[] = [
rsvpCount: 11,
gradientFrom: "#06b6d4",
gradientTo: "#0d9488",
attendees: [{ name: "周杰", photo: "https://i.pravatar.cc/150?img=22" }, { name: "吴芳", photo: "https://i.pravatar.cc/150?img=23" }],
attendees: [{ name: "周杰", photo: avatarForIndex(10) }, { name: "吴芳", photo: avatarForIndex(24) }],
isUpcoming: true,
mode: "offline",
accessLevel: "public",
@@ -221,7 +222,7 @@ const allMeetups: Meetup[] = [
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" }],
attendees: [{ name: "赵磊", photo: avatarForIndex(10) }, { name: "黄薇", photo: avatarForIndex(22) }],
isUpcoming: true,
mode: "offline",
accessLevel: "public",
@@ -245,7 +246,7 @@ const allMeetups: Meetup[] = [
rsvpCount: 14,
gradientFrom: "#6366f1",
gradientTo: "#4f46e5",
attendees: [{ name: "陈思", photo: "https://i.pravatar.cc/150?img=31" }, { name: "郑凯", photo: "https://i.pravatar.cc/150?img=32" }],
attendees: [{ name: "陈思", photo: avatarForIndex(15) }, { name: "郑凯", photo: avatarForIndex(29) }],
isUpcoming: true,
mode: "hybrid",
accessLevel: "hosts",
@@ -269,7 +270,7 @@ const allMeetups: Meetup[] = [
rsvpCount: 6,
gradientFrom: "#84cc16",
gradientTo: "#65a30d",
attendees: [{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=42" }],
attendees: [{ name: "杨帆", photo: avatarForIndex(28) }],
isUpcoming: false,
mode: "offline",
accessLevel: "public",
@@ -293,7 +294,7 @@ const allMeetups: Meetup[] = [
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" }],
attendees: [{ name: "黄薇", photo: avatarForIndex(22) }, { name: "孙浩", photo: avatarForIndex(18) }],
isUpcoming: false,
mode: "hybrid",
accessLevel: "members",

View File

@@ -2,7 +2,9 @@
import { memo, useState } from "react";
import { City } from "../data/cities";
import { useTranslation } from "@/i18n/navigation";
import { getCitySocialSummary } from "../data/city-social";
import { Link, useTranslation } from "@/i18n/navigation";
import { cityVolunteerHref } from "@/app/lib/city-slugs";
function ratingToPercent(rating: string): number {
const m: Record<string, number> = {
@@ -57,6 +59,7 @@ interface CityCardProps {
function CityCardComponent({ city, rank, onClick }: CityCardProps) {
const [hovered, setHovered] = useState(false);
const { t } = useTranslation("cityCard");
const socialSummary = getCitySocialSummary(city);
const scores = [
{
@@ -130,7 +133,14 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
{/* Bottom: temp + cost + nomads */}
<div>
<div className="flex items-center gap-1 mb-1.5">
<div className="mb-1 flex flex-wrap items-center gap-1">
<Link
href={cityVolunteerHref(city)}
onClick={(e) => e.stopPropagation()}
className="shrink-0 rounded-full bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#ff4d4f] shadow-sm transition-colors hover:bg-white/90 sm:text-[11px]"
>
{t("volunteerRecruitment")}
</Link>
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
👥 {city.nomadsNow}{t("nomadsHere")}
</span>
@@ -138,6 +148,17 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
🌡 {city.temperature}°C
</span>
</div>
<div className="mb-1.5 grid grid-cols-3 gap-1">
<span className="truncate rounded-full bg-white/20 px-1.5 py-0.5 text-center text-[9px] text-white/90 backdrop-blur-sm sm:text-[10px]">
{socialSummary.coffeechat}
</span>
<span className="truncate rounded-full bg-white/20 px-1.5 py-0.5 text-center text-[9px] text-white/90 backdrop-blur-sm sm:text-[10px]">
🧭 {socialSummary.localGuide}
</span>
<span className="truncate rounded-full bg-white/20 px-1.5 py-0.5 text-center text-[9px] text-white/90 backdrop-blur-sm sm:text-[10px]">
🥾 {socialSummary.cityWalk}
</span>
</div>
<div className="flex items-end justify-between text-white text-xs sm:text-sm">
<span className="text-white/80 text-[10px] sm:text-xs line-clamp-1 max-w-[60%]">
{city.description}
@@ -184,8 +205,15 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
</div>
{/* Bottom: city info small */}
<div className="text-center text-white/40 text-xs">
{city.name} · {city.countryEmoji} {city.country}
<div className="space-y-2 text-center text-xs">
<div className="grid grid-cols-3 gap-1 text-white/80">
<span className="rounded-full bg-white/10 px-2 py-1"> {socialSummary.coffeechat}</span>
<span className="rounded-full bg-white/10 px-2 py-1">🧭 {socialSummary.localGuide}</span>
<span className="rounded-full bg-white/10 px-2 py-1">🥾 {socialSummary.cityWalk}</span>
</div>
<div className="text-white/40">
{city.name} · {city.countryEmoji} {city.country}
</div>
</div>
</div>
</div>

View File

@@ -5,7 +5,18 @@ import { createPortal } from "react-dom";
import { useTranslation } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation";
import { City } from "@/app/data/cities";
import {
getCityActivities,
getCitySocialMembers,
getCitySocialSummary,
type ActivityMode,
type CityActivity,
type CityProfileRecord,
type CitySocialMember,
type SocialMode,
} from "@/app/data/city-social";
import { apiFetch } from "@/app/lib/api-client";
import { cityVolunteerHref } from "@/app/lib/city-slugs";
import { mediaUrl } from "@/app/lib/media";
const CITY_COORDS: Record<string, [number, number]> = {
@@ -77,6 +88,7 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
const [activeTab, setActiveTab] = useState("scores");
const [favorited, setFavorited] = useState(false);
const [detailData, setDetailData] = useState<CityDetailData | null>(null);
const [socialProfiles, setSocialProfiles] = useState<CityProfileRecord[]>([]);
useEffect(() => {
if (!city || !isOpen) return;
@@ -86,6 +98,13 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
.catch(() => setDetailData(null));
}, [city, isOpen]);
useEffect(() => {
if (!city || !isOpen) return;
apiFetch<{ items: CityProfileRecord[] }>("/api/profiles")
.then((data) => setSocialProfiles(data.items || []))
.catch(() => setSocialProfiles([]));
}, [city, isOpen]);
if (!isOpen || !city || typeof document === "undefined") return null;
const coverImage = city.coverImage ?? CITY_IMAGES[city.name] ?? `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`;
@@ -106,9 +125,14 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
const costLabel = getCostLabel(city.costPerMonth, t);
const humidityLabel = getHumidityLabel(city.humidity, t);
const acLabel = getAcLabel(acPercent, t);
const socialSummary = getCitySocialSummary(city);
const tabs = [
{ id: "guide", labelKey: "tabs.guide" },
{ id: "coffeechat", labelKey: "tabs.coffeechat" },
{ id: "localGuide", labelKey: "tabs.localGuide" },
{ id: "cityWalk", labelKey: "tabs.cityWalk" },
{ id: "activities", labelKey: "tabs.activities" },
{ id: "pros", labelKey: "tabs.pros" },
{ id: "reviews", labelKey: "tabs.reviews" },
{ id: "cost", labelKey: "tabs.cost" },
@@ -181,8 +205,25 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
<p className="text-white/90 text-sm mt-0.5">
{city.countryEmoji} {city.country}
</p>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-white/90">
<span className="rounded-full bg-white/20 px-2 py-1 backdrop-blur-sm">
{socialSummary.coffeechat} CoffeeChat
</span>
<span className="rounded-full bg-white/20 px-2 py-1 backdrop-blur-sm">
🧭 {socialSummary.localGuide}
</span>
<span className="rounded-full bg-white/20 px-2 py-1 backdrop-blur-sm">
🥾 {socialSummary.cityWalk} Citywalk
</span>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
<Link
href={cityVolunteerHref(city)}
className="rounded-full bg-white px-3 py-1.5 text-xs font-semibold text-[#ff4d4f] shadow-sm transition-colors hover:bg-white/90 sm:px-4 sm:py-2 sm:text-sm"
>
{t("volunteerRecruitment")}
</Link>
<span className="text-white/90 text-xs sm:text-sm">
{reviewsCount.toLocaleString()} {t("reviews")}
</span>
@@ -265,7 +306,7 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
</div>
</div>
) : (
<CityTabContent activeTab={activeTab} city={city} data={detailData} />
<CityTabContent activeTab={activeTab} city={city} data={detailData} profiles={socialProfiles} />
)}
</div>
</div>
@@ -324,10 +365,12 @@ function CityTabContent({
activeTab,
city,
data,
profiles,
}: {
activeTab: string;
city: City;
data: CityDetailData | null;
profiles: CityProfileRecord[];
}) {
const detail = asRecord(data?.detail);
const tab = asRecord(detail[activeTab]);
@@ -335,6 +378,15 @@ function CityTabContent({
const meetups = data?.meetups || [];
const discussions = data?.discussions || [];
if (activeTab === "coffeechat" || activeTab === "localGuide" || activeTab === "cityWalk") {
const members = getCitySocialMembers(city, profiles).filter((member) => member.openTo.includes(activeTab as SocialMode));
return <SocialMembersPanel city={city} mode={activeTab as SocialMode} members={members} />;
}
if (activeTab === "activities") {
return <CityActivitiesPanel city={city} activities={getCityActivities(city, meetups)} />;
}
if (activeTab === "guide") {
return (
<div className="grid gap-5 lg:grid-cols-[1fr_280px]">
@@ -525,6 +577,172 @@ function CityTabContent({
return <RelatedContent items={content} />;
}
const SOCIAL_MODE_META: Record<SocialMode, { title: string; subtitle: string; badge: string; cta: string; tone: string }> = {
coffeechat: {
title: "CoffeeChat",
subtitle: "公开资料成员,欢迎陌生人在咖啡厅线下聊天。适合新到城市、想快速了解本地生活和远程工作节奏的人。",
badge: "可咖啡",
cta: "约咖啡聊天",
tone: "bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200",
},
localGuide: {
title: "地陪",
subtitle: "居住或旅居在本城的成员,可带你熟悉街区、咖啡馆、共享办公和本地路线。",
badge: "可带玩",
cta: "联系地陪",
tone: "bg-cyan-50 text-cyan-800 dark:bg-cyan-950/30 dark:text-cyan-200",
},
cityWalk: {
title: "Citywalk / 徒步",
subtitle: "想找人一起城市漫步、周末轻徒步、拍照探路。适合低压力线下同行,路线由成员自由发起。",
badge: "可同行",
cta: "约徒步",
tone: "bg-emerald-50 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200",
},
};
function SocialMembersPanel({
city,
mode,
members,
}: {
city: City;
mode: SocialMode;
members: CitySocialMember[];
}) {
const meta = SOCIAL_MODE_META[mode];
return (
<div className="space-y-5">
<section className={`rounded-2xl border border-gray-200 p-4 dark:border-gray-700 ${meta.tone}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] opacity-70">{city.name}</p>
<h2 className="mt-1 text-xl font-bold">{meta.title}</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 opacity-90">{meta.subtitle}</p>
</div>
<Link
href="/dating"
className="rounded-full bg-gray-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-gray-700 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200"
>
{meta.cta}
</Link>
</div>
</section>
<div className="grid gap-4 md:grid-cols-2">
{members.map((member) => (
<SocialMemberCard key={`${mode}-${member.id}`} member={member} mode={mode} />
))}
</div>
</div>
);
}
function SocialMemberCard({ member, mode }: { member: CitySocialMember; mode: SocialMode }) {
const meta = SOCIAL_MODE_META[mode];
return (
<article className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900">
<div className="flex gap-3">
<img src={mediaUrl(member.photo)} alt={member.name} className="h-14 w-14 rounded-full object-cover shadow-sm" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-bold text-gray-900 dark:text-gray-100">{member.name}{member.age ? `${member.age}` : ""}</h3>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${meta.tone}`}>{meta.badge}</span>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{member.role} · {member.location} · {member.responseTime}
</p>
</div>
</div>
<p className="mt-3 text-sm leading-6 text-gray-700 dark:text-gray-300">{member.bio}</p>
<div className="mt-3 flex flex-wrap gap-2">
{member.focus.map((item) => (
<span key={item} className="rounded-full bg-gray-100 px-2.5 py-1 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300">
{item}
</span>
))}
</div>
<div className="mt-4 flex items-center justify-between gap-3 border-t border-gray-100 pt-3 dark:border-gray-800">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{member.availability}</span>
<Link href="/messages" className="rounded-full bg-[#ff4d4f] px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500">
</Link>
</div>
</article>
);
}
const ACTIVITY_MODE_META: Record<ActivityMode, { label: string; tone: string }> = {
offline: { label: "线下", tone: "bg-orange-100 text-orange-700 dark:bg-orange-950/40 dark:text-orange-200" },
online: { label: "线上", tone: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200" },
hybrid: { label: "线上 + 线下", tone: "bg-violet-100 text-violet-700 dark:bg-violet-950/40 dark:text-violet-200" },
};
function CityActivitiesPanel({ city, activities }: { city: City; activities: CityActivity[] }) {
const offline = activities.filter((activity) => activity.mode !== "online");
const online = activities.filter((activity) => activity.mode === "online");
return (
<div className="space-y-5">
<section className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#ff4d4f]">{city.name}</p>
<h2 className="mt-1 text-xl font-bold text-gray-900 dark:text-gray-100"></h2>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
线线
</p>
</div>
<Link href="/meetups/host" className="rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white hover:bg-red-500">
</Link>
</div>
</section>
<div className="grid gap-5 lg:grid-cols-2">
<ActivityColumn title="线下 / 混合" items={offline} emptyText="暂无线下活动,可以发起一场咖啡局。" />
<ActivityColumn title="线上" items={online} emptyText="暂无线上活动,可以先开一个问答房。" />
</div>
</div>
);
}
function ActivityColumn({ title, items, emptyText }: { title: string; items: CityActivity[]; emptyText: string }) {
return (
<Panel title={title}>
<div className="space-y-3">
{items.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
{items.length === 0 && (
<div className="rounded-xl bg-gray-50 p-4 text-sm text-gray-500 dark:bg-gray-800 dark:text-gray-400">{emptyText}</div>
)}
</div>
</Panel>
);
}
function ActivityCard({ activity }: { activity: CityActivity }) {
const modeMeta = ACTIVITY_MODE_META[activity.mode];
const capacity = activity.maxAttendees ? ` / ${activity.maxAttendees}` : "";
return (
<Link href="/meetups" className="block rounded-xl bg-gray-50 p-3 transition-colors hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="line-clamp-2 text-sm font-bold text-gray-900 dark:text-gray-100">{activity.title}</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{activity.date} {activity.time} · {activity.venue}
</p>
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ${modeMeta.tone}`}>{modeMeta.label}</span>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-gray-600 dark:text-gray-300">{activity.description}</p>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{activity.organizer}</span>
<span>{activity.rsvpCount}{capacity} </span>
</div>
</Link>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">

View File

@@ -12,22 +12,23 @@ import {
import type { PayChannel, PayEnv } from "@/app/lib/payment";
import { apiUrl } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex } from "@/app/data/avatars";
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
const avatarPhotos = [
"https://i.pravatar.cc/150?img=32",
"https://i.pravatar.cc/150?img=33",
"https://i.pravatar.cc/150?img=25",
"https://i.pravatar.cc/150?img=52",
"https://i.pravatar.cc/150?img=44",
"https://i.pravatar.cc/150?img=55",
"https://i.pravatar.cc/150?img=47",
"https://i.pravatar.cc/150?img=57",
"https://i.pravatar.cc/150?img=48",
"https://i.pravatar.cc/150?img=59",
"https://i.pravatar.cc/150?img=49",
avatarForIndex(0),
avatarForIndex(1),
avatarForIndex(2),
avatarForIndex(3),
avatarForIndex(4),
avatarForIndex(5),
avatarForIndex(6),
avatarForIndex(7),
avatarForIndex(8),
avatarForIndex(9),
avatarForIndex(10),
];
const features = [

16
app/data/avatars.ts Normal file
View File

@@ -0,0 +1,16 @@
export const AVATAR_BASE_URL = "https://minio.nomadro.cn/hackrobot/nomadcna/avatars";
export const AVATAR_URLS = Array.from(
{ length: 32 },
(_, index) => `${AVATAR_BASE_URL}/avatar-${String(index + 1).padStart(2, "0")}.jpg`
);
export function avatarForIndex(index: number): string {
const safeIndex = Number.isFinite(index) ? Math.abs(Math.trunc(index)) : 0;
return AVATAR_URLS[safeIndex % AVATAR_URLS.length];
}
export function avatarForName(name: string): string {
const seed = Array.from(name || "NomadCNA").reduce((sum, char) => sum + char.charCodeAt(0), 0);
return avatarForIndex(seed);
}

271
app/data/city-social.ts Normal file
View File

@@ -0,0 +1,271 @@
import type { City } from "./cities";
import { avatarForIndex, avatarForName } from "./avatars";
export type SocialMode = "coffeechat" | "localGuide" | "cityWalk";
export type ActivityMode = "offline" | "online" | "hybrid";
export interface CityProfileRecord {
id?: string;
name?: string;
age?: number;
location?: string;
tags?: string[];
bio?: string;
photo?: string;
}
export interface CitySocialMember {
id: string;
name: string;
age?: number;
role: string;
location: string;
photo: string;
bio: string;
availability: string;
responseTime: string;
focus: string[];
openTo: SocialMode[];
}
export interface CityActivity {
id: string;
title: string;
mode: ActivityMode;
city: string;
date: string;
time: string;
venue: string;
organizer: string;
description: string;
rsvpCount: number;
maxAttendees?: number;
}
const FALLBACK_NAMES = [
"林晓雨",
"陈浩然",
"王思琪",
"张明远",
"李雅婷",
"刘子轩",
"周雨桐",
"吴俊杰",
"郑诗涵",
"孙宇航",
];
const ROLE_BY_INDEX = [
"产品设计师",
"独立开发者",
"内容创作者",
"远程运营",
"自由摄影师",
"数据顾问",
];
const COFFEE_FOCUS = [
["远程工作节奏", "城市落地", "产品设计"],
["独立开发", "副业收入", "咖啡馆办公"],
["内容创作", "本地生活", "社群连接"],
["创业项目", "远程团队", "效率工具"],
];
const GUIDE_FOCUS = [
["老街路线", "本地咖啡", "短租避坑"],
["共享办公", "夜生活", "美食路线"],
["城市漫步", "摄影机位", "周末短途"],
["交通动线", "生活区", "新朋友局"],
];
const CITY_WALK_FOCUS = [
["Citywalk", "老城徒步", "街区观察"],
["周末徒步", "轻户外", "补给路线"],
["城市漫步", "摄影路线", "本地小店"],
["公园路线", "日落机位", "低强度同行"],
];
function citySeed(city: City): number {
return city.idNumber || city.id || city.name.charCodeAt(0);
}
export function getCitySocialSummary(city: City) {
const base = Math.max(1, city.nomadsNow);
const seed = citySeed(city);
return {
coffeechat: Math.max(4, Math.round(base * 0.055) + (seed % 4)),
localGuide: Math.max(2, Math.round(base * 0.022) + (seed % 3)),
cityWalk: Math.max(3, Math.round(base * 0.032) + (seed % 5)),
};
}
function fallbackMember(city: City, index: number): CitySocialMember {
const seed = citySeed(city) + index;
const name = FALLBACK_NAMES[seed % FALLBACK_NAMES.length];
const isGuideFirst = index % 3 === 1;
const openTo: SocialMode[] =
index % 2 === 0
? ["coffeechat", "cityWalk"]
: isGuideFirst
? ["localGuide"]
: ["coffeechat"];
if (index % 4 === 0 && !openTo.includes("localGuide")) openTo.push("localGuide");
const focus = openTo.includes("cityWalk")
? CITY_WALK_FOCUS[index % CITY_WALK_FOCUS.length]
: openTo.includes("localGuide")
? GUIDE_FOCUS[index % GUIDE_FOCUS.length]
: COFFEE_FOCUS[index % COFFEE_FOCUS.length];
return {
id: `fallback-${city.slug || city.name}-${index}`,
name,
age: 25 + (seed % 11),
role: ROLE_BY_INDEX[index % ROLE_BY_INDEX.length],
location: city.name,
photo: avatarForIndex(seed + index),
bio: `目前在${city.name}旅居/居住,资料开放,欢迎同城线下见面。`,
availability: index % 2 === 0 ? "本周可约 2 次" : "周末更方便",
responseTime: index % 3 === 0 ? "通常当天回复" : "通常 24 小时内回复",
focus,
openTo,
};
}
export function getFallbackCitySocialMembers(city: City): CitySocialMember[] {
return Array.from({ length: 8 }, (_, index) => fallbackMember(city, index));
}
function profileOpenTo(profile: CityProfileRecord, index: number): SocialMode[] {
const tags = (profile.tags || []).join(" ");
const modes = new Set<SocialMode>();
if (/coffee|咖啡|聊天|线下/.test(tags)) modes.add("coffeechat");
if (/地陪|向导|探店|游玩|路线|本地/.test(tags)) modes.add("localGuide");
if (/citywalk|徒步|漫步|散步|户外|hiking/i.test(tags)) modes.add("cityWalk");
if (modes.size === 0) {
modes.add(index % 3 === 0 ? "cityWalk" : index % 2 === 0 ? "coffeechat" : "localGuide");
if (index % 3 === 0) modes.add("localGuide");
}
return Array.from(modes);
}
function profileToMember(city: City, profile: CityProfileRecord, index: number): CitySocialMember {
const name = profile.name || "社区成员";
const openTo = profileOpenTo(profile, index);
return {
id: profile.id || `${city.slug || city.name}-profile-${index}`,
name,
age: profile.age,
role: profile.tags?.find((tag) => !["数字游民", city.name, "可线下见面"].includes(tag)) || "数字游民",
location: profile.location || city.name,
photo: profile.photo || avatarForName(name),
bio: profile.bio || `目前在${city.name},欢迎同城咖啡或城市散步。`,
availability: index % 2 === 0 ? "本周可约" : "提前 1 天约",
responseTime: index % 3 === 0 ? "通常当天回复" : "通常 24 小时内回复",
focus: openTo.includes("cityWalk")
? CITY_WALK_FOCUS[index % CITY_WALK_FOCUS.length]
: openTo.includes("localGuide")
? GUIDE_FOCUS[index % GUIDE_FOCUS.length]
: COFFEE_FOCUS[index % COFFEE_FOCUS.length],
openTo,
};
}
export function getCitySocialMembers(city: City, profiles: CityProfileRecord[]): CitySocialMember[] {
const cityProfiles = profiles.filter((profile) => {
const location = profile.location || "";
const tags = profile.tags || [];
return location.includes(city.name) || tags.some((tag) => tag.includes(city.name));
});
const merged = [
...cityProfiles.map((profile, index) => profileToMember(city, profile, index)),
...getFallbackCitySocialMembers(city),
];
const seen = new Set<string>();
return merged.filter((member) => {
const key = `${member.name}-${member.location}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function getFallbackCityActivities(city: City): CityActivity[] {
const seed = citySeed(city);
return [
{
id: `fallback-${city.slug || city.name}-offline`,
title: `${city.name}同城咖啡与落地经验局`,
mode: "offline",
city: city.name,
date: "2026-07-15",
time: "19:30",
venue: `${city.name}核心生活区咖啡馆`,
organizer: `${city.name}城市主理人`,
description: `围绕${city.name}租房、办公、社群和长期停留做小规模线下交流。`,
rsvpCount: 8 + (seed % 14),
maxAttendees: 28,
},
{
id: `fallback-${city.slug || city.name}-online`,
title: `${city.name}线上问答房`,
mode: "online",
city: city.name,
date: "2026-07-18",
time: "20:00",
venue: "MiroTalk 线上房间",
organizer: "NomadCNA",
description: `给准备去${city.name}的人做一次线上答疑,集中聊预算、住区、网络和社交入口。`,
rsvpCount: 18 + (seed % 20),
maxAttendees: 80,
},
{
id: `fallback-${city.slug || city.name}-hybrid`,
title: `${city.name}周末城市漫步`,
mode: "hybrid",
city: city.name,
date: "2026-07-21",
time: "15:00",
venue: `${city.name}本地生活路线`,
organizer: "同城成员自发",
description: "开放给新到城市的成员,线下同行,线上同步路线和问答。",
rsvpCount: 6 + (seed % 10),
maxAttendees: 18,
},
];
}
export function normalizeCityActivities(city: City, meetups: Array<Record<string, unknown>>): CityActivity[] {
return meetups
.filter((meetup) => String(meetup.city || city.name) === city.name)
.map((meetup, index) => {
const rawMode = String(meetup.mode || "offline");
const mode: ActivityMode = rawMode === "online" ? "online" : rawMode === "hybrid" ? "hybrid" : "offline";
return {
id: String(meetup.id || `${city.slug || city.name}-meetup-${index}`),
title: String(meetup.title || meetup.venue || `${city.name}同城活动`),
mode,
city: String(meetup.city || city.name),
date: String(meetup.date || "时间待定"),
time: String(meetup.time || ""),
venue: String(meetup.venue || (mode === "online" ? "线上房间" : "活动地点待定")),
organizer: String(meetup.organizer || `${city.name}城市主理人`),
description: String(meetup.description || "同城成员发起的交流活动。"),
rsvpCount: Number(meetup.rsvpCount || 0),
maxAttendees: meetup.maxAttendees ? Number(meetup.maxAttendees) : undefined,
};
});
}
export function getCityActivities(city: City, meetups: Array<Record<string, unknown>> = []): CityActivity[] {
const normalized = normalizeCityActivities(city, meetups);
const fallback = getFallbackCityActivities(city);
const merged = [...normalized, ...fallback];
const seen = new Set<string>();
return merged.filter((activity) => {
const key = `${activity.title}-${activity.mode}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}

View File

@@ -1,6 +1,8 @@
/**
* 博客文章 Mock 数据
*/
import { avatarForIndex } from "./avatars";
export interface BlogPost {
id: string;
title: string;
@@ -26,7 +28,7 @@ export const blogPosts: BlogPost[] = [
excerptEn: "Based on 3000+ surveys, analyzing the lifestyle, work patterns and future trends of China's digital nomad community.",
coverImage: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800",
author: "游牧中国",
authorAvatar: "https://api.dicebear.com/7.x/initials/svg?seed=NomadCNA",
authorAvatar: avatarForIndex(20),
date: "2024-12-20",
category: "研究报告",
categoryEn: "Research",
@@ -41,7 +43,7 @@ export const blogPosts: BlogPost[] = [
excerptEn: "A comprehensive comparison of digital nomad visa policies and application processes in Thailand, Indonesia, Vietnam, Malaysia and more.",
coverImage: "https://images.unsplash.com/photo-1528181304800-259b08848526?w=800",
author: "小明",
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=xiaoming",
authorAvatar: avatarForIndex(9),
date: "2024-12-15",
category: "签证指南",
categoryEn: "Visa Guide",
@@ -56,7 +58,7 @@ export const blogPosts: BlogPost[] = [
excerptEn: "Why has Dali become the top destination for digital nomads? A comprehensive analysis of climate, cost of living and community vibe.",
coverImage: "https://images.unsplash.com/photo-1584551246679-0daf3d275d0f?w=800",
author: "小红",
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=xiaohong",
authorAvatar: avatarForIndex(2),
date: "2024-12-10",
category: "城市攻略",
categoryEn: "City Guide",
@@ -71,7 +73,7 @@ export const blogPosts: BlogPost[] = [
excerptEn: "From time management to self-motivation, sharing common challenges and practical solutions in remote work.",
coverImage: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=800",
author: "老王",
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=laowang",
authorAvatar: avatarForIndex(21),
date: "2024-12-05",
category: "经验分享",
categoryEn: "Experience",
@@ -86,7 +88,7 @@ export const blogPosts: BlogPost[] = [
excerptEn: "In the era of social media, how can digital nomads build their personal brand to attract more opportunities and resources?",
coverImage: "https://images.unsplash.com/photo-1552664730-d307ca884978?w=800",
author: "阿花",
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=ahua",
authorAvatar: avatarForIndex(12),
date: "2024-11-28",
category: "职业发展",
categoryEn: "Career",
@@ -101,7 +103,7 @@ export const blogPosts: BlogPost[] = [
excerptEn: "Understanding tax residency definitions in different countries and how to plan your tax status rationally.",
coverImage: "https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=800",
author: "李老师",
authorAvatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=lishilaoshi",
authorAvatar: avatarForIndex(14),
date: "2024-11-20",
category: "财务管理",
categoryEn: "Finance",

60
app/lib/city-slugs.ts Normal file
View File

@@ -0,0 +1,60 @@
import { cities, type City } from "@/app/data/cities";
const CITY_SLUG_BY_NAME: Record<string, string> = {
: "dali",
: "chengdu",
: "shenzhen",
: "shanghai",
: "hangzhou",
: "xiamen",
: "beijing",
广: "guangzhou",
: "chongqing",
: "kunming",
: "sanya",
: "lijiang",
西: "xian",
: "nanjing",
: "changsha",
: "qingdao",
: "suzhou",
: "zhuhai",
};
function normalizeSlug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[']/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function citySlug(city: Pick<City, "slug" | "name" | "nameEn" | "id">): string {
if (city.slug) return normalizeSlug(city.slug);
if (CITY_SLUG_BY_NAME[city.name]) return CITY_SLUG_BY_NAME[city.name];
if (city.nameEn) return normalizeSlug(city.nameEn);
return `city-${city.id}`;
}
export function cityVolunteerHref(city: Pick<City, "slug" | "name" | "nameEn" | "id">): string {
return `/city/${encodeURIComponent(citySlug(city))}/volunteer`;
}
export function findCityBySlug(slug: string): City | undefined {
const decoded = decodeURIComponent(slug).trim();
const normalized = normalizeSlug(decoded);
return cities.find((city) => {
const candidates = [
city.slug,
citySlug(city),
city.name,
city.nameEn,
CITY_SLUG_BY_NAME[city.name],
].filter(Boolean);
return candidates.some((candidate) => {
const text = String(candidate);
return text === decoded || normalizeSlug(text) === normalized;
});
});
}