Files
2026-06-06 04:29:46 -05:00

218 lines
9.5 KiB
TypeScript
Raw Permalink 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 { 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>
);
}