s
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user