344 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|