nomadweb/frontend/src/components/HomeClient.tsx
eric b4f1846d65 Add runway calculator and meeting golden-hour timeline with polished UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:12:33 -05:00

226 lines
8.7 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, 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)} />
)}
</>
);
}