Files
2026-06-07 23:57:52 -05:00

819 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
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]> = {
: [25.7, 100.2],
: [30.6, 104.1],
: [22.5, 114.1],
: [31.2, 121.5],
: [30.3, 120.2],
: [24.5, 118.1],
: [39.9, 116.4],
广: [23.1, 113.3],
: [29.6, 106.6],
: [25.0, 102.7],
: [18.3, 109.5],
: [26.9, 100.2],
西: [34.3, 108.9],
: [32.1, 118.8],
: [28.2, 112.9],
: [36.1, 120.4],
: [31.3, 120.6],
: [22.3, 113.6],
};
const CITY_IMAGES: Record<string, string> = {
: "https://images.unsplash.com/photo-1590856029826-c7a73142bbf1?w=1200",
: "https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=1200",
: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=1200",
: "https://images.unsplash.com/photo-1542051841857-5f90071e7989?w=1200",
: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200",
: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
: "https://images.unsplash.com/photo-1508804185872-d7badad00f7d?w=1200",
广: "https://images.unsplash.com/photo-1596422846543-75c6fc197f07?w=1200",
: "https://images.unsplash.com/photo-1563841930606-67e2cce48b78?w=1200",
: "https://images.unsplash.com/photo-1589553416260-f586c8f1514f?w=1200",
: "https://images.unsplash.com/photo-1590523277543-a94d2e4eb00b?w=1200",
: "https://images.unsplash.com/photo-1548013146-72479768bada?w=1200",
西: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
: "https://images.unsplash.com/photo-1552465011-b4e21bf6e79a?w=1200",
: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
: "https://images.unsplash.com/photo-1590523277543-a94d2e4eb00b?w=1200",
};
type ScoreLabel = "great" | "good" | "okay" | "bad";
function scoreToLabel(score: number): ScoreLabel {
if (score >= 4.2) return "great";
if (score >= 3.5) return "good";
if (score >= 2.5) return "okay";
return "bad";
}
interface CityModalProps {
city: City | null;
isOpen: boolean;
onClose: () => void;
}
interface CityDetailData {
detail?: Record<string, unknown> | null;
content?: Array<Record<string, unknown>>;
meetups?: Array<Record<string, unknown>>;
discussions?: Array<Record<string, unknown>>;
}
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
const { t } = useTranslation("cityModal");
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;
const slug = city.slug || city.nameEn?.toLowerCase() || city.name.toLowerCase();
apiFetch<CityDetailData & { ok: boolean }>(`/api/cities/${slug}`)
.then((data) => setDetailData(data))
.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})`;
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + ((city.id * 97) % 500);
const likedCount = city.likedCount ?? Math.floor(reviewsCount * 0.55);
const dislikedCount = city.dislikedCount ?? Math.floor(reviewsCount * 0.08);
const qualityOfLife = (city.qualityOfLife?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
const familyScore = (city.familyScore?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore - 0.2);
const communityScore = (city.communityScore?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore + 0.1);
const nomadPercent = city.nomadPercent ?? Math.min(5, Math.ceil((city.nomadsNow / 500) * 2));
const funLabel = (city.funLabel?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
const feelsLike = city.feelsLikeTemp ?? city.temperature + 2;
const acPercent = city.acPercent ?? Math.min(95, 60 + city.temperature);
const [lat, lng] = city.lat && city.lng ? [city.lat, city.lng] : (CITY_COORDS[city.name] ?? [30, 120]);
const region = t("region");
const internetInfo = getInternetInfo(city.internetSpeed, t);
const tempInfo = getTempInfo(city.temperature, feelsLike, t);
const costLabel = getCostLabel(city.costPerMonth, t);
const humidityLabel = getHumidityLabel(city.humidity, t);
const acLabel = getAcLabel(acPercent, t);
const 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" },
{ id: "people", labelKey: "tabs.people" },
{ id: "chat", labelKey: "tabs.chat" },
{ id: "photos", labelKey: "tabs.photos" },
{ id: "weather", labelKey: "tabs.weather" },
{ id: "trends", labelKey: "tabs.trends" },
{ id: "demographics", labelKey: "tabs.demographics" },
{ id: "scores", labelKey: "tabs.scores" },
];
const scoresList = [
{ icon: "🚩", labelKey: "scores.nomadScore", value: `${Math.round(city.overallScore)}/5 (${t("scores.rank")} #${city.id})`, good: true },
{ icon: "❤️", labelKey: "scores.likedByMembers", value: `${likedCount} ${t("scores.likedIt")} ${dislikedCount} ${t("scores.dislikedIt")}`, good: null },
{ icon: "⭐", labelKey: "scores.qualityOfLife", value: t(`scores.scoreLabels.${qualityOfLife}`), good: qualityOfLife !== "bad" },
{ icon: "👨‍👩‍👧", labelKey: "scores.familyScore", value: t(`scores.scoreLabels.${familyScore}`), good: familyScore !== "bad" },
{ icon: "👥", labelKey: "scores.communityScore", value: t(`scores.scoreLabels.${communityScore}`), good: communityScore !== "bad" },
{ icon: "🏙️", labelKey: "scores.nomadDensity", value: `${nomadPercent}% ${t("scores.nomadHere")}`, good: true },
{ icon: "💰", labelKey: "scores.cost", value: `${costLabel}: ¥${city.costPerMonth.toLocaleString()}/${t("scores.perMonth")}`, good: city.costPerMonth <= 6000 },
{ icon: "📡", labelKey: "scores.internet", value: internetInfo.text, good: internetInfo.isGood },
{ icon: "😊", labelKey: "scores.fun", value: t(`scores.scoreLabels.${funLabel}`), good: funLabel !== "bad" },
{ icon: "🌡️", labelKey: "scores.temperature", value: tempInfo.text, good: tempInfo.isGood },
{ icon: "💧", labelKey: "scores.humidity", value: `${humidityLabel}: ${city.humidity}%`, good: true },
{ icon: "❄️", labelKey: "scores.acPercent", value: `${acLabel}: ${acPercent}%`, good: acPercent >= 70 },
];
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.2},${lat - 0.15},${lng + 0.2},${lat + 0.15}&layer=mapnik&marker=${lat},${lng}`;
const modalContent = (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-4 md:p-6">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div
className="relative z-10 flex flex-col w-full max-w-5xl max-h-[90vh] rounded-2xl overflow-hidden shadow-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header with cover image */}
<div className="relative h-36 sm:h-44 md:h-52 shrink-0">
{typeof coverImage === "string" && coverImage.startsWith("http") ? (
<img
src={mediaUrl(coverImage)}
alt={city.name}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div
className="absolute inset-0 w-full h-full"
style={{ background: coverImage as string }}
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
<button
onClick={onClose}
className="absolute right-3 top-3 sm:right-4 sm:top-4 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white transition-colors"
aria-label={t("close")}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="absolute bottom-0 left-0 right-0 p-3 sm:p-4 flex flex-wrap items-end justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white drop-shadow-lg">
{city.name}
</h1>
<p className="text-white/90 text-sm mt-0.5">
{city.countryEmoji} {city.country}
</p>
<div 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 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>
<button
onClick={() => setFavorited(!favorited)}
className={`rounded-full px-3 py-1.5 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium transition-colors ${
favorited
? "bg-[#ff4d4f] text-white"
: "bg-white/20 text-white hover:bg-white/30"
}`}
>
{favorited ? t("favorited") : t("favorite")}
</button>
</div>
</div>
</div>
{/* Breadcrumb */}
<div className="shrink-0 px-3 sm:px-4 py-2 border-b border-gray-100 dark:border-gray-800">
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
{t("breadcrumbPrefix")} &gt; {region} &gt; {city.country} &gt; {city.name}
</p>
</div>
{/* Tabs */}
<div className="shrink-0 border-b border-gray-100 dark:border-gray-800 overflow-x-auto">
<div className="flex gap-1 min-w-max px-3 sm:px-4 py-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium whitespace-nowrap rounded-lg transition-colors ${
activeTab === tab.id
? "text-[#ff4d4f] border-b-2 border-[#ff4d4f] -mb-[2px]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
</div>
{/* Content - scrollable */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
{activeTab === "scores" ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<div className="space-y-2 sm:space-y-3">
{scoresList.map((s) => (
<div
key={s.labelKey}
className="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50"
>
<span className="text-lg sm:text-xl w-7 sm:w-8 shrink-0">{s.icon}</span>
<span className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">
{t(s.labelKey)}
</span>
<span
className={`text-xs font-medium px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-full shrink-0 ${
s.good === null
? "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
: s.good
? "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-400"
}`}
>
{String(s.value)}
</span>
</div>
))}
</div>
<div className="rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 h-64 sm:h-72 lg:h-80">
<iframe
title={`${city.name} ${t("map")}`}
src={mapUrl}
className="w-full h-full"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</div>
) : (
<CityTabContent activeTab={activeTab} city={city} data={detailData} profiles={socialProfiles} />
)}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
function getInternetInfo(speed: number, t: (k: string) => string): { text: string; isGood: boolean } {
const avg = t("scores.avg");
if (speed >= 80) return { text: `${t("scores.fast")}: ${speed} Mbps (${avg})`, isGood: true };
if (speed >= 50) return { text: `${t("scores.medium")}: ${speed} Mbps (${avg})`, isGood: false };
return { text: `${t("scores.slow")}: ${speed} Mbps (${avg})`, isGood: false };
}
function getTempInfo(temp: number, feels: number, t: (k: string) => string): { text: string; isGood: boolean } {
const feelsLike = t("scores.feelsLike");
if (temp >= 32 || feels >= 38) return { text: `${t("scores.tooHot")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: false };
if (temp >= 25) return { text: `${t("scores.warm")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: true };
if (temp >= 15) return { text: `${t("scores.comfortable")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: true };
return { text: `${t("scores.cold")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: false };
}
function getCostLabel(cost: number, t: (k: string) => string): string {
if (cost <= 4000) return t("scores.affordable");
if (cost <= 7000) return t("scores.moderate");
return t("scores.expensive");
}
function getHumidityLabel(h: number, t: (k: string) => string): string {
if (h <= 60) return t("scores.comfy");
if (h <= 80) return t("scores.moderate");
return t("scores.humid");
}
function getAcLabel(pct: number, t: (k: string) => string): string {
if (pct >= 85) return t("scores.veryHigh");
if (pct >= 70) return t("scores.high");
return t("scores.normal");
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function asArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? (value.filter((x) => x && typeof x === "object") as Array<Record<string, unknown>>) : [];
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
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]);
const content = data?.content || [];
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]">
<section className="space-y-4">
<Panel title="城市落地指南">
<p className="leading-7 text-gray-700 dark:text-gray-300">{String(tab.summary || city.description)}</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<InfoCard label="工作配置" value={String(tab.workSetup || `${city.internetSpeed}Mbps 网络,建议先短住实测。`)} />
<InfoCard label="适合人群" value={asStringArray(tab.bestFor).join(" · ") || city.tags.join(" · ")} />
</div>
</Panel>
<Panel title="30 天落地清单">
<CheckList items={asStringArray(tab.arrivalChecklist)} />
</Panel>
</section>
<section className="space-y-4">
<RelatedContent items={content} />
<Panel title="同城活动">
<div className="space-y-3">
{meetups.map((meetup) => (
<Link key={String(meetup.id)} href="/meetups" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
<span className="font-semibold">{String(meetup.venue || "活动地点待定")}</span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">{String(meetup.date || "")} · {String(meetup.rsvpCount || 0)} </span>
</Link>
))}
{meetups.length === 0 && (
<Link href="/meetups/host" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
</Link>
)}
</div>
</Panel>
</section>
</div>
);
}
if (activeTab === "pros") {
return (
<div className="grid gap-5 md:grid-cols-2">
<Panel title="优点">
<CheckList items={asStringArray(tab.pros)} tone="good" />
</Panel>
<Panel title="需要注意">
<CheckList items={asStringArray(tab.cons)} tone="warn" />
</Panel>
</div>
);
}
if (activeTab === "reviews") {
const items = asArray(tab.items);
return (
<div className="space-y-4">
{items.map((review, i) => (
<Panel key={i} title={`${review.author || "社区成员"} · ${review.role || "数字游民"}`}>
<p className="leading-7 text-gray-700 dark:text-gray-300">{String(review.text || "")}</p>
<p className="mt-3 text-sm text-[#ff4d4f]"> {String(review.score || city.overallScore)}/5</p>
</Panel>
))}
</div>
);
}
if (activeTab === "cost") {
const breakdown = asArray(tab.breakdown);
const total = Number(tab.monthlyTotal || city.costPerMonth);
return (
<Panel title={`月生活成本:¥${total.toLocaleString()}`}>
<div className="space-y-3">
{breakdown.map((item) => {
const amount = Number(item.amount || 0);
return (
<div key={String(item.label)}>
<div className="mb-1 flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{String(item.label)}</span>
<span>¥{amount.toLocaleString()}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div className="h-full rounded-full bg-[#ff4d4f]" style={{ width: `${Math.min(100, (amount / total) * 100)}%` }} />
</div>
</div>
);
})}
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.tip || "成本数据会随社区反馈持续校准。")}</p>
</Panel>
);
}
if (activeTab === "people") {
return (
<div className="grid gap-5 lg:grid-cols-2">
<Panel title={`${Number(tab.nomadsNow || city.nomadsNow).toLocaleString()} 位游民正在这里`}>
<div className="space-y-3">
{asArray(tab.personas).map((item) => (
<InfoCard key={String(item.name)} label={String(item.name)} value={`${String(item.percent)}%`} />
))}
</div>
</Panel>
<Panel title="相关访谈与工具">
<RelatedContent items={content} compact />
</Panel>
</div>
);
}
if (activeTab === "chat") {
return (
<div className="grid gap-5 lg:grid-cols-2">
<Panel title="同城频道">
<div className="space-y-3">
{asArray(tab.channels).map((channel) => (
<InfoCard key={String(channel.name)} label={String(channel.name)} value={`${String(channel.members)} 成员 · ${String(channel.status)}`} />
))}
</div>
<Link href="/join" className="mt-4 inline-flex rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white"></Link>
</Panel>
<Panel title="同城讨论">
<div className="space-y-3">
{discussions.map((topic) => (
<Link key={String(topic.id)} href="/discuss" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
{String(topic.title)}
</Link>
))}
</div>
</Panel>
</div>
);
}
if (activeTab === "photos") {
const photos = asStringArray(tab.items);
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{photos.map((src) => (
<img key={src} src={mediaUrl(src)} alt={city.name} className="h-56 w-full rounded-2xl object-cover shadow-sm" />
))}
</div>
);
}
if (activeTab === "weather") {
return (
<Panel title="天气与季节">
<div className="grid gap-3 sm:grid-cols-3">
<InfoCard label="当前温度" value={`${String(tab.temperature || city.temperature)}°C`} />
<InfoCard label="湿度" value={`${String(tab.humidity || city.humidity)}%`} />
<InfoCard label="推荐月份" value={asStringArray(tab.bestMonths).join("、")} />
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.note || "真实天气接口接入后会显示未来 7 天趋势。")}</p>
</Panel>
);
}
if (activeTab === "trends") {
return (
<Panel title="社群增长趋势">
<div className="flex h-48 items-end gap-3">
{asArray(tab.growth).map((point) => {
const value = Number(point.value || 0);
return (
<div key={String(point.month)} className="flex flex-1 flex-col items-center gap-2">
<div className="w-full rounded-t-lg bg-[#ff4d4f]" style={{ height: `${Math.max(18, Math.min(100, value / 12))}%` }} />
<span className="text-xs text-gray-400">{String(point.month)}</span>
</div>
);
})}
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.insight || "趋势数据来自社区成员位置和活动报名聚合。")}</p>
</Panel>
);
}
if (activeTab === "demographics") {
return (
<div className="grid gap-5 md:grid-cols-2">
<Panel title="年龄结构">
{asArray(tab.age).map((item) => <InfoCard key={String(item.label)} label={String(item.label)} value={`${String(item.value)}%`} />)}
</Panel>
<Panel title="职业结构">
{asArray(tab.work).map((item) => <InfoCard key={String(item.label)} label={String(item.label)} value={`${String(item.value)}%`} />)}
</Panel>
</div>
);
}
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",
},
};
const DATING_INTENT_BY_MODE: Record<SocialMode, string> = {
coffeechat: "friends",
localGuide: "partner",
cityWalk: "explore",
};
function SocialMembersPanel({
city,
mode,
members,
}: {
city: City;
mode: SocialMode;
members: CitySocialMember[];
}) {
const meta = SOCIAL_MODE_META[mode];
const datingHref = `/dating?intent=${DATING_INTENT_BY_MODE[mode]}&city=${encodeURIComponent(city.slug || city.name)}`;
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={datingHref}
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">
<h3 className="mb-3 font-bold text-gray-900 dark:text-gray-100">{title}</h3>
{children}
</section>
);
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="mb-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800">
<p className="text-xs text-gray-400">{label}</p>
<p className="mt-1 text-sm font-medium text-gray-800 dark:text-gray-200">{value}</p>
</div>
);
}
function CheckList({ items, tone = "good" }: { items: string[]; tone?: "good" | "warn" }) {
const icon = tone === "good" ? "✓" : "!";
return (
<div className="space-y-2">
{items.map((item) => (
<div key={item} className="flex gap-2 rounded-xl bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<span className={tone === "good" ? "text-emerald-500" : "text-amber-500"}>{icon}</span>
<span>{item}</span>
</div>
))}
</div>
);
}
function RelatedContent({ items, compact = false }: { items: Array<Record<string, unknown>>; compact?: boolean }) {
const hrefFor = (item: Record<string, unknown>) => {
const type = String(item.type || "");
const slug = String(item.slug || "");
if (type === "ebook") return `/ebooks/${slug}`;
if (type === "video") return `/videos/${slug}`;
if (type === "app") return "/app";
return String(item.targetUrl || "/digital");
};
return (
<Panel title="关联内容">
<div className="space-y-3">
{items.map((item) => {
const href = hrefFor(item);
const external = href.startsWith("http");
const body = (
<div className="flex gap-3 rounded-xl bg-gray-50 p-3 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700">
{!compact && <img src={mediaUrl(String(item.coverImage || ""))} alt="" className="h-14 w-20 rounded-lg object-cover" />}
<div className="min-w-0">
<p className="line-clamp-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{String(item.title)}</p>
<p className="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{String(item.subtitle || "")}</p>
</div>
</div>
);
return external ? (
<a key={String(item.slug)} href={href} target="_blank" rel="noopener noreferrer">{body}</a>
) : (
<Link key={String(item.slug)} href={href}>{body}</Link>
);
})}
</div>
</Panel>
);
}