408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useMemo } from "react";
|
||
import { Link, useLocale } from "@/i18n/navigation";
|
||
import { useTranslation } from "@/i18n/navigation";
|
||
import HeroSection from "../components/HeroSection";
|
||
import FilterBar from "../components/FilterBar";
|
||
import CityCard from "../components/CityCard";
|
||
import CityModal from "../components/CityModal";
|
||
import Footer from "@/app/components/Footer";
|
||
import { cities, type City } from "../data/cities";
|
||
import { HOME_CONFIG } from "@/config";
|
||
import { apiFetch } from "@/app/lib/api-client";
|
||
import { mediaUrl } from "@/app/lib/media";
|
||
|
||
const {
|
||
memberPhotos: fallbackMemberPhotos,
|
||
meetups: fallbackHomeMeetups,
|
||
stats: fallbackStats,
|
||
communityStats: fallbackCommunityStats,
|
||
} = HOME_CONFIG;
|
||
|
||
interface ContentItem {
|
||
slug: string;
|
||
type: "ebook" | "video" | "app" | "guide";
|
||
title: string;
|
||
titleEn?: string;
|
||
subtitle: string;
|
||
subtitleEn?: string;
|
||
description?: string;
|
||
coverImage: string;
|
||
targetUrl: string;
|
||
ctaLabel: string;
|
||
ctaLabelEn?: string;
|
||
durationMinutes?: number;
|
||
}
|
||
|
||
const FALLBACK_HOME_CONTENT: ContentItem[] = [
|
||
{
|
||
slug: "nomad-china-playbook",
|
||
type: "ebook",
|
||
title: "《中国数字游民落地手册》",
|
||
titleEn: "China Digital Nomad Playbook",
|
||
subtitle: "选城、预算、租房和社群融入的一本实操指南",
|
||
subtitleEn: "A practical guide for city choice, budget, housing, and community.",
|
||
coverImage: "https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=1200",
|
||
targetUrl: "/ebooks/nomad-china-playbook",
|
||
ctaLabel: "查看电子书",
|
||
ctaLabelEn: "View ebook",
|
||
},
|
||
{
|
||
slug: "lin-dali-documentary",
|
||
type: "video",
|
||
title: "林晓雨:在大理远程工作的现实一天",
|
||
titleEn: "A remote work day in Dali",
|
||
subtitle: "个人访谈 / 现实纪录片",
|
||
subtitleEn: "Interview documentary",
|
||
coverImage: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
|
||
targetUrl: "/videos/lin-dali-documentary",
|
||
ctaLabel: "观看访谈",
|
||
ctaLabelEn: "Watch",
|
||
},
|
||
{
|
||
slug: "nomadcna-app",
|
||
type: "app",
|
||
title: "NomadCNA App",
|
||
titleEn: "NomadCNA App",
|
||
subtitle: "记录旅居路线、发现同城活动、保存城市清单",
|
||
subtitleEn: "Track routes, discover meetups, and save city lists.",
|
||
coverImage: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=1200",
|
||
targetUrl: "/app",
|
||
ctaLabel: "下载 App",
|
||
ctaLabelEn: "Download app",
|
||
},
|
||
{
|
||
slug: "digital-nomad-guide",
|
||
type: "guide",
|
||
title: "数字游民指南",
|
||
titleEn: "Digital Nomad Guide",
|
||
subtitle: "完整方法论、工具、案例和长期更新",
|
||
subtitleEn: "Methods, tools, cases, and long-term updates.",
|
||
coverImage: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200",
|
||
targetUrl: "/digital",
|
||
ctaLabel: "打开指南",
|
||
ctaLabelEn: "Open guide",
|
||
},
|
||
];
|
||
|
||
const HIDDEN_HOME_RIGHT_CARD_TYPES = new Set<ContentItem["type"]>(["app", "guide"]);
|
||
|
||
interface ProfileItem {
|
||
id: string;
|
||
name: string;
|
||
location?: string;
|
||
photo: string;
|
||
}
|
||
|
||
interface MeetupItem {
|
||
id: string;
|
||
city: string;
|
||
emoji?: string;
|
||
date?: string;
|
||
time?: string;
|
||
rsvpCount?: number;
|
||
venue?: string;
|
||
}
|
||
|
||
interface HomeStats {
|
||
cities: string;
|
||
nomads: string;
|
||
homeMeetups: string;
|
||
cost: string;
|
||
}
|
||
|
||
function safetyOrder(s: string): number {
|
||
const o: Record<string, number> = { 极好: 5, 很好: 4, 好: 3, 一般: 2, 差: 1 };
|
||
return o[s] || 0;
|
||
}
|
||
|
||
function shortDate(date?: string): string {
|
||
if (!date) return "时间待定";
|
||
const month = Number(date.slice(5, 7));
|
||
const day = Number(date.slice(8, 10));
|
||
return `${month}月${day}日`;
|
||
}
|
||
|
||
export default function Home() {
|
||
const [activeFilter, setActiveFilter] = useState("全部");
|
||
const [sortBy, setSortBy] = useState("score");
|
||
const [cityModal, setCityModal] = useState<City | null>(null);
|
||
const [liveCities, setLiveCities] = useState<City[]>(cities);
|
||
const [homeContent, setHomeContent] = useState<ContentItem[]>(FALLBACK_HOME_CONTENT);
|
||
const [homeStats, setHomeStats] = useState<HomeStats>(fallbackStats);
|
||
const [memberPhotos, setMemberPhotos] = useState(fallbackMemberPhotos);
|
||
const [homeMeetups, setHomeMeetups] = useState(fallbackHomeMeetups);
|
||
const [communityStats, setCommunityStats] = useState(fallbackCommunityStats);
|
||
const locale = useLocale();
|
||
const { t } = useTranslation("home");
|
||
|
||
useEffect(() => {
|
||
apiFetch<{ ok: boolean; items: City[] }>("/api/cities")
|
||
.then((data) => {
|
||
if (data.items?.length) {
|
||
setLiveCities(data.items.map((item) => ({ ...item, id: Number((item as unknown as { idNumber?: number }).idNumber || item.id) })));
|
||
}
|
||
})
|
||
.catch(() => setLiveCities(cities));
|
||
apiFetch<{ ok: boolean; items: ContentItem[] }>("/api/content/home")
|
||
.then((data) => setHomeContent(data.items?.length ? data.items : FALLBACK_HOME_CONTENT))
|
||
.catch(() => setHomeContent(FALLBACK_HOME_CONTENT));
|
||
apiFetch<{ stats: { cities: number; nomads: number; meetups: number; minCost: number; profiles: number; discussions: number } }>("/api/stats/overview")
|
||
.then((data) => {
|
||
setHomeStats({
|
||
cities: data.stats.cities.toLocaleString(),
|
||
nomads: data.stats.nomads.toLocaleString(),
|
||
homeMeetups: data.stats.meetups.toLocaleString(),
|
||
cost: `¥${data.stats.minCost.toLocaleString()}`,
|
||
});
|
||
setCommunityStats({
|
||
onlineMembers: `${data.stats.profiles.toLocaleString()}+`,
|
||
messagesPerMonth: `${Math.max(data.stats.discussions * 180, 1).toLocaleString()}+`,
|
||
});
|
||
})
|
||
.catch(() => {
|
||
setHomeStats(fallbackStats);
|
||
setCommunityStats(fallbackCommunityStats);
|
||
});
|
||
apiFetch<{ items: ProfileItem[] }>("/api/profiles")
|
||
.then((data) => {
|
||
const profiles = (data.items || []).slice(0, 12);
|
||
if (profiles.length) {
|
||
setMemberPhotos(profiles.map((profile) => ({ name: profile.name, photo: profile.photo })));
|
||
}
|
||
})
|
||
.catch(() => setMemberPhotos(fallbackMemberPhotos));
|
||
apiFetch<{ items: MeetupItem[] }>("/api/meetups")
|
||
.then((data) => {
|
||
const meetups = (data.items || []).slice(0, 6);
|
||
if (meetups.length) {
|
||
setHomeMeetups(
|
||
meetups.map((meetup) => ({
|
||
city: meetup.city,
|
||
emoji: meetup.emoji || "📍",
|
||
date: shortDate(meetup.date),
|
||
count: Number(meetup.rsvpCount || 0),
|
||
}))
|
||
);
|
||
}
|
||
})
|
||
.catch(() => setHomeMeetups(fallbackHomeMeetups));
|
||
}, []);
|
||
|
||
const filteredCities = useMemo(() => {
|
||
let result = [...liveCities];
|
||
if (activeFilter !== "全部") {
|
||
result = result.filter((c) => c.tags.includes(activeFilter));
|
||
}
|
||
result.sort((a, b) => {
|
||
switch (sortBy) {
|
||
case "cost-asc":
|
||
return a.costPerMonth - b.costPerMonth;
|
||
case "cost-desc":
|
||
return b.costPerMonth - a.costPerMonth;
|
||
case "internet":
|
||
return b.internetSpeed - a.internetSpeed;
|
||
case "safety":
|
||
return safetyOrder(b.safety) - safetyOrder(a.safety);
|
||
case "nomads":
|
||
return b.nomadsNow - a.nomadsNow;
|
||
case "temperature":
|
||
return b.temperature - a.temperature;
|
||
default:
|
||
return b.overallScore - a.overallScore;
|
||
}
|
||
});
|
||
return result;
|
||
}, [activeFilter, sortBy, liveCities]);
|
||
|
||
const homepageFeatureContent = useMemo(
|
||
() => homeContent.filter((item) => !HIDDEN_HOME_RIGHT_CARD_TYPES.has(item.type)).slice(0, 4),
|
||
[homeContent]
|
||
);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||
<HeroSection />
|
||
|
||
<div className="border-y border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-4 flex flex-wrap items-center justify-center gap-6 sm:gap-12 text-center">
|
||
<div>
|
||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.cities}</span>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.cities")}</p>
|
||
</div>
|
||
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
|
||
<div>
|
||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.nomads}</span>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.nomads")}</p>
|
||
</div>
|
||
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
|
||
<div>
|
||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.homeMeetups}</span>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.meetups")}</p>
|
||
</div>
|
||
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
|
||
<div>
|
||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.cost}</span>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.cost")}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-3 sm:px-5 md:px-10 pt-5">
|
||
<FilterBar
|
||
activeFilter={activeFilter}
|
||
onFilterChange={setActiveFilter}
|
||
sortBy={sortBy}
|
||
onSortChange={setSortBy}
|
||
resultCount={filteredCities.length}
|
||
/>
|
||
</div>
|
||
|
||
<div className="items-grid">
|
||
{homepageFeatureContent.map((item, index) => {
|
||
const title = locale === "zh" ? item.title : item.titleEn || item.title;
|
||
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
|
||
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
|
||
const typeMeta: Record<string, { header: string; icon: string; href: string }> = {
|
||
ebook: { header: "电子书", icon: "📘", href: `/ebooks/${item.slug}` },
|
||
video: { header: "访谈视频", icon: "▶️", href: `/videos/${item.slug}` },
|
||
app: { header: "App", icon: "📱", href: "/app" },
|
||
guide: { header: "数字游民指南", icon: "🧭", href: "/digital" },
|
||
};
|
||
const meta = typeMeta[item.type] || typeMeta.guide;
|
||
const isExternal = meta.href.startsWith("http");
|
||
const content = (
|
||
<>
|
||
<div className="right-item-header">{meta.header}</div>
|
||
<div className="relative h-[calc(100%-38px)] sm:h-[calc(100%-42px)] overflow-hidden">
|
||
<img src={mediaUrl(item.coverImage)} alt={title} className="absolute inset-0 h-full w-full object-cover" />
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/35 to-transparent" />
|
||
<div className="absolute inset-x-0 bottom-0 p-3 sm:p-4 text-white">
|
||
<div className="mb-1 text-xl sm:text-2xl">{meta.icon}</div>
|
||
<h3 className="line-clamp-2 text-xs sm:text-sm font-bold leading-snug">{title}</h3>
|
||
<p className="mt-1 line-clamp-2 text-[10px] sm:text-xs text-white/80">{subtitle}</p>
|
||
<span className="mt-2 inline-flex rounded-full bg-white/90 px-2.5 py-1 text-[10px] sm:text-xs font-semibold text-gray-900">
|
||
{cta}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
return isExternal ? (
|
||
<a
|
||
key={item.slug}
|
||
href={meta.href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="right-item block"
|
||
style={{ gridColumn: "-2 / -1", gridRow: index + 1 }}
|
||
>
|
||
{content}
|
||
</a>
|
||
) : (
|
||
<Link
|
||
key={item.slug}
|
||
href={meta.href}
|
||
className="right-item block"
|
||
style={{ gridColumn: "-2 / -1", gridRow: index + 1 }}
|
||
>
|
||
{content}
|
||
</Link>
|
||
);
|
||
})}
|
||
|
||
<Link href="/join" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 1 }}>
|
||
<div className="right-item-header">{t("joinCommunity")}</div>
|
||
<div className="flex flex-col items-center justify-center text-center p-2 sm:p-5 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
|
||
<span className="text-2xl sm:text-5xl mb-1 sm:mb-3">💬</span>
|
||
<p className="text-[10px] sm:text-sm text-gray-600 dark:text-gray-400 mb-0.5">
|
||
{t("joinCommunityDesc")}
|
||
</p>
|
||
<p className="text-[9px] sm:text-xs text-gray-400 dark:text-gray-500 mb-1.5 sm:mb-4">
|
||
{communityStats.onlineMembers} {t("membersOnline")}
|
||
</p>
|
||
<span className="bg-[#ff4d4f] text-white px-2.5 sm:px-6 py-1 sm:py-2 rounded-lg text-[10px] sm:text-sm font-semibold whitespace-nowrap">
|
||
{t("joinCommunity")} →
|
||
</span>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link href="/dating" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 2 }}>
|
||
<div className="right-item-header">{t("newMembers")}</div>
|
||
<div className="p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
|
||
{memberPhotos.map((m) => (
|
||
<img
|
||
key={m.name}
|
||
src={mediaUrl(m.photo)}
|
||
alt={m.name}
|
||
title={m.name}
|
||
className="w-6 h-6 sm:w-10 sm:h-10 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||
/>
|
||
))}
|
||
<div className="w-full mt-0.5 text-[9px] sm:text-xs text-gray-400 dark:text-gray-500 text-center">
|
||
{t("joinedThisMonthShort")}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link href="/meetups" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 3 }}>
|
||
<div className="right-item-header">{t("latestEvents")}</div>
|
||
<div className="p-1.5 sm:p-4 flex flex-col gap-0.5 sm:gap-2.5 text-[10px] sm:text-sm text-gray-700 dark:text-gray-300 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
|
||
{homeMeetups.slice(0, 3).map((meetup) => (
|
||
<div
|
||
key={`${meetup.city}-${meetup.date}`}
|
||
className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100"
|
||
>
|
||
<span>{meetup.emoji}</span>
|
||
<span className="line-clamp-1">{meetup.city} · {meetup.date} · {meetup.count}{t("people")}</span>
|
||
</div>
|
||
))}
|
||
<div className="flex -space-x-1 mt-auto">
|
||
{memberPhotos.slice(0, 5).map((m) => (
|
||
<img
|
||
key={m.name}
|
||
src={mediaUrl(m.photo)}
|
||
alt=""
|
||
className="w-4 h-4 sm:w-7 sm:h-7 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
{filteredCities.map((city, i) => (
|
||
<CityCard
|
||
key={city.id}
|
||
city={city}
|
||
rank={i + 1}
|
||
onClick={() => setCityModal(city)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{filteredCities.length === 0 && (
|
||
<div className="text-center py-16 sm:py-20">
|
||
<span className="text-4xl sm:text-5xl mb-4 block">🏜️</span>
|
||
<p className="text-gray-500 dark:text-gray-400 text-base sm:text-lg">
|
||
{t("noMatch")}
|
||
</p>
|
||
<button
|
||
onClick={() => setActiveFilter("全部")}
|
||
className="mt-4 text-sm text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 px-5 py-2 rounded-full hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||
>
|
||
{t("showAll")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<CityModal
|
||
city={cityModal}
|
||
isOpen={!!cityModal}
|
||
onClose={() => setCityModal(null)}
|
||
/>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|