nomadweb/frontend/src/components/VisaWizard.tsx
eric bf654f76e6 Add visa wizard, coworking, packing, season guide, and UX polish
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:05:48 -05:00

180 lines
6.2 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 type { Visa } from "@/lib/types";
interface Props {
visas: Visa[];
}
const INCOME_OPTIONS = [
{ key: "low", label: "💰 低于 ¥8,000/月", max: 8000 },
{ key: "mid", label: "💼 ¥8,000 – ¥20,000/月", max: 20000 },
{ key: "high", label: "🚀 ¥20,000 – ¥50,000/月", max: 50000 },
{ key: "premium", label: "👑 高于 ¥50,000/月", max: Infinity },
];
const DURATION_OPTIONS = [
{ key: "short", label: "⏱️ 1-3 个月", months: 3 },
{ key: "medium", label: "📅 3-12 个月", months: 12 },
{ key: "long", label: "🏠 1 年以上", months: 24 },
];
const REGION_OPTIONS = [
{ key: "any", label: "🌏 不限" },
{ key: "europe", label: "🏰 欧洲" },
{ key: "sea", label: "🌴 东南亚" },
{ key: "latam", label: "🌮 拉美" },
];
const VISA_INCOME: Record<string, number> = {
"1": 6000, "2": 17000, "3": 2000, "4": 47000, "5": 18000, "6": 28000,
};
const VISA_REGION: Record<string, string> = {
"1": "europe", "2": "europe", "3": "sea", "4": "sea", "5": "latam", "6": "europe",
};
export default function VisaWizard({ visas }: Props) {
const [step, setStep] = useState(0);
const [income, setIncome] = useState("");
const [duration, setDuration] = useState("");
const [region, setRegion] = useState("any");
const [showResults, setShowResults] = useState(false);
const results = useMemo(() => {
if (!income || !duration) return [];
const incomeOpt = INCOME_OPTIONS.find((o) => o.key === income)!;
const durOpt = DURATION_OPTIONS.find((o) => o.key === duration)!;
return visas
.map((v) => {
let score = 0;
const reqIncome = VISA_INCOME[v.id] ?? 15000;
const vRegion = VISA_REGION[v.id] ?? "any";
if (reqIncome <= incomeOpt.max) score += 40;
else if (reqIncome <= incomeOpt.max * 1.5) score += 20;
if (v.difficulty <= 35) score += 25;
else if (v.difficulty <= 55) score += 15;
else score += 5;
if (durOpt.months >= 12 && v.duration.includes("年")) score += 20;
else if (durOpt.months <= 3) score += v.duration.includes("天") ? 20 : 10;
else score += 15;
if (region === "any" || vRegion === region) score += 15;
return { visa: v, score: Math.min(score, 100) };
})
.filter((r) => r.score >= 30)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}, [visas, income, duration, region]);
const canNext = step === 0 ? !!income : step === 1 ? !!duration : true;
const handleFinish = () => {
setShowResults(true);
setStep(3);
};
const reset = () => {
setStep(0);
setIncome("");
setDuration("");
setRegion("any");
setShowResults(false);
};
return (
<div className="visa-wizard reveal">
<div className="visa-wizard-header">
<span className="section-tag">🧙 VISA WIZARD</span>
<h3>签证智能匹配</h3>
<p>回答 3 个问题,找到最适合你的远程工作签证</p>
</div>
{!showResults ? (
<div className="visa-wizard-body">
<div className="visa-wizard-steps">
{["月收入", "停留时长", "目的地"].map((label, i) => (
<div key={label} className={`wizard-step-dot${i <= step ? " active" : ""}${i < step ? " done" : ""}`}>
<span>{i < step ? "✓" : i + 1}</span>
<small>{label}</small>
</div>
))}
</div>
{step === 0 && (
<div className="wizard-options">
{INCOME_OPTIONS.map((o) => (
<button key={o.key} className={`wizard-option${income === o.key ? " selected" : ""}`} onClick={() => setIncome(o.key)}>
{o.label}
</button>
))}
</div>
)}
{step === 1 && (
<div className="wizard-options">
{DURATION_OPTIONS.map((o) => (
<button key={o.key} className={`wizard-option${duration === o.key ? " selected" : ""}`} onClick={() => setDuration(o.key)}>
{o.label}
</button>
))}
</div>
)}
{step === 2 && (
<div className="wizard-options">
{REGION_OPTIONS.map((o) => (
<button key={o.key} className={`wizard-option${region === o.key ? " selected" : ""}`} onClick={() => setRegion(o.key)}>
{o.label}
</button>
))}
</div>
)}
<div className="wizard-nav">
{step > 0 && <button className="btn btn-ghost" onClick={() => setStep(step - 1)}>← 上一步</button>}
{step < 2 ? (
<button className="btn btn-primary" disabled={!canNext} onClick={() => setStep(step + 1)}>下一步 →</button>
) : (
<button className="btn btn-primary" onClick={handleFinish}>查看推荐 ✨</button>
)}
</div>
</div>
) : (
<div className="visa-wizard-results">
{results.length === 0 ? (
<p className="wizard-empty">暂无完全匹配的签证,建议咨询专业移民顾问或放宽条件重试</p>
) : (
<div className="wizard-result-cards">
{results.map(({ visa, score }, i) => (
<div key={visa.id} className={`wizard-result-card${i === 0 ? " winner" : ""}`}>
{i === 0 && <span className="wizard-winner-badge">🏆 最佳匹配</span>}
<div className="wizard-result-top">
<span className="wizard-result-flag">{visa.flag}</span>
<div>
<h4>{visa.name}</h4>
<span className="wizard-match-score">{score}% 匹配</span>
</div>
</div>
<ul>
<li>💰 {visa.income_req}</li>
<li>📅 {visa.duration}</li>
<li>⏱️ {visa.approval_time}</li>
</ul>
</div>
))}
</div>
)}
<button className="btn btn-ghost" onClick={reset}>🔄 重新匹配</button>
</div>
)}
</div>
);
}