Files
gitlab-instance-0a899031_cn…/app/[locale]/page.tsx
2026-06-07 23:18:53 +08:00

408 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}