-
向下滚动
+
{t.hero.scroll}
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx
index f71abeb..4c8411d 100644
--- a/frontend/src/components/HomeClient.tsx
+++ b/frontend/src/components/HomeClient.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useEffect, useState, type ComponentType } from "react";
-import Link from "next/link";
+import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import type { Destination, BlogPost, FAQ, Testimonial, Tool, Visa, Stats, ChartData } from "@/lib/types";
import Loader from "./Loader";
@@ -9,6 +8,7 @@ import ParticleCanvas from "./ParticleCanvas";
import Navbar from "./Navbar";
import TickerBar from "./TickerBar";
import Hero from "./Hero";
+import JourneyStrip from "./JourneyStrip";
import WorldMap from "./WorldMap";
import Destinations from "./Destinations";
import GlobalSearch from "./GlobalSearch";
@@ -19,69 +19,12 @@ import BackToTop from "./BackToTop";
import MatcherTrigger from "./MatcherTrigger";
import WhenVisible from "./WhenVisible";
-const lazy =
(loader: () => Promise<{ default: ComponentType
}>) =>
+const lazy =
(loader: () => Promise<{ default: import("react").ComponentType
}>) =>
dynamic(loader, { ssr: false, loading: () => null });
-const SpinGlobe = lazy(() => import("./SpinGlobe"));
-const DailyQuote = lazy(() => import("./DailyQuote"));
-const ToolsStrip = lazy(() => import("./ToolsStrip"));
-const TripPlanner = lazy(() => import("./TripPlanner"));
-const Calculator = lazy(() => import("./Calculator"));
-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 FirstMonthCost = lazy(() => import("./FirstMonthCost"));
-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 DaylightWorkHours = lazy(() => import("./DaylightWorkHours"));
-const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
-const WorkSpotCost = lazy(() => import("./WorkSpotCost"));
-const DataBudgetPlanner = lazy(() => import("./DataBudgetPlanner"));
-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 VisaStayCountdown = lazy(() => import("./VisaStayCountdown"));
-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 BagWeightEstimator = lazy(() => import("./BagWeightEstimator"));
-const ArrivalChecklist = lazy(() => import("./ArrivalChecklist"));
-const Phrasebook = lazy(() => import("./Phrasebook"));
-const CityNotes = lazy(() => import("./CityNotes"));
-const NomadEvents = lazy(() => import("./NomadEvents"));
-const WeekendIdeas = lazy(() => import("./WeekendIdeas"));
-const DepositReturnChecklist = lazy(() => import("./DepositReturnChecklist"));
-const HydrationTracker = lazy(() => import("./HydrationTracker"));
-const TipGuide = lazy(() => import("./TipGuide"));
-const BillSplit = lazy(() => import("./BillSplit"));
-const AirportTransferPick = lazy(() => import("./AirportTransferPick"));
-const NomadMoodCheck = lazy(() => import("./NomadMoodCheck"));
-const RemoteDayPlan = lazy(() => import("./RemoteDayPlan"));
-const LaundryDay = lazy(() => import("./LaundryDay"));
-const LocalScamAlerts = lazy(() => import("./LocalScamAlerts"));
-const ColivingQuestions = lazy(() => import("./ColivingQuestions"));
-const SleepWindDown = lazy(() => import("./SleepWindDown"));
-const InvoiceFxHelper = lazy(() => import("./InvoiceFxHelper"));
-const RainyDayBackup = lazy(() => import("./RainyDayBackup"));
-const Tools = lazy(() => import("./Tools"));
const BlogSection = lazy(() => import("./BlogSection"));
const FAQSection = lazy(() => import("./FAQSection"));
-const Community = lazy(() => import("./Community"));
const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
const CookieConsent = lazy(() => import("./CookieConsent"));
const NomadTips = lazy(() => import("./NomadTips"));
@@ -102,7 +45,6 @@ interface Props {
}
export default function HomeClient(props: Props) {
- const [compareList, setCompareList] = useState([]);
const [matcherOpen, setMatcherOpen] = useState(false);
const [chromeReady, setChromeReady] = useState(false);
@@ -117,28 +59,22 @@ export default function HomeClient(props: Props) {
const ric = window.requestIdleCallback
? window.requestIdleCallback(start, { timeout: 2800 })
: 0;
- const t = window.setTimeout(start, 1200);
+ const timer = window.setTimeout(start, 1200);
return () => {
- clearTimeout(t);
+ clearTimeout(timer);
if (ric && window.cancelIdleCallback) window.cancelIdleCallback(ric);
};
}, []);
useEffect(() => {
- // Above-fold reveal; keep watching briefly for late client mounts
- const mark = (root: ParentNode = document) => {
- root.querySelectorAll(".reveal:not(.visible)").forEach((el, i) => {
- (el as HTMLElement).style.transitionDelay = `${(i % 6) * 0.08}s`;
- });
- };
- mark();
const observer = new IntersectionObserver(
- (entries) => entries.forEach((e) => {
- if (e.isIntersecting) {
- e.target.classList.add("visible");
- observer.unobserve(e.target);
- }
- }),
+ (entries) =>
+ entries.forEach((e) => {
+ if (e.isIntersecting) {
+ e.target.classList.add("visible");
+ observer.unobserve(e.target);
+ }
+ }),
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" }
);
const observeAll = () => {
@@ -153,122 +89,54 @@ export default function HomeClient(props: Props) {
};
}, []);
- 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];
- });
- };
-
- const runCompare = () => {
- if (compareList.length < 2) return;
- window.location.href = `/compare?cities=${compareList.join(",")}`;
- };
+ const openMatcher = () => setMatcherOpen(true);
return (
- <>
+
-
+
+
+
-
setMatcherOpen(true)} />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
- setMatcherOpen(true)} />
+
+
{matcherOpen && (
- setMatcherOpen(false)} />
+ setMatcherOpen(false)}
+ />
)}
+
{chromeReady && (
<>
- setMatcherOpen(true)} />
+
>
)}
-
- {compareList.length > 0 && (
-
- ⚖️ 已选 {compareList.length}/4
-
- = 2 ? `/compare?cities=${compareList.join(",")}` : "/compare"}
- className={`btn btn-ghost${compareList.length < 2 ? " disabled" : ""}`}
- aria-disabled={compareList.length < 2}
- onClick={(e) => { if (compareList.length < 2) e.preventDefault(); }}
- >
- 完整对比页
-
-
-
- )}
- >
+
);
}
diff --git a/frontend/src/components/JoinClient.tsx b/frontend/src/components/JoinClient.tsx
new file mode 100644
index 0000000..688038f
--- /dev/null
+++ b/frontend/src/components/JoinClient.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { api } from "@/lib/api";
+import { useAuth } from "@/lib/auth";
+import { useToast } from "@/lib/toast";
+import { useI18n } from "@/lib/i18n";
+
+export default function JoinClient() {
+ const { user, token } = useAuth();
+ const router = useRouter();
+ const { toast } = useToast();
+ const { t } = useI18n();
+ const [city, setCity] = useState("清迈");
+ const [bio, setBio] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const submit = async () => {
+ if (!token) {
+ router.push("/login?next=/join");
+ return;
+ }
+ setLoading(true);
+ try {
+ await api.socialJoin(token, { city, bio, lookingFor: ["friends", "explore"] });
+ toast(t.join.profileOk, "success");
+ } catch {
+ toast(t.join.profileFail, "error");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const pay = async () => {
+ if (!token) {
+ router.push("/login?next=/join");
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await api.createPayment(token, `${window.location.origin}/join/paid`, "join");
+ localStorage.setItem("nomadro-pay-order", res.order_id);
+ window.location.href = res.redirect_url;
+ } catch {
+ toast(t.join.payFail, "error");
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
{t.join.tag}
+
{t.join.title}
+
{t.join.subtitle}
+
+
+
+
+
{t.join.step1}
+
+ setCity(e.target.value)} />
+
+
+
+
{t.join.step2}
+
{t.join.vipDesc}
+
+ - ✨ {t.join.perk1}
+ - 💬 {t.join.perk2}
+ - 🎙️ {t.join.perk3}
+ - 📚 {t.join.perk4}
+
+
+ {!user &&
{t.nav.login}}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/JoinPaidClient.tsx b/frontend/src/components/JoinPaidClient.tsx
new file mode 100644
index 0000000..94e4c79
--- /dev/null
+++ b/frontend/src/components/JoinPaidClient.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { api } from "@/lib/api";
+import { useAuth } from "@/lib/auth";
+import { useI18n } from "@/lib/i18n";
+
+export default function JoinPaidClient() {
+ const { token } = useAuth();
+ const searchParams = useSearchParams();
+ const { t } = useI18n();
+ const [status, setStatus] = useState<"loading" | "ok" | "fail">("loading");
+
+ useEffect(() => {
+ const orderId = searchParams.get("order_id") || localStorage.getItem("nomadro-pay-order");
+ if (!token || !orderId) {
+ setStatus("fail");
+ return;
+ }
+ let tries = 0;
+ const run = async () => {
+ try {
+ await api.completePayment(token, orderId);
+ localStorage.removeItem("nomadro-pay-order");
+ setStatus("ok");
+ } catch {
+ tries += 1;
+ if (tries < 5) setTimeout(run, 800);
+ else setStatus("fail");
+ }
+ };
+ void run();
+ }, [token, searchParams]);
+
+ return (
+
+
+
+ {status === "loading" &&
{t.join.paidLoading}
}
+ {status === "ok" && (
+ <>
+
🎉 {t.join.paidOk}
+
{t.join.goDating}
+
{t.join.goDigital}
+ >
+ )}
+ {status === "fail" && (
+ <>
+
{t.join.paidFail}
+
{t.join.retry}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/JourneyStrip.tsx b/frontend/src/components/JourneyStrip.tsx
new file mode 100644
index 0000000..21bc0b4
--- /dev/null
+++ b/frontend/src/components/JourneyStrip.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import Link from "next/link";
+import { useI18n } from "@/lib/i18n";
+
+interface Props {
+ onOpenMatcher: () => void;
+}
+
+/** Homepage discovery path — no plan/compare on home */
+export default function JourneyStrip({ onOpenMatcher }: Props) {
+ const { t } = useI18n();
+
+ const steps = [
+ {
+ n: "01",
+ emoji: "🌍",
+ title: t.journey.step1Title,
+ desc: t.journey.step1Desc,
+ href: "/#destinations",
+ cta: t.journey.step1Cta,
+ },
+ {
+ n: "02",
+ emoji: "🎯",
+ title: t.journey.step2Title,
+ desc: t.journey.step2Desc,
+ onClick: onOpenMatcher,
+ cta: t.journey.matchCta,
+ },
+ {
+ n: "03",
+ emoji: "📋",
+ title: t.journey.step3Title,
+ desc: t.journey.step3Desc,
+ href: "/#visa",
+ cta: t.journey.step3Cta,
+ },
+ {
+ n: "04",
+ emoji: "🛠️",
+ title: t.journey.step4Title,
+ desc: t.journey.step4Desc,
+ href: "/tools",
+ cta: t.journey.step4Cta,
+ },
+ ];
+
+ return (
+
+
+
+
{t.journey.tag}
+
{t.journey.title}
+
{t.journey.subtitle}
+
+
+ {steps.map((s, i) => (
+ -
+ {i > 0 && }
+
+
+ {s.n}
+ {s.emoji}
+
+
{s.title}
+
{s.desc}
+
+ {"href" in s && s.href ? (
+
+ {s.cta}
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx
index 0b5daec..78eb78c 100644
--- a/frontend/src/components/KeyboardShortcuts.tsx
+++ b/frontend/src/components/KeyboardShortcuts.tsx
@@ -11,7 +11,6 @@ const SHORTCUTS = [
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
{ keys: ["T"], label: "打开工具箱", icon: "🛠️" },
- { keys: ["P"], label: "旅居计划中心", icon: "🗓️" },
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
@@ -42,10 +41,6 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
e.preventDefault();
window.location.href = "/tools";
}
- if (e.key === "p" && !typing && !e.metaKey && !e.ctrlKey) {
- e.preventDefault();
- window.location.href = "/plan";
- }
if (e.key === "Escape" && open) setOpen(false);
};
window.addEventListener("keydown", onKey);
diff --git a/frontend/src/components/MeetupLiveClient.tsx b/frontend/src/components/MeetupLiveClient.tsx
new file mode 100644
index 0000000..a4ddddc
--- /dev/null
+++ b/frontend/src/components/MeetupLiveClient.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { api } from "@/lib/api";
+import { useAuth } from "@/lib/auth";
+import { useI18n } from "@/lib/i18n";
+import type { MeetupSession } from "@/lib/types";
+
+export default function MeetupLiveClient({ meetupId, title }: { meetupId: string; title: string }) {
+ const { token } = useAuth();
+ const { t } = useI18n();
+ const [session, setSession] = useState(null);
+ const [layout, setLayout] = useState<"split" | "video" | "chat">("split");
+
+ useEffect(() => {
+ api.getMeetupSession(meetupId, token || undefined).then(setSession).catch(() => setSession(null));
+ }, [meetupId, token]);
+
+ if (!session) {
+ return ;
+ }
+
+ if (!session.canJoin) {
+ return (
+
+
+
+
{title}
+
{session.lockReason === "login_required" ? t.live.loginRequired : t.live.vipRequired}
+
+ {session.lockReason === "login_required" ? t.nav.login : t.join.payBtn}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
← {t.live.back}
+
{title}
+
+ {(["split", "video", "chat"] as const).map((m) => (
+
+ ))}
+
+
+
+ {(layout === "split" || layout === "video") && session.videoUrl && (
+
+ )}
+ {(layout === "split" || layout === "chat") && session.chatUrl && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/MeetupsClient.tsx b/frontend/src/components/MeetupsClient.tsx
new file mode 100644
index 0000000..a4a3f42
--- /dev/null
+++ b/frontend/src/components/MeetupsClient.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { useMemo, useState, useEffect } from "react";
+import Link from "next/link";
+import { api } from "@/lib/api";
+import { useToast } from "@/lib/toast";
+import { useAuth } from "@/lib/auth";
+import { useI18n } from "@/lib/i18n";
+import type { Meetup } from "@/lib/types";
+
+const MODE_LABELS: Record = {
+ online: "💻 线上",
+ offline: "📍 线下",
+ hybrid: "🔀 混合",
+};
+
+interface Props {
+ meetups: Meetup[];
+}
+
+export default function MeetupsClient({ meetups }: Props) {
+ const { t } = useI18n();
+ const { toast } = useToast();
+ const { token } = useAuth();
+ const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
+ const [rsvpIds, setRsvpIds] = useState>(new Set());
+
+ useEffect(() => {
+ if (!token) return;
+ api.getMyRsvps(token).then((r) => setRsvpIds(new Set(r.ids))).catch(() => {});
+ }, [token]);
+
+ const filtered = useMemo(() => {
+ if (mode === "all") return meetups;
+ return meetups.filter((m) => m.mode === mode);
+ }, [meetups, mode]);
+
+ const handleRsvp = async (meetup: Meetup) => {
+ if (rsvpIds.has(meetup.id)) {
+ toast(t.meetups.alreadyRsvp, "info");
+ return;
+ }
+ try {
+ const res = await api.rsvpMeetup(meetup.id, undefined, token || undefined);
+ const next = new Set(rsvpIds).add(meetup.id);
+ setRsvpIds(next);
+ toast(res.message || t.meetups.rsvpOk, "success");
+ } catch {
+ toast(t.meetups.rsvpFail, "error");
+ }
+ };
+
+ return (
+
+
+
+
+
+
{t.meetups.tag}
+
{t.meetups.title}
+
{t.meetups.subtitle}
+
+
+
+ {t.meetups.hostBtn}
+ {(["all", "online", "offline", "hybrid"] as const).map((m) => (
+
+ ))}
+
+
+
+ {filtered.map((m) => (
+
+
+
{m.emoji}
+
+
{m.title}
+
+ {MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time}
+
+
+
+ {m.description}
+ 📍 {m.venue}
+
+ {m.tags.map((tag) => (
+ {tag}
+ ))}
+
+
+ {m.destination_slug && (
+
+ {t.meetups.viewCity} →
+
+ )}
+
+ ))}
+
+
+ {filtered.length === 0 && (
+
{t.meetups.empty}
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/MeetupsHostClient.tsx b/frontend/src/components/MeetupsHostClient.tsx
new file mode 100644
index 0000000..4ee790e
--- /dev/null
+++ b/frontend/src/components/MeetupsHostClient.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { api } from "@/lib/api";
+import { useAuth } from "@/lib/auth";
+import { useToast } from "@/lib/toast";
+import { useI18n } from "@/lib/i18n";
+
+export default function MeetupsHostClient() {
+ const { token } = useAuth();
+ const router = useRouter();
+ const { toast } = useToast();
+ const { t } = useI18n();
+ const [form, setForm] = useState({
+ title: "",
+ city: "线上",
+ date: "",
+ time: "19:00",
+ venue: "",
+ description: "",
+ mode: "online" as "online" | "offline" | "hybrid",
+ access_level: "public",
+ max_attendees: 30,
+ emoji: "🎉",
+ });
+
+ const set = (k: string, v: string | number) => setForm((f) => ({ ...f, [k]: v }));
+
+ const submit = async () => {
+ if (!token) {
+ router.push("/login?next=/meetups/host");
+ return;
+ }
+ try {
+ const res = await api.createMeetup(token, form);
+ toast(t.meetups.hostOk, "success");
+ router.push("/meetups");
+ } catch {
+ toast(t.meetups.hostFail, "error");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/MemberProfileClient.tsx b/frontend/src/components/MemberProfileClient.tsx
new file mode 100644
index 0000000..daf6620
--- /dev/null
+++ b/frontend/src/components/MemberProfileClient.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import Link from "next/link";
+import { useI18n } from "@/lib/i18n";
+import type { MemberProfile } from "@/lib/types";
+
+export default function MemberProfileClient({ profile }: { profile: MemberProfile }) {
+ const { t } = useI18n();
+ return (
+
+
+
+
+
{profile.photo || "🧑💻"}
+
{profile.name}{profile.vip ? " ✨" : ""}
+
{profile.location}
+
{profile.bio}
+
+ {(profile.tags || []).map((tag) => {tag})}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx
index 5a1dad2..4d6e359 100644
--- a/frontend/src/components/MovePlanClient.tsx
+++ b/frontend/src/components/MovePlanClient.tsx
@@ -15,6 +15,7 @@ import {
} from "@/lib/planMeta";
import { PLAN_SYNC_EVENT, pushPlanNow } from "@/lib/planSync";
import { buildPlanIcs, downloadPlanIcs } from "@/lib/planIcs";
+import { useI18n } from "@/lib/i18n";
import type { Destination, TripItem, Visa } from "@/lib/types";
interface Props {
@@ -35,6 +36,7 @@ function scoreDest(d: Destination) {
export default function MovePlanClient({ destinations, visas = [] }: Props) {
const { toast } = useToast();
+ const { t } = useI18n();
const { token, user, planSyncStatus } = useAuth();
const [trip, setTrip] = useState([]);
const [meta, setMeta] = useState(() => ({
@@ -56,7 +58,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
if (Array.isArray(decoded) && decoded.length > 0) {
setTrip(decoded);
saveTrip(decoded);
- toast("已导入分享的行程");
+ toast(t.plan.importedShare);
window.history.replaceState({}, "", "/plan");
}
} catch { /* ignore */ }
@@ -65,7 +67,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
}
setMeta(loadPlanMeta());
setReady(true);
- }, [toast]);
+ }, [toast, t.plan.importedShare]);
useEffect(() => {
const refresh = () => {
@@ -160,7 +162,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
]);
setSelected("");
setMonths(1);
- toast(`${dest.emoji} ${dest.name} 已加入计划`);
+ toast(`${dest.emoji} ${dest.name} ${t.plan.addedToPlan}`);
};
const updateItem = (slug: string, patch: Partial) =>
@@ -187,7 +189,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
const encoded = btoa(JSON.stringify(trip));
const url = `${window.location.origin}/plan?trip=${encoded}`;
await navigator.clipboard.writeText(url);
- toast("分享链接已复制");
+ toast(t.plan.shareCopied);
};
const exportTrip = () => {
@@ -214,12 +216,12 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
a.download = `nomadro-plan-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
- toast("已下载 Markdown 计划");
+ toast(t.plan.mdDone);
};
const exportIcs = () => {
if (!meta.startMonth) {
- toast("请先设置预计出发月,再导出日历", "info");
+ toast(t.plan.needStartMonth, "info");
return;
}
const ics = buildPlanIcs({
@@ -237,11 +239,11 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
})),
});
if (!ics) {
- toast("无法生成日历,请检查行程", "error");
+ toast(t.plan.icsFail, "error");
return;
}
downloadPlanIcs(`nomadro-plan-${meta.startMonth}.ics`, ics);
- toast("已下载日历文件,可导入手机/Google Calendar");
+ toast(t.plan.icsDone);
};
const copyPlain = async () => {
@@ -249,14 +251,14 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
.map((t, i) => `${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 ¥${(t.cost * t.months).toLocaleString()}${t.range ? ` (${t.range})` : ""}`)
.join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()} · 就绪 ${readiness}%`;
await navigator.clipboard.writeText(text);
- toast("计划已复制");
+ toast(t.plan.copied);
};
if (!ready) {
return (
-
加载计划中…
+
{t.plan.loading}
);
@@ -266,27 +268,27 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
- 🗓️ MOVE PLAN
+ {t.plan.tag}
persistMeta({ ...meta, title: e.target.value || "我的旅居计划" })}
- aria-label="计划标题"
+ onChange={(e) => persistMeta({ ...meta, title: e.target.value || t.plan.defaultTitle })}
+ aria-label={t.plan.titleAria}
/>
- 把多城路线、预算、签证提醒和出发清单放在一页——真正能拿去执行的旅居计划。
+ {t.plan.subtitle}
{user ? (
<>
- {planSyncStatus === "syncing" && "同步中…"}
- {planSyncStatus === "synced" && "✓ 已登录 · 计划云端同步"}
- {planSyncStatus === "offline" && "离线 · 仅保存在本机"}
- {planSyncStatus === "idle" && "已登录"}
+ {planSyncStatus === "syncing" && t.plan.syncing}
+ {planSyncStatus === "synced" && t.plan.synced}
+ {planSyncStatus === "offline" && t.plan.offline}
+ {planSyncStatus === "idle" && t.plan.loggedIn}
>
) : (
- 登录后跨设备同步计划 →
+ {t.plan.loginSync}
)}