218 lines
7.1 KiB
TypeScript
218 lines
7.1 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import type { Destination } from "@/lib/types";
|
||
import { useI18n } from "@/lib/i18n";
|
||
import { useToast } from "@/lib/toast";
|
||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||
import { meetupCityHref } from "@/lib/meetupLinks";
|
||
|
||
const STORAGE_KEY = "nomadro-runway";
|
||
|
||
interface Props {
|
||
destinations: Destination[];
|
||
}
|
||
|
||
interface Saved {
|
||
savings: number;
|
||
monthly: number;
|
||
income: number;
|
||
slug: string;
|
||
}
|
||
|
||
export default function RunwayCalculator({ destinations }: Props) {
|
||
const { t } = useI18n();
|
||
const { toast } = useToast();
|
||
const [savings, setSavings] = useState(80000);
|
||
const [monthly, setMonthly] = useState(4500);
|
||
const [income, setIncome] = useState(0);
|
||
const [slug, setSlug] = useState("");
|
||
const [ready, setReady] = useState(false);
|
||
const [pulse, setPulse] = useState(false);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) {
|
||
const s = JSON.parse(raw) as Saved;
|
||
setSavings(s.savings ?? 80000);
|
||
setMonthly(s.monthly ?? 4500);
|
||
setIncome(s.income ?? 0);
|
||
setSlug(s.slug ?? "");
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setReady(true);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!ready) return;
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ savings, monthly, income, slug }));
|
||
}, [savings, monthly, income, slug, ready]);
|
||
|
||
const selected = destinations.find((d) => d.slug === slug);
|
||
|
||
const netBurn = Math.max(monthly - income, 0);
|
||
const months = useMemo(() => {
|
||
if (netBurn <= 0) return income > 0 ? 999 : 0;
|
||
return Math.floor((savings / netBurn) * 10) / 10;
|
||
}, [savings, netBurn, income]);
|
||
|
||
const pct = Math.min((months / 18) * 100, 100);
|
||
const level =
|
||
months >= 12
|
||
? { label: "从容远航", color: "#4ECDC4", tip: "可以大胆规划长线旅居" }
|
||
: months >= 6
|
||
? { label: "稳健起步", color: "#FFE66D", tip: "适合 1–2 站深度停留" }
|
||
: months >= 3
|
||
? { label: "短途试水", color: "#F472B6", tip: "建议选低成本城市先验证" }
|
||
: { label: "需要蓄力", color: "#FF6B6B", tip: "先攒启动金或提高远程收入" };
|
||
|
||
const applyDest = (s: string) => {
|
||
const d = destinations.find((x) => x.slug === s);
|
||
if (!d) return;
|
||
setSlug(s);
|
||
setMonthly(d.cost);
|
||
setPulse(true);
|
||
setTimeout(() => setPulse(false), 500);
|
||
};
|
||
|
||
const addToPlan = () => {
|
||
if (!selected) return;
|
||
const stay = months >= 999 ? 6 : Math.max(1, Math.min(24, Math.round(months) || 1));
|
||
const { added } = mergeDestinationsIntoTrip([selected], stay);
|
||
toast(
|
||
added ? `${selected.emoji} ${selected.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
|
||
added ? "success" : "info",
|
||
{ href: "/plan", label: t.strip.openPlan }
|
||
);
|
||
};
|
||
|
||
const milestones = [
|
||
{ m: 3, label: "试水期" },
|
||
{ m: 6, label: "半程" },
|
||
{ m: 12, label: "一年" },
|
||
{ m: 18, label: "长线" },
|
||
];
|
||
|
||
return (
|
||
<section className="section runway-section" id="runway">
|
||
<div className="container">
|
||
<div className="section-header reveal">
|
||
<span className="section-tag">🚀 RUNWAY</span>
|
||
<h2>旅居跑道计算器</h2>
|
||
<p>用存款与月开销,算出你还能自由飞多久</p>
|
||
</div>
|
||
|
||
<div className={`runway-card reveal${pulse ? " runway-pulse" : ""}`}>
|
||
<div className="runway-meter">
|
||
<div
|
||
className="runway-ring"
|
||
style={{ background: `conic-gradient(${level.color} ${pct}%, rgba(255,255,255,0.08) 0)` }}
|
||
>
|
||
<div className="runway-ring-inner">
|
||
<strong>{months >= 999 ? "∞" : months}</strong>
|
||
<small>{months >= 999 ? "可持续" : "个月"}</small>
|
||
</div>
|
||
</div>
|
||
<div className="runway-level" style={{ color: level.color }}>
|
||
<span>{level.label}</span>
|
||
<p>{level.tip}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="runway-form">
|
||
<label>
|
||
<span>启动金 / 存款(¥)</span>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={1000}
|
||
value={savings}
|
||
onChange={(e) => setSavings(Math.max(0, +e.target.value || 0))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>月开销(¥)</span>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={100}
|
||
value={monthly}
|
||
onChange={(e) => {
|
||
setMonthly(Math.max(0, +e.target.value || 0));
|
||
setSlug("");
|
||
}}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>月远程收入(¥,可选)</span>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={100}
|
||
value={income}
|
||
onChange={(e) => setIncome(Math.max(0, +e.target.value || 0))}
|
||
/>
|
||
</label>
|
||
|
||
<div className="runway-dests">
|
||
<small>一键套用目的地月均成本</small>
|
||
<div className="runway-dest-list">
|
||
{destinations.slice(0, 6).map((d) => (
|
||
<button
|
||
key={d.slug}
|
||
type="button"
|
||
className={`runway-dest${slug === d.slug ? " active" : ""}`}
|
||
onClick={() => applyDest(d.slug)}
|
||
>
|
||
<span>{d.emoji}</span>
|
||
<em>{d.name}</em>
|
||
<small>¥{d.cost.toLocaleString()}</small>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="runway-summary">
|
||
<div>
|
||
<small>净月消耗</small>
|
||
<strong>¥{netBurn.toLocaleString()}</strong>
|
||
</div>
|
||
<div>
|
||
<small>预计支撑</small>
|
||
<strong>{months >= 999 ? "收入覆盖开销" : `${months} 个月`}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="runway-milestones" aria-hidden>
|
||
{milestones.map((m) => (
|
||
<div key={m.m} className={`runway-ms${months >= m.m ? " on" : ""}`}>
|
||
<i />
|
||
<span>{m.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{selected && (
|
||
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
|
||
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
|
||
🗓️ {t.nav.plan}
|
||
</button>
|
||
<Link href={`/destinations/${selected.slug}`} className="btn btn-sm">
|
||
{t.common.detail}
|
||
</Link>
|
||
<Link href={meetupCityHref(selected.name)} className="btn btn-sm btn-ghost">
|
||
{t.common.cityMeetups}
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|