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

386 lines
14 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.

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useToast } from "@/lib/toast";
import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, stayHint } from "@/lib/planMeta";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { trackRecentDestination } from "@/lib/recentDestinations";
import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api";
import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import { useI18n } from "@/lib/i18n";
import { meetupCityHref, meetupEventHref } from "@/lib/meetupLinks";
const TZ_LABELS: Record<string, string> = {
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
mexico: "UTC-6", barcelona: "UTC+1", tokyo: "UTC+9",
};
interface Props {
dest: Destination;
allDestinations: Destination[];
}
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const [added, setAdded] = useState(false);
const [months, setMonths] = useState(1);
const [detail, setDetail] = useState<CityDetailPayload | null>(null);
const [content, setContent] = useState<CityContentItem[]>([]);
const [meetups, setMeetups] = useState<Meetup[]>([]);
const [discussions, setDiscussions] = useState<Discussion[]>([]);
const [depthLoading, setDepthLoading] = useState(true);
const scores = useMemo(() => {
const nomads = parseInt(dest.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
return [
{ label: t.destDetail.scoreValue, value: Math.round((12000 - dest.cost) / 120), emoji: "💰" },
{ label: t.destDetail.scoreSpeed, value: Math.round(dest.speed / 2), emoji: "📶" },
{ label: t.destDetail.scoreClimate, value: Math.round(100 - Math.abs(dest.temperature - 24) * 4), emoji: "🌡️" },
{ label: t.destDetail.scoreRating, value: Math.round(dest.rating * 10), emoji: "⭐" },
{ label: t.destDetail.scoreCommunity, value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
];
}, [dest, t]);
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
const overBudget = budget > 0 && dest.cost > budget;
const related = allDestinations
.filter((d) => d.region === dest.region && d.slug !== dest.slug)
.slice(0, 3);
useEffect(() => {
trackRecentDestination({
slug: dest.slug,
name: dest.name,
country: dest.country,
emoji: dest.emoji,
cost: dest.cost,
rating: dest.rating,
});
}, [dest.slug, dest.name, dest.country, dest.emoji, dest.cost, dest.rating]);
useEffect(() => {
setDepthLoading(true);
api
.getDestinationFull(dest.slug)
.then((full) => {
setDetail(full.detail);
setContent(full.content || []);
setMeetups(full.meetups || []);
setDiscussions(full.discussions || []);
})
.catch(() => {
setDetail(null);
setContent([]);
setMeetups([]);
setDiscussions([]);
})
.finally(() => setDepthLoading(false));
}, [dest.slug]);
const addToTrip = () => {
const trip = loadTrip<TripItem>();
if (trip.some((item) => item.slug === dest.slug)) {
toast(t.common.alreadyInTrip, "info", { href: "/plan", label: t.strip.openPlan });
setAdded(true);
return;
}
const { added: n } = mergeDestinationsIntoTrip([dest], months);
setAdded(true);
toast(
n > 0
? t.common.addedToPlanMonths
.replace("{emoji}", dest.emoji)
.replace("{city}", dest.name)
.replace("{months}", String(months))
: t.common.alreadyInPlan,
n > 0 ? "success" : "info",
{ href: "/plan", label: t.strip.openPlan }
);
};
const share = async () => {
const url = window.location.href;
if (navigator.share) {
await navigator.share({ title: `${dest.name} - nomadro`, url });
} else {
await navigator.clipboard.writeText(url);
toast(t.common.linkCopied);
}
};
const copySnapshot = async () => {
const url = typeof window !== "undefined" ? window.location.href : `/destinations/${dest.slug}`;
const lines = [
`${dest.emoji} ${dest.name}, ${dest.country}`,
t.destDetail.snapCost
.replace("{cost}", dest.cost.toLocaleString())
.replace("{speed}", String(dest.speed))
.replace("{rating}", String(dest.rating)),
t.destDetail.snapClimate
.replace("{temp}", String(dest.temperature))
.replace("{nomads}", dest.nomads_count),
visa ? t.destDetail.snapVisa.replace("{visa}", visa.text) : "",
t.destDetail.snapDetail.replace("{url}", url),
"— via nomadro",
].filter(Boolean);
try {
await navigator.clipboard.writeText(lines.join("\n"));
toast(t.profile.snapshotOk);
} catch {
toast(t.profile.snapshotOk, "info");
}
};
useEffect(() => {
const trip = loadTrip<TripItem>();
setAdded(trip.some((item) => item.slug === dest.slug));
}, [dest.slug]);
const costBreak = detail?.cost?.breakdown || [];
const guide = detail?.guide;
const pros = detail?.pros;
return (
<>
<div className="dest-detail-actions">
{added ? (
<Link href="/plan" className="btn btn-primary">{t.common.inPlanOpen}</Link>
) : (
<>
<label className="dest-months-pick">
{t.common.stayLabel}
<input
type="number"
min={1}
max={24}
value={months}
onChange={(e) => setMonths(Math.max(1, Math.min(24, +e.target.value || 1)))}
/>
{t.common.monthsUnit}
</label>
<button type="button" className="btn btn-primary" onClick={addToTrip}>{t.common.addToPlan}</button>
</>
)}
<Link href={`/compare?cities=${dest.slug}`} className="btn btn-ghost">{t.common.goCompare}</Link>
<Link href="/next-stop" className="btn btn-ghost">{t.common.findNext}</Link>
<Link href={meetupCityHref(dest.name)} className="btn btn-ghost">{t.common.cityMeetups}</Link>
<button type="button" className="btn btn-ghost" onClick={share}>{t.common.shareBtn}</button>
<button type="button" className="btn btn-ghost" onClick={() => void copySnapshot()}>
{t.profile.snapshotBtn}
</button>
</div>
{depthLoading && <p className="dest-depth-loading">{t.common.loadingDepth}</p>}
{(visa || overBudget) && (
<div className="dest-plan-hints">
{visa && (
<p className={`plan-visa${visa.overLimit ? " warn" : ""}`}>🛂 {visa.text}</p>
)}
{overBudget && (
<p className="plan-visa warn">
{t.common.cityOverBudget
.replace("{cost}", dest.cost.toLocaleString())
.replace("{budget}", budget.toLocaleString())}
</p>
)}
</div>
)}
{guide?.summary && (
<section className="dest-depth-block">
<h3>{t.destDetail.guide}</h3>
<p>{guide.summary}</p>
{guide.workSetup && <p className="dest-depth-muted">{guide.workSetup}</p>}
{!!guide.bestFor?.length && (
<p className="dest-depth-tags">{t.destDetail.bestFor}{guide.bestFor.join(" · ")}</p>
)}
{!!guide.arrivalChecklist?.length && (
<ul className="dest-depth-list">
{guide.arrivalChecklist.map((item) => <li key={item}>{item}</li>)}
</ul>
)}
</section>
)}
<div className="dest-detail-extras">
<div className="dest-radar-card">
<h3>{t.destDetail.radar}</h3>
<div className="dest-radar-bars">
{scores.map((s) => (
<div key={s.label} className="dest-radar-row">
<span className="dest-radar-label">{s.emoji} {s.label}</span>
<div className="dest-radar-track">
<div className="dest-radar-fill" style={{ width: `${Math.min(s.value, 100)}%` }} />
</div>
<span className="dest-radar-val">{Math.min(s.value, 100)}</span>
</div>
))}
</div>
</div>
<div className="dest-info-cards">
<div className="dest-info-mini">
<span>🕐</span>
<div>
<strong>{t.destDetail.timezone}</strong>
<span>{TZ_LABELS[dest.slug] || t.common.viewTimezone}</span>
</div>
</div>
<div className="dest-info-mini">
<span>👥</span>
<div>
<strong>{t.destDetail.nomadCommunity}</strong>
<span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} {t.destDetail.active}</span>
</div>
</div>
<div className="dest-info-mini">
<span>🌡️</span>
<div>
<strong>{t.destDetail.climate}</strong>
<span>
{detail?.weather?.temperature ?? dest.temperature}°C
{detail?.weather?.bestMonths
? ` · ${t.destDetail.livable} ${detail.weather.bestMonths.join("/")}`
: ""}
</span>
</div>
</div>
</div>
</div>
{!!costBreak.length && (
<section className="dest-depth-block">
<h3>{t.destDetail.costBreak}</h3>
<div className="dest-cost-grid">
{costBreak.map((row) => (
<div key={row.label} className="dest-cost-row">
<span>{row.label}</span>
<strong>¥{row.amount.toLocaleString()}</strong>
</div>
))}
</div>
{detail?.cost?.tip && <p className="dest-depth-muted">{detail.cost.tip}</p>}
</section>
)}
{(pros?.pros || pros?.cons) && (
<section className="dest-depth-block dest-pros-cons">
<div>
<h3>{t.destDetail.pros}</h3>
<ul>{(pros?.pros || []).map((p) => <li key={p}>{p}</li>)}</ul>
</div>
<div>
<h3>{t.destDetail.cons}</h3>
<ul>{(pros?.cons || []).map((p) => <li key={p}>{p}</li>)}</ul>
</div>
</section>
)}
{!!detail?.reviews?.items?.length && (
<section className="dest-depth-block">
<h3>{t.destDetail.reviews} · {detail.reviews.rating}/10</h3>
<div className="dest-review-grid">
{detail.reviews.items.map((r) => (
<blockquote key={`${r.author}-${r.text.slice(0, 12)}`}>
<p>{r.text}</p>
<footer>{r.author} · {r.role} · {"★".repeat(r.score)}</footer>
</blockquote>
))}
</div>
</section>
)}
{!!detail?.chat?.channels?.length && (
<section className="dest-depth-block">
<h3>{t.destDetail.localChat}</h3>
<ul className="dest-depth-list">
{detail.chat.channels.map((c) => (
<li key={c.name}>{c.name} · {c.members} {t.destDetail.peopleUnit} · {c.status}</li>
))}
</ul>
{!!detail.chat.latestTopics?.length && (
<p className="dest-depth-muted">
{t.destDetail.recentTopics}{detail.chat.latestTopics.join(" · ")}
</p>
)}
</section>
)}
{!!meetups.length && (
<section className="dest-depth-block">
<h3>{t.destDetail.relatedMeetups}</h3>
<div className="dest-related-grid">
{meetups.slice(0, 4).map((m) => (
<Link key={m.id} href={meetupEventHref(m)} className="dest-related-card">
<span>{m.emoji || "🎉"}</span>
<div>
<strong>{m.title}</strong>
<span>{m.date} · {m.city}</span>
</div>
</Link>
))}
</div>
</section>
)}
{!!discussions.length && (
<section className="dest-depth-block">
<h3>{t.destDetail.relatedDiscussions}</h3>
<ul className="dest-depth-list">
{discussions.slice(0, 5).map((d) => (
<li key={d.id}>
<Link href={`/community/${d.id}`}>{d.title}</Link>
</li>
))}
</ul>
</section>
)}
{!!content.length && (
<section className="dest-depth-block">
<h3>{t.destDetail.relatedContent}</h3>
<div className="dest-related-grid">
{content.map((c) => (
<Link key={c.slug} href={c.targetUrl || "/digital"} className="dest-related-card">
<span>📄</span>
<div>
<strong>{c.title}</strong>
<span>{c.subtitle || c.ctaLabel || t.destDetail.viewFallback}</span>
</div>
</Link>
))}
</div>
</section>
)}
{related.length > 0 && (
<div className="dest-related">
<h3>{t.destDetail.relatedRegion}</h3>
<div className="dest-related-grid">
{related.map((d) => (
<Link key={d.slug} href={`/destinations/${d.slug}`} className="dest-related-card">
<span>{d.emoji}</span>
<div>
<strong>{d.name}</strong>
<span>¥{d.cost.toLocaleString()}{t.destDetail.perMonth} · ⭐ {d.rating}</span>
</div>
</Link>
))}
</div>
</div>
)}
<RingNext steps={rings.afterCity(dest.name)} />
</>
);
}