nomadweb/frontend/src/components/CompareClient.tsx
eric 969a0b037a Continue progressive rings: search, onboard, plan hubs.
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>
2026-08-30 19:06:32 -05:00

236 lines
9.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 { 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>
);
}