Empty search shows three-ring shortcuts, onboarding follows discover-belong-grow, and plan/compare/next-stop surface only the next linked steps. Co-authored-by: Cursor <cursoragent@cursor.com>
236 lines
9.0 KiB
TypeScript
236 lines
9.0 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import { useToast } from "@/lib/toast";
|
||
import {
|
||
COMPARE_METRICS,
|
||
compareUrl,
|
||
getWinnerIdx,
|
||
overallScore,
|
||
parseCompareSlugs,
|
||
} from "@/lib/compareScore";
|
||
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
|
||
import { useI18n } from "@/lib/i18n";
|
||
import type { Destination } from "@/lib/types";
|
||
import RingNext from "@/components/RingNext";
|
||
import { useRingSteps } from "@/lib/rings";
|
||
|
||
interface Props {
|
||
destinations: Destination[];
|
||
}
|
||
|
||
export default function CompareClient({ destinations }: Props) {
|
||
const searchParams = useSearchParams();
|
||
const router = useRouter();
|
||
const { toast } = useToast();
|
||
const { t } = useI18n();
|
||
const rings = useRingSteps();
|
||
const [picked, setPicked] = useState<string[]>([]);
|
||
|
||
useEffect(() => {
|
||
const fromUrl = parseCompareSlugs(searchParams.get("cities"));
|
||
if (fromUrl.length) {
|
||
setPicked(fromUrl.filter((s) => destinations.some((d) => d.slug === s)));
|
||
return;
|
||
}
|
||
setPicked([]);
|
||
}, [searchParams, destinations]);
|
||
|
||
const selected = useMemo(
|
||
() => picked.map((s) => destinations.find((d) => d.slug === s)).filter((d): d is Destination => !!d),
|
||
[picked, destinations]
|
||
);
|
||
|
||
const scores = selected.map(overallScore);
|
||
const winnerIdx = scores.length ? scores.indexOf(Math.max(...scores)) : -1;
|
||
|
||
const syncUrl = (slugs: string[]) => {
|
||
const href = compareUrl(slugs);
|
||
router.replace(href, { scroll: false });
|
||
};
|
||
|
||
const toggle = (slug: string) => {
|
||
setPicked((prev) => {
|
||
let next: string[];
|
||
if (prev.includes(slug)) next = prev.filter((s) => s !== slug);
|
||
else if (prev.length >= 4) {
|
||
toast(t.compare.maxCities, "info");
|
||
return prev;
|
||
} else next = [...prev, slug];
|
||
syncUrl(next);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const addAll = () => {
|
||
if (selected.length < 1) return;
|
||
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
|
||
if (added === 0) {
|
||
toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info");
|
||
router.push("/plan");
|
||
return;
|
||
}
|
||
toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`);
|
||
router.push("/plan");
|
||
};
|
||
|
||
const addWinner = () => {
|
||
if (winnerIdx < 0) return;
|
||
const d = selected[winnerIdx];
|
||
const { added } = mergeDestinationsIntoTrip([d], 1);
|
||
toast(added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne, added ? "success" : "info");
|
||
if (added) router.push("/plan");
|
||
};
|
||
|
||
const share = async () => {
|
||
if (selected.length < 2) {
|
||
toast(t.compare.needShare, "info");
|
||
return;
|
||
}
|
||
const url = `${window.location.origin}${compareUrl(selected.map((d) => d.slug))}`;
|
||
await navigator.clipboard.writeText(url);
|
||
toast(t.compare.linkCopied);
|
||
};
|
||
|
||
return (
|
||
<div className="compare-page">
|
||
<div className="container">
|
||
<nav className="detail-nav">
|
||
<Link href="/">{t.common.backHome}</Link>
|
||
<Link href="/#destinations">{t.compare.dest}</Link>
|
||
<Link href="/plan">{t.compare.plan}</Link>
|
||
</nav>
|
||
|
||
<header className="compare-page-hero">
|
||
<span className="section-tag">{t.compare.tag}</span>
|
||
<h1>{t.compare.title}</h1>
|
||
<p>{t.compare.subtitle}</p>
|
||
</header>
|
||
|
||
<section className="compare-pick plan-panel">
|
||
<div className="plan-panel-head">
|
||
<h2>{t.compare.pick}({picked.length}/4)</h2>
|
||
<div className="plan-actions">
|
||
<button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>{t.compare.clear}</button>
|
||
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>{t.compare.copyLink}</button>
|
||
</div>
|
||
</div>
|
||
<div className="compare-pick-grid">
|
||
{destinations.map((d) => {
|
||
const on = picked.includes(d.slug);
|
||
return (
|
||
<button
|
||
key={d.slug}
|
||
type="button"
|
||
className={`compare-pick-chip${on ? " on" : ""}`}
|
||
onClick={() => toggle(d.slug)}
|
||
aria-pressed={on}
|
||
>
|
||
<span>{d.emoji}</span>
|
||
<strong>{d.name}</strong>
|
||
<small>¥{d.cost.toLocaleString()}</small>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
{selected.length < 2 ? (
|
||
<div className="plan-empty compare-page-empty">
|
||
<span className="plan-empty-icon">⚖️</span>
|
||
<p>{t.compare.needMore} {Math.max(0, 2 - selected.length)} {t.compare.needMoreSuffix}</p>
|
||
<Link href="/#destinations" className="btn btn-ghost">{t.compare.backDest}</Link>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<section className="plan-panel">
|
||
<div className="compare-cards">
|
||
{selected.map((d, i) => (
|
||
<div key={d.slug} className={`compare-city-card${i === winnerIdx ? " winner" : ""}`}>
|
||
{i === winnerIdx && <span className="compare-winner-badge">{t.compare.winner}</span>}
|
||
<span className="compare-city-emoji">{d.emoji}</span>
|
||
<h3>{d.name}</h3>
|
||
<span className="compare-city-country">{d.country}</span>
|
||
<div className="compare-score-ring">
|
||
<svg viewBox="0 0 80 80">
|
||
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="6" />
|
||
<circle
|
||
cx="40" cy="40" r="34" fill="none"
|
||
stroke="url(#comparePageGrad)" strokeWidth="6"
|
||
strokeLinecap="round"
|
||
strokeDasharray={`${scores[i] * 2.14} 214`}
|
||
transform="rotate(-90 40 40)"
|
||
/>
|
||
</svg>
|
||
<span className="compare-score-num">{scores[i]}</span>
|
||
</div>
|
||
<Link href={`/destinations/${d.slug}`} className="compare-city-link">{t.common.detail}</Link>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="compare-metrics">
|
||
{COMPARE_METRICS.map((m) => {
|
||
const values = selected.map(m.get);
|
||
const winIdx = getWinnerIdx(values, m.lowerBetter);
|
||
const maxVal = Math.max(...values);
|
||
return (
|
||
<div key={m.label} className="compare-metric-row">
|
||
<div className="compare-metric-label">{m.emoji} {m.label}</div>
|
||
<div className="compare-metric-bars">
|
||
{selected.map((d, i) => {
|
||
const val = m.get(d);
|
||
const pct = maxVal > 0 ? (val / maxVal) * 100 : 0;
|
||
return (
|
||
<div key={d.slug} className="compare-bar-item">
|
||
<div className="compare-bar-header">
|
||
<span>{d.emoji} {d.name}</span>
|
||
<span className={i === winIdx ? "compare-best" : ""}>
|
||
{i === winIdx && "🏆 "}{m.format(val)}
|
||
</span>
|
||
</div>
|
||
<div className="compare-bar-track">
|
||
<div
|
||
className={`compare-bar-fill${i === winIdx ? " best" : ""}`}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="compare-page-actions">
|
||
<button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}>
|
||
{t.compare.addWinner}
|
||
</button>
|
||
<button type="button" className="btn btn-primary" onClick={addAll}>
|
||
{t.compare.addAll}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<svg width="0" height="0">
|
||
<defs>
|
||
<linearGradient id="comparePageGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||
<stop offset="0%" stopColor="#FF6B6B" />
|
||
<stop offset="50%" stopColor="#FFE66D" />
|
||
<stop offset="100%" stopColor="#4ECDC4" />
|
||
</linearGradient>
|
||
</defs>
|
||
</svg>
|
||
</>
|
||
)}
|
||
|
||
<RingNext steps={rings.afterCity} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|