nomadweb/frontend/src/components/RunwayCalculator.tsx
eric bdf0516197 Fix dating match gate and expand seed data for usable demos.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 06:18:25 -05:00

218 lines
7.1 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.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}