819 lines
36 KiB
TypeScript
819 lines
36 KiB
TypeScript
"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")} > {region} > {city.country} > {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>
|
||
);
|
||
}
|