nomadweb/frontend/src/components/FirstMonthCost.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

147 lines
6.0 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 { 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";
/** First-month one-time + rent estimates in CNY. */
const LANDING: Record<string, { rent: number; depositMult: number; sim: number; transfer: number; misc: number }> = {
chiangmai: { rent: 2800, depositMult: 1, sim: 80, transfer: 120, misc: 400 },
bali: { rent: 3500, depositMult: 1, sim: 100, transfer: 200, misc: 500 },
lisbon: { rent: 6500, depositMult: 2, sim: 150, transfer: 180, misc: 800 },
barcelona: { rent: 7000, depositMult: 2, sim: 150, transfer: 200, misc: 900 },
mexico: { rent: 4500, depositMult: 1, sim: 120, transfer: 250, misc: 600 },
tokyo: { rent: 9000, depositMult: 2, sim: 200, transfer: 300, misc: 1200 },
};
interface Props {
destinations: Destination[];
}
export default function FirstMonthCost({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
const [housing, setHousing] = useState<"budget" | "mid" | "nice">("mid");
const [includeDeposit, setIncludeDeposit] = useState(true);
const base = LANDING[slug] || { rent: 4000, depositMult: 1, sim: 100, transfer: 150, misc: 500 };
const dest = destinations.find((d) => d.slug === slug);
const rentFactor = housing === "budget" ? 0.75 : housing === "nice" ? 1.35 : 1;
const breakdown = useMemo(() => {
const rent = Math.round(base.rent * rentFactor);
const deposit = includeDeposit ? Math.round(rent * base.depositMult) : 0;
const sim = base.sim;
const transfer = base.transfer;
const misc = Math.round(base.misc * rentFactor);
const total = rent + deposit + sim + transfer + misc;
return [
{ key: "rent", emoji: "🏡", label: "首月房租", amount: rent },
{ key: "deposit", emoji: "🔐", label: `押金 ×${base.depositMult}`, amount: deposit },
{ key: "sim", emoji: "📱", label: "上网卡", amount: sim },
{ key: "transfer", emoji: "🚕", label: "机场接驳", amount: transfer },
{ key: "misc", emoji: "🛒", label: "日用启动", amount: misc },
{ key: "total", emoji: "💳", label: "合计启动金", amount: total },
];
}, [base, rentFactor, includeDeposit]);
const total = breakdown.find((b) => b.key === "total")!.amount;
const maxBar = Math.max(...breakdown.filter((b) => b.key !== "total").map((b) => b.amount), 1);
const addToPlan = () => {
if (!dest) return;
const { added } = mergeDestinationsIntoTrip([dest], 1);
toast(
added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
added ? "success" : "info",
{ href: "/plan", label: t.strip.openPlan }
);
};
return (
<section className="section firstmonth-section" id="first-month">
<div className="container">
<div className="section-header reveal">
<span className="section-tag">🛬 FIRST MONTH</span>
<h2>落地首月成本</h2>
<p>房租、押金、SIM、接机一次算清,避免到了才慌</p>
</div>
<div className="firstmonth-card reveal">
<div className="firstmonth-controls">
<label>
<span>城市</span>
<select value={slug} onChange={(e) => setSlug(e.target.value)}>
{destinations.map((d) => (
<option key={d.slug} value={d.slug}>{d.emoji} {d.name}</option>
))}
</select>
</label>
<div className="firstmonth-housing">
{([
["budget", "💰 经济"],
["mid", "🏠 舒适"],
["nice", "✨ 品质"],
] as const).map(([k, label]) => (
<button
key={k}
type="button"
className={`firstmonth-chip${housing === k ? " active" : ""}`}
onClick={() => setHousing(k)}
>
{label}
</button>
))}
</div>
<label className="firstmonth-check">
<input
type="checkbox"
checked={includeDeposit}
onChange={(e) => setIncludeDeposit(e.target.checked)}
/>
<span>计入押金(当地常见 {base.depositMult} 个月)</span>
</label>
</div>
<div className="firstmonth-result">
<div className="firstmonth-total">
<small>{dest?.emoji} 建议准备</small>
<strong>¥{total.toLocaleString()}</strong>
<span>含首月硬成本,不含机票与签证费</span>
</div>
<div className="firstmonth-bars">
{breakdown.filter((b) => b.key !== "total" && b.amount > 0).map((b) => (
<div key={b.key} className="firstmonth-bar-row">
<span>{b.emoji} {b.label}</span>
<div className="firstmonth-bar-track">
<i style={{ width: `${(b.amount / maxBar) * 100}%` }} />
</div>
<em>¥{b.amount.toLocaleString()}</em>
</div>
))}
</div>
{dest && (
<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/${dest.slug}`} className="btn btn-sm">
{t.common.detail}
</Link>
<Link href={meetupCityHref(dest.name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
</div>
)}
</div>
</div>
</div>
</section>
);
}