226 lines
8.7 KiB
TypeScript
226 lines
8.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, type ComponentType } from "react";
|
||
import dynamic from "next/dynamic";
|
||
import { api } from "@/lib/api";
|
||
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
|
||
import Loader from "./Loader";
|
||
import ParticleCanvas from "./ParticleCanvas";
|
||
import Navbar from "./Navbar";
|
||
import TickerBar from "./TickerBar";
|
||
import Hero from "./Hero";
|
||
import WorldMap from "./WorldMap";
|
||
import Destinations from "./Destinations";
|
||
import GlobalSearch from "./GlobalSearch";
|
||
import Footer from "./Footer";
|
||
import CompareModal from "./CompareModal";
|
||
import DestinationMatcher, { MatcherTrigger } from "./DestinationMatcher";
|
||
import ScrollProgress from "./ScrollProgress";
|
||
import SectionNav from "./SectionNav";
|
||
import CookieConsent from "./CookieConsent";
|
||
import NomadTips from "./NomadTips";
|
||
import DailyQuote from "./DailyQuote";
|
||
import ToolsStrip from "./ToolsStrip";
|
||
import FeedbackWidget from "./FeedbackWidget";
|
||
import OnboardingTour from "./OnboardingTour";
|
||
import KeyboardShortcuts from "./KeyboardShortcuts";
|
||
import BackToTop from "./BackToTop";
|
||
import TripPlanner from "./TripPlanner";
|
||
import Calculator from "./Calculator";
|
||
import SpinGlobe from "./SpinGlobe";
|
||
|
||
const lazy = <P extends object>(loader: () => Promise<{ default: ComponentType<P> }>) =>
|
||
dynamic(loader, { ssr: false, loading: () => null });
|
||
|
||
const TimezoneBoard = lazy(() => import("./TimezoneBoard"));
|
||
const JetLagEstimator = lazy(() => import("./JetLagEstimator"));
|
||
const CostCompare = lazy(() => import("./CostCompare"));
|
||
const SavingsGoal = lazy(() => import("./SavingsGoal"));
|
||
const RunwayCalculator = lazy(() => import("./RunwayCalculator"));
|
||
const ExpenseTracker = lazy(() => import("./ExpenseTracker"));
|
||
const TaxDayCounter = lazy(() => import("./TaxDayCounter"));
|
||
const FlightCost = lazy(() => import("./FlightCost"));
|
||
const CurrencyConverter = lazy(() => import("./CurrencyConverter"));
|
||
const MeetingWindow = lazy(() => import("./MeetingWindow"));
|
||
const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
|
||
const FocusTimer = lazy(() => import("./FocusTimer"));
|
||
const NomadScore = lazy(() => import("./NomadScore"));
|
||
const Lifestyle = lazy(() => import("./Lifestyle"));
|
||
const Charts = lazy(() => import("./Charts"));
|
||
const VisaSection = lazy(() => import("./VisaSection"));
|
||
const InsuranceGuide = lazy(() => import("./InsuranceGuide"));
|
||
const EmergencyContacts = lazy(() => import("./EmergencyContacts"));
|
||
const AtmCashGuide = lazy(() => import("./AtmCashGuide"));
|
||
const FoodWaterTips = lazy(() => import("./FoodWaterTips"));
|
||
const CoworkingGuide = lazy(() => import("./CoworkingGuide"));
|
||
const HousingGuide = lazy(() => import("./HousingGuide"));
|
||
const SimGuide = lazy(() => import("./SimGuide"));
|
||
const PowerPlugGuide = lazy(() => import("./PowerPlugGuide"));
|
||
const CafeGuide = lazy(() => import("./CafeGuide"));
|
||
const SeasonGuide = lazy(() => import("./SeasonGuide"));
|
||
const PackingChecklist = lazy(() => import("./PackingChecklist"));
|
||
const ArrivalChecklist = lazy(() => import("./ArrivalChecklist"));
|
||
const Phrasebook = lazy(() => import("./Phrasebook"));
|
||
const CityNotes = lazy(() => import("./CityNotes"));
|
||
const NomadEvents = lazy(() => import("./NomadEvents"));
|
||
const Tools = lazy(() => import("./Tools"));
|
||
const BlogSection = lazy(() => import("./BlogSection"));
|
||
const FAQSection = lazy(() => import("./FAQSection"));
|
||
const Community = lazy(() => import("./Community"));
|
||
|
||
interface Props {
|
||
stats: Stats;
|
||
ticker: string[];
|
||
destinations: Destination[];
|
||
visas: Visa[];
|
||
faqs: FAQ[];
|
||
testimonials: Testimonial[];
|
||
tools: Tool[];
|
||
blog: BlogPost[];
|
||
charts: { cost: ChartData; speed: ChartData; growth: ChartData; radar: ChartData };
|
||
}
|
||
|
||
export default function HomeClient(props: Props) {
|
||
const [compareList, setCompareList] = useState<string[]>([]);
|
||
const [compareResults, setCompareResults] = useState<Destination[] | null>(null);
|
||
const [compareOpen, setCompareOpen] = useState(false);
|
||
const [matcherOpen, setMatcherOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const onMatcher = () => setMatcherOpen(true);
|
||
window.addEventListener("open-matcher", onMatcher);
|
||
return () => window.removeEventListener("open-matcher", onMatcher);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const observer = new IntersectionObserver(
|
||
(entries) => entries.forEach((e) => {
|
||
if (e.isIntersecting) {
|
||
e.target.classList.add("visible");
|
||
observer.unobserve(e.target);
|
||
}
|
||
}),
|
||
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" }
|
||
);
|
||
|
||
let scheduled = 0;
|
||
const observeAll = () => {
|
||
document.querySelectorAll(".reveal:not(.visible)").forEach((el, i) => {
|
||
(el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.1}s`;
|
||
observer.observe(el);
|
||
});
|
||
};
|
||
|
||
const schedule = () => {
|
||
if (scheduled) return;
|
||
scheduled = window.setTimeout(() => {
|
||
scheduled = 0;
|
||
observeAll();
|
||
}, 80);
|
||
};
|
||
|
||
observeAll();
|
||
const mo = new MutationObserver(schedule);
|
||
mo.observe(document.body, { childList: true, subtree: true });
|
||
return () => {
|
||
observer.disconnect();
|
||
mo.disconnect();
|
||
if (scheduled) clearTimeout(scheduled);
|
||
};
|
||
}, []);
|
||
|
||
const toggleCompare = (slug: string) => {
|
||
setCompareList((prev) => {
|
||
if (prev.includes(slug)) return prev.filter((s) => s !== slug);
|
||
if (prev.length >= 4) return prev;
|
||
return [...prev, slug];
|
||
});
|
||
setCompareResults(null);
|
||
};
|
||
|
||
const runCompare = async () => {
|
||
if (compareList.length < 2) return;
|
||
try {
|
||
const data = await api.compareDestinations(compareList);
|
||
setCompareResults(data);
|
||
setCompareOpen(true);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<ScrollProgress />
|
||
<Loader />
|
||
<ParticleCanvas />
|
||
<Navbar />
|
||
<GlobalSearch />
|
||
<TickerBar messages={props.ticker} />
|
||
<Hero stats={props.stats} />
|
||
<WorldMap destinations={props.destinations} />
|
||
<Destinations destinations={props.destinations} onCompare={toggleCompare} compareList={compareList} onOpenMatcher={() => setMatcherOpen(true)} />
|
||
<SpinGlobe destinations={props.destinations} />
|
||
<DailyQuote />
|
||
<ToolsStrip />
|
||
<TimezoneBoard destinations={props.destinations} />
|
||
<JetLagEstimator destinations={props.destinations} />
|
||
<TripPlanner destinations={props.destinations} />
|
||
<Calculator destinations={props.destinations} />
|
||
<CostCompare destinations={props.destinations} />
|
||
<SavingsGoal destinations={props.destinations} />
|
||
<RunwayCalculator destinations={props.destinations} />
|
||
<ExpenseTracker />
|
||
<TaxDayCounter />
|
||
<FlightCost destinations={props.destinations} />
|
||
<CurrencyConverter />
|
||
<MeetingWindow destinations={props.destinations} />
|
||
<WifiWorkScore destinations={props.destinations} />
|
||
<FocusTimer />
|
||
<NomadScore />
|
||
<Lifestyle />
|
||
<Charts {...props.charts} />
|
||
<VisaSection visas={props.visas} />
|
||
<InsuranceGuide />
|
||
<EmergencyContacts destinations={props.destinations} />
|
||
<AtmCashGuide destinations={props.destinations} />
|
||
<FoodWaterTips destinations={props.destinations} />
|
||
<CoworkingGuide />
|
||
<HousingGuide destinations={props.destinations} />
|
||
<SimGuide destinations={props.destinations} />
|
||
<PowerPlugGuide destinations={props.destinations} />
|
||
<CafeGuide destinations={props.destinations} />
|
||
<SeasonGuide destinations={props.destinations} />
|
||
<PackingChecklist />
|
||
<ArrivalChecklist destinations={props.destinations} />
|
||
<Phrasebook destinations={props.destinations} />
|
||
<CityNotes destinations={props.destinations} />
|
||
<NomadEvents />
|
||
<Tools tools={props.tools} />
|
||
<BlogSection posts={props.blog} />
|
||
<FAQSection faqs={props.faqs} />
|
||
<Community testimonials={props.testimonials} />
|
||
<Footer />
|
||
<BackToTop />
|
||
<SectionNav />
|
||
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
|
||
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
|
||
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
||
<CookieConsent />
|
||
<NomadTips />
|
||
<FeedbackWidget />
|
||
<OnboardingTour />
|
||
|
||
{compareList.length > 0 && (
|
||
<div className="compare-bar">
|
||
<span>⚖️ 已选 {compareList.length}/4</span>
|
||
<button className="btn btn-primary" onClick={runCompare} disabled={compareList.length < 2}>对比城市</button>
|
||
<button className="btn btn-ghost" onClick={() => { setCompareList([]); setCompareResults(null); }}>清空</button>
|
||
</div>
|
||
)}
|
||
|
||
{compareOpen && compareResults && (
|
||
<CompareModal destinations={compareResults} onClose={() => setCompareOpen(false)} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|