414 lines
20 KiB
TypeScript
414 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo } from "react";
|
||
import { Link, useLocale, useRouter } 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";
|
||
|
||
const { memberPhotos, travelingNow, meetups: homeMeetups, hotTopics, routes, stats, communityStats } = HOME_CONFIG;
|
||
|
||
function safetyOrder(s: string): number {
|
||
const o: Record<string, number> = { 极好: 5, 很好: 4, 好: 3, 一般: 2, 差: 1 };
|
||
return o[s] || 0;
|
||
}
|
||
|
||
export default function Home() {
|
||
const [activeFilter, setActiveFilter] = useState("全部");
|
||
const [sortBy, setSortBy] = useState("score");
|
||
const [cityModal, setCityModal] = useState<City | null>(null);
|
||
const locale = useLocale();
|
||
const router = useRouter();
|
||
const { t } = useTranslation("home");
|
||
const { t: tNav } = useTranslation("nav");
|
||
|
||
const filteredCities = useMemo(() => {
|
||
let result = [...cities];
|
||
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]);
|
||
|
||
const cityTags = [
|
||
{ key: "dali", emoji: "🏯", zh: "大理", en: "Dali" },
|
||
{ key: "chengdu", emoji: "🐼", zh: "成都", en: "Chengdu" },
|
||
{ key: "shenzhen", emoji: "🌃", zh: "深圳", en: "Shenzhen" },
|
||
{ key: "hangzhou", emoji: "⛲", zh: "杭州", en: "Hangzhou" },
|
||
];
|
||
|
||
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">{stats.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">{stats.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">{stats.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">{stats.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">
|
||
<Link href="/join" className="right-item block">
|
||
<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">
|
||
<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={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">
|
||
<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)]">
|
||
<div 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">
|
||
🗓 {t("meetupDate")}
|
||
</div>
|
||
<div 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">
|
||
📍 {t("meetupLocation")}
|
||
</div>
|
||
<div 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">
|
||
👥 {t("meetupPeople")}
|
||
</div>
|
||
<div className="flex -space-x-1 mt-auto">
|
||
{memberPhotos.slice(0, 5).map((m) => (
|
||
<img
|
||
key={m.name}
|
||
src={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>
|
||
)}
|
||
|
||
<section className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 pt-8 sm:pt-12 pb-4">
|
||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-5 sm:mb-6 flex items-center gap-2">
|
||
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
|
||
{t("hotRoutes")}
|
||
</h2>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
||
{routes.map((route) => (
|
||
<Link
|
||
key={route.titleZh}
|
||
href={`/dashboard?route=${encodeURIComponent(locale === "zh" ? route.titleZh : route.titleEn)}`}
|
||
className="community-card group cursor-pointer block"
|
||
>
|
||
<div className={`h-2 bg-gradient-to-r ${route.gradient}`} />
|
||
<div className="p-4 sm:p-5">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">
|
||
{locale === "zh" ? route.titleZh : route.titleEn}
|
||
</h3>
|
||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||
{locale === "zh" ? route.durationZh : route.durationEn}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 mb-3">
|
||
{route.emojis.map((emoji, i) => (
|
||
<span key={i} className="flex items-center gap-0.5 text-sm">
|
||
<span>{emoji}</span>
|
||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||
{locale === "zh" ? route.citiesZh[i] : route.citiesEn[i]}
|
||
</span>
|
||
{i < route.emojis.length - 1 && (
|
||
<span className="text-gray-300 dark:text-gray-600 mx-1">→</span>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
|
||
{locale === "zh" ? route.descZh : route.descEn}
|
||
</p>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||
{locale === "zh" ? route.costZh : route.costEn}
|
||
</span>
|
||
<span className="text-xs text-[#ff4d4f] font-medium group-hover:translate-x-1 transition-transform">
|
||
{t("routeDetails")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section id="community" className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 py-8 sm:py-12">
|
||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-5 sm:mb-6 flex items-center gap-2">
|
||
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
|
||
{t("communityDynamics")}
|
||
</h2>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
||
<Link href="/meetups" className="community-card block">
|
||
<div className="p-4 sm:p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm flex items-center gap-1.5">
|
||
🍹 {t("recentMeetups")}
|
||
</h3>
|
||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||
{homeMeetups.length}{t("perMonth")}
|
||
</span>
|
||
</div>
|
||
<div className="space-y-2.5">
|
||
{homeMeetups.map((m) => (
|
||
<div key={m.city} className="flex items-center justify-between py-1.5">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base">{m.emoji}</span>
|
||
<div>
|
||
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">{m.city}</p>
|
||
<p className="text-[11px] text-gray-400 dark:text-gray-500">{m.date}</p>
|
||
</div>
|
||
</div>
|
||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||
{m.count}{t("people")}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="block w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 transition-colors">
|
||
{t("viewMore")}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<div className="community-card">
|
||
<div className="p-4 sm:p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm flex items-center gap-1.5">
|
||
🛩️ {t("travelingNow")}
|
||
</h3>
|
||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||
{travelingNow.length}{t("people")}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{travelingNow.map((m) => (
|
||
<img
|
||
key={m.name}
|
||
src={m.photo}
|
||
alt={m.name}
|
||
title={`${m.name} · ${m.dest}`}
|
||
className="w-9 h-9 rounded-full shadow-sm cursor-pointer hover:scale-110 transition-transform object-cover"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Link href="/discuss" className="community-card block">
|
||
<div className="p-4 sm:p-5">
|
||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm flex items-center gap-1.5 mb-4">
|
||
🔥 {t("hotDiscussions")}
|
||
</h3>
|
||
<div className="space-y-3">
|
||
{hotTopics.map((t_item, i) => (
|
||
<div key={t_item.titleZh} className="flex items-start gap-2.5 group">
|
||
<span className={`text-xs font-bold mt-0.5 w-4 shrink-0 ${i < 3 ? "text-[#ff4d4f]" : "text-gray-300 dark:text-gray-600"}`}>
|
||
{i + 1}
|
||
</span>
|
||
<div className="flex-1">
|
||
<p className="min-w-0 text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors line-clamp-1 leading-snug">
|
||
{locale === "zh" ? t_item.titleZh : t_item.titleEn}
|
||
</p>
|
||
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">
|
||
{t_item.views.toLocaleString()} {t("views")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="block w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 transition-colors">
|
||
{t("viewAllTopics")}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link href="/services" className="community-card bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-950/30 dark:to-teal-950/30 block">
|
||
<div className="p-4 sm:p-5">
|
||
<div className="flex items-start gap-3 mb-3">
|
||
<span className="text-2xl">🛡️</span>
|
||
<div>
|
||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">{t("travelInsurance")}</h3>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t("travelInsuranceDesc")}</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-emerald-700 dark:text-emerald-400 bg-emerald-100/50 dark:bg-emerald-900/30 rounded-lg px-3 py-2 mb-3">
|
||
{t("insuranceCoverage")}
|
||
</p>
|
||
<div className="w-full text-center text-sm font-semibold text-emerald-700 dark:text-emerald-400 hover:text-emerald-800 dark:hover:text-emerald-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-emerald-200 dark:border-emerald-800 rounded-full py-2 transition-colors">
|
||
{t("learnMore")}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link href="/join" className="community-card bg-gradient-to-br from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30 block">
|
||
<div className="p-4 sm:p-5">
|
||
<div className="flex items-start gap-3 mb-3">
|
||
<span className="text-2xl">💬</span>
|
||
<div>
|
||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">{t("communityChat")}</h3>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{locale === "zh" ? `本月 ${communityStats.messagesPerMonth} 条消息` : `${communityStats.messagesPerMonth} messages this month`}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<div className="flex -space-x-2">
|
||
{memberPhotos.slice(0, 6).map((m) => (
|
||
<img
|
||
key={m.name}
|
||
src={m.photo}
|
||
alt=""
|
||
className="w-7 h-7 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||
/>
|
||
))}
|
||
</div>
|
||
<span className="text-xs text-gray-400 dark:text-gray-500">{t("onlineNow")}</span>
|
||
</div>
|
||
<div className="w-full text-center text-sm font-semibold text-violet-700 dark:text-violet-400 hover:text-violet-800 dark:hover:text-violet-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-violet-200 dark:border-violet-800 rounded-full py-2 transition-colors">
|
||
{t("joinChat")}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<Link href="/tools" className="community-card bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 block">
|
||
<div className="p-4 sm:p-5">
|
||
<div className="flex items-start gap-3 mb-3">
|
||
<span className="text-2xl">🏛️</span>
|
||
<div>
|
||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">{t("landingGuide")}</h3>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||
{t("landingGuideDesc")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||
{cityTags.map((tag) => (
|
||
<button
|
||
key={tag.key}
|
||
onClick={() => router.push(`/city/${tag.key}`)}
|
||
className="text-[11px] bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full px-2 py-0.5 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
|
||
>
|
||
{tag.emoji} {locale === "zh" ? tag.zh : tag.en}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="w-full text-center text-sm font-semibold text-amber-700 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-amber-200 dark:border-amber-800 rounded-full py-2 transition-colors">
|
||
{t("viewGuides")}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
</div>
|
||
</section>
|
||
|
||
<CityModal
|
||
city={cityModal}
|
||
isOpen={!!cityModal}
|
||
onClose={() => setCityModal(null)}
|
||
/>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|