218 lines
9.5 KiB
TypeScript
218 lines
9.5 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useTranslation } from "@/i18n/navigation";
|
||
import Footer from "@/app/components/Footer";
|
||
import { apiFetch } from "@/app/lib/api-client";
|
||
|
||
interface ToolCity {
|
||
id: string;
|
||
name: string;
|
||
temperature: number;
|
||
humidity: number;
|
||
costPerMonth: number;
|
||
internetSpeed: number;
|
||
}
|
||
|
||
const RATES = [
|
||
{ from: "CNY", to: "USD", rate: 0.14 },
|
||
{ from: "CNY", to: "EUR", rate: 0.13 },
|
||
{ from: "CNY", to: "THB", rate: 5.2 },
|
||
{ from: "USD", to: "CNY", rate: 7.2 },
|
||
];
|
||
|
||
export default function ToolsPage() {
|
||
const { t } = useTranslation("tools");
|
||
const [income, setIncome] = useState(100000);
|
||
const [cnyAmount, setCnyAmount] = useState(10000);
|
||
const [visaDate, setVisaDate] = useState("2025-06-15");
|
||
const [monthlySpend, setMonthlySpend] = useState(6000);
|
||
const [targetTemp, setTargetTemp] = useState(20);
|
||
const [cities, setCities] = useState<ToolCity[]>([]);
|
||
const [today] = useState(() => Date.now());
|
||
|
||
useEffect(() => {
|
||
apiFetch<{ items: ToolCity[] }>("/api/cities")
|
||
.then((data) => setCities(data.items || []))
|
||
.catch(() => setCities([]));
|
||
}, []);
|
||
|
||
const taxEstimate = Math.round(income * 0.15);
|
||
const cnyToUsd = (v: number) => (v * 0.14).toFixed(2);
|
||
const daysLeft = Math.ceil(
|
||
(new Date(visaDate).getTime() - today) / (1000 * 60 * 60 * 24)
|
||
);
|
||
const fireNumber = monthlySpend * 12 * 25;
|
||
const yearsToFire = Math.max(1, Math.ceil(fireNumber / Math.max(1, income - monthlySpend * 12)));
|
||
const climateMatches = useMemo(
|
||
() =>
|
||
[...cities]
|
||
.sort((a, b) => Math.abs(a.temperature - targetTemp) - Math.abs(b.temperature - targetTemp))
|
||
.slice(0, 5),
|
||
[cities, targetTemp]
|
||
);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-12">
|
||
<div className="mb-10">
|
||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||
{t("title")}
|
||
</h1>
|
||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||
{t("subtitle")}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-6">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||
💱 {t("exchange")}
|
||
</h2>
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||
{RATES.map((r) => (
|
||
<div
|
||
key={`${r.from}-${r.to}`}
|
||
className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800"
|
||
>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{r.from} → {r.to}
|
||
</p>
|
||
<p className="mt-1 text-lg font-bold text-gray-900 dark:text-gray-100">
|
||
{r.rate}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-4 p-4 rounded-xl bg-gray-50 dark:bg-gray-800">
|
||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-2">CNY → USD</label>
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
type="number"
|
||
value={cnyAmount}
|
||
onChange={(e) => setCnyAmount(Number(e.target.value) || 0)}
|
||
className="flex-1 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||
/>
|
||
<span className="text-gray-400">≈</span>
|
||
<span className="font-semibold text-gray-900 dark:text-gray-100">${cnyToUsd(cnyAmount)}</span>
|
||
</div>
|
||
</div>
|
||
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">
|
||
{t("disclaimer")}
|
||
</p>
|
||
</section>
|
||
|
||
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-6">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||
📊 {t("tax")}
|
||
</h2>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("incomeCountry")} (年收入 ¥)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={income}
|
||
onChange={(e) => setIncome(Number(e.target.value))}
|
||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||
/>
|
||
</div>
|
||
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||
{t("estimate")}: 约 ¥{taxEstimate.toLocaleString()}/年
|
||
</p>
|
||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-300">
|
||
{t("taxDisclaimer")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-6">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||
🛂 {t("visa")}
|
||
</h2>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("visaExpiry")}
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={visaDate}
|
||
onChange={(e) => setVisaDate(e.target.value)}
|
||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||
/>
|
||
</div>
|
||
<div className="p-4 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800/50">
|
||
<p className="text-lg font-semibold text-emerald-800 dark:text-emerald-200">
|
||
{daysLeft > 0 ? `${daysLeft} ${t("daysLeft")}` : t("expired")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-6">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||
🌱 {t("fireCalculator")}
|
||
</h2>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("fireDesc")}</p>
|
||
<div className="grid gap-4 sm:grid-cols-[1fr_220px]">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
月支出预算 (¥)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={monthlySpend}
|
||
onChange={(e) => setMonthlySpend(Number(e.target.value) || 0)}
|
||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||
/>
|
||
</div>
|
||
<div className="rounded-xl bg-gray-50 p-4 text-sm dark:bg-gray-800">
|
||
<p className="text-gray-500 dark:text-gray-400">FIRE 目标资产</p>
|
||
<p className="mt-1 text-xl font-bold text-gray-900 dark:text-gray-100">¥{fireNumber.toLocaleString()}</p>
|
||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">按 4% 规则估算,当前收入约 {yearsToFire} 年达到。</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-6">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||
🌤️ {t("climateFinder")}
|
||
</h2>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("climateDesc")}</p>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
目标体感温度:{targetTemp}°C
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min={10}
|
||
max={30}
|
||
value={targetTemp}
|
||
onChange={(e) => setTargetTemp(Number(e.target.value))}
|
||
className="w-full accent-[#ff4d4f]"
|
||
/>
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{climateMatches.map((city) => (
|
||
<div key={city.id} className="rounded-xl bg-gray-50 p-4 text-sm dark:bg-gray-800">
|
||
<p className="font-semibold text-gray-900 dark:text-gray-100">{city.name}</p>
|
||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||
{city.temperature}°C · 湿度 {city.humidity}% · ¥{city.costPerMonth.toLocaleString()}/月 · {city.internetSpeed}Mbps
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|