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

344 lines
14 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import { compareUrl } from "@/lib/compareScore";
import {
EMPTY_MATCHER_PREFS,
loadMatcherPrefs,
matcherPrefsComplete,
saveMatcherPrefs,
clearMatcherPrefs,
type MatcherPrefs,
type MatcherBudget,
type MatcherClimate,
type MatcherPriority,
type MatcherRegion,
} from "@/lib/matcherPrefs";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import type { Destination } from "@/lib/types";
interface Props {
destinations: Destination[];
open: boolean;
onClose: () => void;
}
const STEP_KEYS = ["budget", "climate", "priority", "region"] as const;
function parseNomads(n: string): number {
return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0;
}
function scoreDestination(
d: Destination,
prefs: Required<Pick<MatcherPrefs, "budget" | "climate" | "priority" | "region">>
): number {
let score = 0;
if (prefs.budget === "low") score += d.cost <= 5000 ? 35 : Math.max(0, 35 - (d.cost - 5000) / 200);
else if (prefs.budget === "medium") score += d.cost >= 4500 && d.cost <= 9500 ? 35 : 35 - Math.abs(d.cost - 7000) / 300;
else score += d.cost >= 9000 ? 35 : Math.max(0, 35 - (9000 - d.cost) / 200);
if (prefs.climate === "warm") score += d.temperature >= 25 ? 25 : Math.max(0, 25 - (25 - d.temperature) * 3);
else if (prefs.climate === "mild") score += d.temperature >= 18 && d.temperature <= 25 ? 25 : 25 - Math.abs(d.temperature - 21) * 2;
else score += d.temperature <= 18 ? 25 : Math.max(0, 25 - (d.temperature - 18) * 3);
if (prefs.priority === "cost") score += (12000 - d.cost) / 80;
else if (prefs.priority === "speed") score += d.speed / 4;
else if (prefs.priority === "community") score += parseNomads(d.nomads_count) / 500;
else score += d.rating * 3;
if (prefs.region === "any" || d.region === prefs.region) score += 20;
else score -= 10;
score += d.rating * 2;
return Math.round(score);
}
export default function DestinationMatcher({ destinations, open, onClose }: Props) {
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [step, setStep] = useState(0);
const [prefs, setPrefs] = useState<MatcherPrefs>(EMPTY_MATCHER_PREFS);
const [direction, setDirection] = useState<"forward" | "back">("forward");
const [hydrated, setHydrated] = useState(false);
const steps = useMemo(
() => [
{ title: t.matcher.budgetTitle, subtitle: t.matcher.budgetSub, key: "budget" as const },
{ title: t.matcher.climateTitle, subtitle: t.matcher.climateSub, key: "climate" as const },
{ title: t.matcher.priorityTitle, subtitle: t.matcher.prioritySub, key: "priority" as const },
{ title: t.matcher.regionTitle, subtitle: t.matcher.regionSub, key: "region" as const },
],
[t]
);
const options = useMemo(
() => ({
budget: [
{ value: "low" as MatcherBudget, emoji: "💰", label: t.matcher.budgetLow, desc: t.matcher.budgetLowDesc },
{ value: "medium" as MatcherBudget, emoji: "💳", label: t.matcher.budgetMed, desc: t.matcher.budgetMedDesc },
{ value: "high" as MatcherBudget, emoji: "💎", label: t.matcher.budgetHigh, desc: t.matcher.budgetHighDesc },
],
climate: [
{ value: "warm" as MatcherClimate, emoji: "☀️", label: t.matcher.climateWarm, desc: t.matcher.climateWarmDesc },
{ value: "mild" as MatcherClimate, emoji: "🌤️", label: t.matcher.climateMild, desc: t.matcher.climateMildDesc },
{ value: "cool" as MatcherClimate, emoji: "🍂", label: t.matcher.climateCool, desc: t.matcher.climateCoolDesc },
],
priority: [
{ value: "cost" as MatcherPriority, emoji: "💰", label: t.matcher.priCost, desc: t.matcher.priCostDesc },
{ value: "speed" as MatcherPriority, emoji: "📶", label: t.matcher.priSpeed, desc: t.matcher.priSpeedDesc },
{ value: "community" as MatcherPriority, emoji: "🤝", label: t.matcher.priCommunity, desc: t.matcher.priCommunityDesc },
{ value: "lifestyle" as MatcherPriority, emoji: "🎨", label: t.matcher.priLifestyle, desc: t.matcher.priLifestyleDesc },
],
region: [
{ value: "any" as MatcherRegion, emoji: "🌏", label: t.matcher.regionAny, desc: t.matcher.regionAnyDesc },
{ value: "sea" as MatcherRegion, emoji: "🌴", label: t.matcher.regionSea, desc: t.matcher.regionSeaDesc },
{ value: "europe" as MatcherRegion, emoji: "🏰", label: t.matcher.regionEurope, desc: t.matcher.regionEuropeDesc },
{ value: "latam" as MatcherRegion, emoji: "🌮", label: t.matcher.regionLatam, desc: t.matcher.regionLatamDesc },
{ value: "asia" as MatcherRegion, emoji: "🏯", label: t.matcher.regionAsia, desc: t.matcher.regionAsiaDesc },
],
}),
[t]
);
useEffect(() => {
if (!open) {
setHydrated(false);
return;
}
const saved = loadMatcherPrefs();
setPrefs(saved);
if (matcherPrefsComplete(saved)) setStep(STEP_KEYS.length);
else {
const filled = STEP_KEYS.findIndex((k) => !saved[k]);
setStep(filled === -1 ? 0 : filled);
}
setHydrated(true);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const results = useMemo(() => {
if (!matcherPrefsComplete(prefs)) return [];
return [...destinations]
.map((d) => ({ dest: d, score: scoreDestination(d, prefs) }))
.sort((a, b) => b.score - a.score);
}, [destinations, prefs]);
const top3 = results.slice(0, 3).map((r) => r.dest);
const currentKey = steps[step]?.key;
const isComplete = step >= STEP_KEYS.length;
const progress = isComplete ? 100 : ((step + 1) / STEP_KEYS.length) * 100;
const select = (value: string) => {
if (!currentKey) return;
setDirection("forward");
const next = { ...prefs, [currentKey]: value };
setPrefs(next);
saveMatcherPrefs(next);
setTimeout(() => setStep((s) => s + 1), 280);
};
const goBack = () => {
if (step === 0) {
onClose();
return;
}
setDirection("back");
setStep((s) => s - 1);
};
const reset = () => {
setStep(0);
setPrefs(EMPTY_MATCHER_PREFS);
clearMatcherPrefs();
setDirection("forward");
};
const buildPlanFromTop = () => {
if (top3.length === 0) return;
const { added, skipped } = mergeDestinationsIntoTrip(top3, 1);
onClose();
if (added === 0) {
toast(skipped ? t.matcher.alreadyAll : t.matcher.addFail, "info", {
href: "/plan",
label: t.strip.openPlan,
});
router.push("/plan");
return;
}
toast(
t.matcher.addedTop.replace("{n}", String(added)) +
(skipped ? t.matcher.skipped.replace("{n}", String(skipped)) : ""),
"success",
{ href: "/plan", label: t.strip.openPlan }
);
router.push("/plan");
};
const addOne = (dest: Destination, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
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 }
);
};
if (!open) return null;
return (
<div
className="modal-overlay open matcher-overlay"
role="dialog"
aria-modal="true"
aria-label={t.matcher.tag.replace("{n}", "1").replace("{total}", String(STEP_KEYS.length))}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
{!hydrated ? (
<div className="modal matcher-modal">
<button className="modal-close" onClick={onClose} aria-label={t.matcher.close}>
✕
</button>
<p className="dest-empty">{t.common.loading}</p>
</div>
) : (
<div className="modal matcher-modal">
<button className="modal-close" onClick={onClose} aria-label={t.matcher.close}>✕</button>
<div className="matcher-progress">
<div className="matcher-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="matcher-body">
{!isComplete && currentKey ? (
<div className={`matcher-step matcher-${direction}`} key={step}>
<div className="matcher-step-meta">
<span className="section-tag">
{t.matcher.tag
.replace("{n}", String(step + 1))
.replace("{total}", String(STEP_KEYS.length))}
</span>
<h2>{steps[step].title}</h2>
<p>{steps[step].subtitle}</p>
</div>
<div className="matcher-options">
{options[currentKey].map((opt) => (
<button
key={opt.value}
className={`matcher-option${prefs[currentKey] === opt.value ? " selected" : ""}`}
onClick={() => select(opt.value)}
>
<span className="matcher-option-emoji">{opt.emoji}</span>
<div>
<strong>{opt.label}</strong>
<span>{opt.desc}</span>
</div>
<svg className="matcher-option-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
))}
</div>
<button className="matcher-back" onClick={goBack}>
{step === 0 ? t.matcher.cancel : t.matcher.back}
</button>
</div>
) : (
<div className="matcher-results matcher-forward">
<div className="matcher-step-meta">
<span className="section-tag">{t.matcher.resultsTag}</span>
<h2>{t.matcher.resultsTitle}</h2>
<p>{t.matcher.resultsSub}</p>
</div>
{results.length === 0 ? (
<div className="notif-empty">
<p className="dest-empty">{t.matcher.empty}</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-ghost" onClick={reset}>
{t.matcher.retry}
</button>
<Link href="/next-stop" className="btn btn-primary" onClick={onClose}>
{t.matcher.goNextStop}
</Link>
</div>
</div>
) : (
<>
<div className="matcher-result-list">
{results.slice(0, 8).map(({ dest, score }, i) => (
<div key={dest.slug} className={`matcher-result-card${i === 0 ? " top" : ""}`}>
<Link href={`/destinations/${dest.slug}`} className="matcher-result-main" onClick={onClose}>
<div className="matcher-result-rank">
{i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`}
</div>
<span className="matcher-result-emoji">{dest.emoji}</span>
<div className="matcher-result-info">
<strong>{dest.name}</strong>
<span>{dest.country} · {dest.tag}</span>
</div>
<div className="matcher-result-score">
<div className="matcher-score-bar">
<div className="matcher-score-fill" style={{ width: `${Math.min((score / (results[0]?.score || 1)) * 100, 100)}%` }} />
</div>
<span>
{t.matcher.matchPct.replace(
"{n}",
String(Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100))
)}
</span>
</div>
<div className="matcher-result-tags">
<span>💰 ¥{dest.cost.toLocaleString()}</span>
<span>📶 {dest.speed}Mbps</span>
<span>⭐ {dest.rating}</span>
</div>
</Link>
<button type="button" className="matcher-add-btn" onClick={(e) => addOne(dest, e)}>
{t.matcher.addPlan}
</button>
</div>
))}
</div>
<div className="matcher-actions">
<button type="button" className="btn btn-ghost" onClick={reset}>{t.matcher.retryBtn}</button>
{top3.length >= 2 && (
<Link
href={compareUrl(top3.map((d) => d.slug))}
className="btn btn-ghost"
onClick={onClose}
>
{t.matcher.compareTop.replace("{n}", String(top3.length))}
</Link>
)}
<button type="button" className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
{t.matcher.writeTop.replace("{n}", String(Math.min(3, top3.length)))}
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
)}
</div>
);
}