386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
"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)} />
|
||
</>
|
||
);
|
||
}
|