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

@@ -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">