s
This commit is contained in:
392
app/[locale]/city/[slug]/volunteer/page.tsx
Normal file
392
app/[locale]/city/[slug]/volunteer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
16
app/data/avatars.ts
Normal 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
271
app/data/city-social.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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
60
app/lib/city-slugs.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user