Polish destination actions, submit, likes, newsletter honesty.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 20:58:10 -05:00
parent 0ce48023ae
commit 9f2eee7e83
10 changed files with 269 additions and 65 deletions

View File

@ -64,6 +64,16 @@ export default async function BlogDetailPage({ params }: { params: Promise<{ slu
</div> </div>
</section> </section>
)} )}
<section className="blog-related" style={{ marginTop: "2rem" }}>
<h2>接着探索</h2>
<div className="dating-empty-actions" style={{ justifyContent: "flex-start" }}>
<Link href="/community" className="btn btn-primary btn-sm">社区讨论</Link>
<Link href="/book" className="btn btn-sm">电子书</Link>
<Link href="/next-stop" className="btn btn-sm">下一站</Link>
<Link href="/submit" className="btn btn-ghost btn-sm">投稿</Link>
</div>
</section>
</div> </div>
</SiteShell> </SiteShell>
); );

View File

@ -4532,6 +4532,12 @@ img { max-width: 100%; display: block; }
margin: 24px 0; margin: 24px 0;
} }
.dest-depth-loading {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0 0 1rem;
}
.dest-detail-extras { .dest-detail-extras {
display: grid; display: grid;
grid-template-columns: 1.2fr 1fr; grid-template-columns: 1.2fr 1fr;
@ -10641,6 +10647,12 @@ img { max-width: 100%; display: block; }
color: var(--text-primary); color: var(--text-primary);
} }
.newsletter-note {
margin-top: 10px;
font-size: 0.8rem;
color: var(--text-muted);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.report-grid { grid-template-columns: 1fr; } .report-grid { grid-template-columns: 1fr; }
} }

View File

@ -104,7 +104,27 @@ export default function ToolsHubPage() {
</button> </button>
</div> </div>
)} )}
{filtered.length === 0 && <p className="dest-empty">没有匹配的工具,试试其他关键词</p>} {filtered.length === 0 && (
<div className="notif-empty">
<p className="dest-empty">没有匹配的工具,试试其他关键词</p>
<div className="dating-empty-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => {
setQ("");
setFilter("all");
setShowAll(false);
}}
>
清除筛选
</button>
<Link href="/next-stop" className="btn btn-primary btn-sm">
去下一站
</Link>
</div>
</div>
)}
</div> </div>
</div> </div>
</SiteShell> </SiteShell>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -17,43 +17,102 @@ export default function ContentSubmitClient() {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [contentType, setContentType] = useState("article"); const [contentType, setContentType] = useState("article");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const submit = async () => { const submit = async () => {
if (!token) { if (!token) {
router.push("/login?next=/submit"); router.push("/login?next=/submit");
return; return;
} }
if (!title.trim() || title.trim().length < 4) {
toast("请填写更完整的标题", "info");
return;
}
setBusy(true);
try { try {
await api.submitContent({ title, url, notes, content_type: contentType, name: "" }, token); await api.submitContent(
{
title: title.trim(),
url: url.trim(),
notes: notes.trim(),
content_type: contentType,
name: "",
},
token
);
toast(t.submit.ok, "success"); toast(t.submit.ok, "success");
setTitle(""); setTitle("");
setUrl(""); setUrl("");
setNotes(""); setNotes("");
setDone(true);
} catch { } catch {
toast(t.submit.fail, "error"); toast(t.submit.fail, "error");
} finally {
setBusy(false);
} }
}; };
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav> <nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
</nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.submit.tag}</span> <span className="section-tag">{t.submit.tag}</span>
<h1>{t.submit.title}</h1> <h1>{t.submit.title}</h1>
<p>{t.submit.subtitle}</p> <p>{t.submit.subtitle}</p>
</div> </div>
{done ? (
<div className="dating-gate reveal">
<h2>投稿已收到</h2>
<p>审核通过后会出现在博客或视频区</p>
<div className="dating-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
再投一篇
</button>
<Link href="/community" className="btn">
去社区
</Link>
</div>
</div>
) : (
<div className="community-compose reveal"> <div className="community-compose reveal">
<select value={contentType} onChange={(e) => setContentType(e.target.value)}> <select value={contentType} onChange={(e) => setContentType(e.target.value)}>
<option value="article">{t.submit.article}</option> <option value="article">{t.submit.article}</option>
<option value="video">{t.submit.video}</option> <option value="video">{t.submit.video}</option>
<option value="ebook">{t.submit.ebook}</option> <option value="ebook">{t.submit.ebook}</option>
</select> </select>
<input placeholder={t.submit.subject} value={title} onChange={(e) => setTitle(e.target.value)} /> <input
<input placeholder={t.submit.url} value={url} onChange={(e) => setUrl(e.target.value)} /> placeholder={t.submit.subject}
<textarea rows={4} placeholder={t.submit.notes} value={notes} onChange={(e) => setNotes(e.target.value)} /> value={title}
<button type="button" className="btn btn-primary" onClick={submit}>{t.submit.send}</button> onChange={(e) => setTitle(e.target.value)}
maxLength={120}
/>
<input
placeholder={t.submit.url}
value={url}
onChange={(e) => setUrl(e.target.value)}
maxLength={500}
/>
<textarea
rows={4}
placeholder={t.submit.notes}
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={2000}
/>
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
{busy ? "提交中…" : t.submit.send}
</button>
{!token && (
<p className="section-desc">
<Link href="/login?next=/submit">{t.nav.login}</Link> 后可提交
</p>
)}
</div> </div>
)}
</div> </div>
</div> </div>
); );

View File

@ -11,10 +11,19 @@ export default function DatingLikesClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const [likes, setLikes] = useState<MatchProfile[]>([]); const [likes, setLikes] = useState<MatchProfile[]>([]);
const [loading, setLoading] = useState(Boolean(token));
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) {
api.getMatchLikes(token).then(setLikes).catch(() => setLikes([])); setLoading(false);
return;
}
setLoading(true);
api
.getMatchLikes(token)
.then(setLikes)
.catch(() => setLikes([]))
.finally(() => setLoading(false));
}, [token]); }, [token]);
if (!user) { if (!user) {
@ -23,7 +32,9 @@ export default function DatingLikesClient() {
<div className="container"> <div className="container">
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.dating.loginTitle}</h2> <h2>{t.dating.loginTitle}</h2>
<Link href="/login?next=/dating/likes" className="btn btn-primary">{t.nav.login}</Link> <Link href="/login?next=/dating/likes" className="btn btn-primary">
{t.nav.login}
</Link>
</div> </div>
</div> </div>
</div> </div>
@ -41,15 +52,35 @@ export default function DatingLikesClient() {
<h1>{t.dating.likesTitle}</h1> <h1>{t.dating.likesTitle}</h1>
<p>{t.dating.likesSubtitle}</p> <p>{t.dating.likesSubtitle}</p>
</div> </div>
<div className="dating-likes-grid"> {loading && <p className="dest-empty">加载中…</p>}
{likes.map((p) => ( {!loading && likes.length === 0 && (
<div key={p.id} className="dating-like-card reveal"> <div className="notif-empty reveal">
<div className="dating-like-photo">{p.photo || "🧑‍💻"}</div> <p className="dest-empty">{t.dating.likesEmpty}</p>
<strong>{p.name}</strong> <Link href="/dating" className="btn btn-primary btn-sm">
<p className="dating-meta">{p.location}</p> 去滑动匹配
</Link>
</div> </div>
))} )}
{likes.length === 0 && <p className="dest-empty">{t.dating.likesEmpty}</p>} <div className="dating-likes-grid">
{!loading &&
likes.map((p) => {
const photo = p.photo || "";
const href = p.userId ? `/members/${p.userId}` : "/dating";
return (
<Link key={p.id} href={href} className="dating-like-card reveal">
<div className="dating-like-photo">
{photo.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photo} alt="" className="member-photo-img" />
) : (
photo || "🧑‍💻"
)}
</div>
<strong>{p.name}</strong>
<p className="dating-meta">{p.location || "全球"}</p>
</Link>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@ -41,6 +41,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const [content, setContent] = useState<CityContentItem[]>([]); const [content, setContent] = useState<CityContentItem[]>([]);
const [meetups, setMeetups] = useState<Meetup[]>([]); const [meetups, setMeetups] = useState<Meetup[]>([]);
const [discussions, setDiscussions] = useState<Discussion[]>([]); const [discussions, setDiscussions] = useState<Discussion[]>([]);
const [depthLoading, setDepthLoading] = useState(true);
const scores = getScores(dest); const scores = getScores(dest);
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]); const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []); const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
@ -51,12 +52,22 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
.slice(0, 3); .slice(0, 3);
useEffect(() => { useEffect(() => {
api.getDestinationFull(dest.slug).then((full) => { setDepthLoading(true);
api
.getDestinationFull(dest.slug)
.then((full) => {
setDetail(full.detail); setDetail(full.detail);
setContent(full.content || []); setContent(full.content || []);
setMeetups(full.meetups || []); setMeetups(full.meetups || []);
setDiscussions(full.discussions || []); setDiscussions(full.discussions || []);
}).catch(() => {}); })
.catch(() => {
setDetail(null);
setContent([]);
setMeetups([]);
setDiscussions([]);
})
.finally(() => setDepthLoading(false));
}, [dest.slug]); }, [dest.slug]);
const addToTrip = () => { const addToTrip = () => {
@ -113,13 +124,17 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
/> />
月 月
</label> </label>
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button> <button type="button" className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
</> </>
)} )}
<Link href={`/compare?cities=${dest.slug}`} className="btn btn-ghost">⚖️ 去对比</Link>
<Link href="/next-stop" className="btn btn-ghost">🧭 找下一站</Link>
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link> <Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link>
<button className="btn btn-ghost" onClick={share}>📤 分享</button> <button type="button" className="btn btn-ghost" onClick={share}>📤 分享</button>
</div> </div>
{depthLoading && <p className="dest-depth-loading">加载城市深度信息…</p>}
{(visa || overBudget) && ( {(visa || overBudget) && (
<div className="dest-plan-hints"> <div className="dest-plan-hints">
{visa && ( {visa && (

View File

@ -1,14 +1,22 @@
"use client"; "use client";
import { useAuth } from "@/lib/auth";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { loginNextFrom } from "@/lib/authNext";
export default function FavoriteButton({ slug }: { slug: string }) { export default function FavoriteButton({ slug }: { slug: string }) {
const { user, isFavorite, toggleFavorite } = useAuth(); const { user, isFavorite, toggleFavorite } = useAuth();
const pathname = usePathname();
const next = loginNextFrom(pathname || `/destinations/${slug}`);
if (!user) { if (!user) {
return ( return (
<Link href="/login" className="btn btn-ghost" style={{ fontSize: "0.85rem" }}> <Link
href={`/login?next=${encodeURIComponent(next)}`}
className="btn btn-ghost"
style={{ fontSize: "0.85rem" }}
>
🤍 登录收藏 🤍 登录收藏
</Link> </Link>
); );
@ -16,8 +24,13 @@ export default function FavoriteButton({ slug }: { slug: string }) {
const fav = isFavorite(slug); const fav = isFavorite(slug);
return ( return (
<button className={`btn ${fav ? "btn-primary" : "btn-ghost"}`} <button
onClick={() => toggleFavorite(slug)} style={{ fontSize: "0.85rem" }}> type="button"
className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
onClick={() => void toggleFavorite(slug)}
style={{ fontSize: "0.85rem" }}
aria-pressed={fav}
>
{fav ? "❤️ 已收藏" : "🤍 收藏"} {fav ? "❤️ 已收藏" : "🤍 收藏"}
</button> </button>
); );

View File

@ -41,6 +41,7 @@ const CORE_PAGES: SearchResult[] = [
/** Secondary pages only surface when the query matches — avoid dumping the sitemap. */ /** Secondary pages only surface when the query matches — avoid dumping the sitemap. */
const EXTRA_PAGES: SearchResult[] = [ const EXTRA_PAGES: SearchResult[] = [
{ type: "page", title: "游牧代码电子书", subtitle: "在线阅读与下载版", emoji: "📖", url: "/book" },
{ type: "page", title: "游民访谈", subtitle: "真实旅居视频", emoji: "🎬", url: "/videos" }, { type: "page", title: "游民访谈", subtitle: "真实旅居视频", emoji: "🎬", url: "/videos" },
{ type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" }, { type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" },
{ type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" }, { type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" },

View File

@ -9,15 +9,23 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
const submit = async () => { const submit = async () => {
if (!email.trim()) return; const value = email.trim();
if (!value || !value.includes("@")) {
toast("请输入有效邮箱", "info");
return;
}
setBusy(true);
try { try {
const res = await api.subscribeNewsletter(email.trim(), source); const res = await api.subscribeNewsletter(value, source);
toast(res.message || t.newsletter.ok, "success"); toast(res.message || t.newsletter.ok, "success");
setEmail(""); setEmail("");
} catch { } catch {
toast(t.newsletter.fail, "error"); toast(t.newsletter.fail, "error");
} finally {
setBusy(false);
} }
}; };
@ -26,9 +34,19 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
<strong>{t.newsletter.title}</strong> <strong>{t.newsletter.title}</strong>
<p>{t.newsletter.subtitle}</p> <p>{t.newsletter.subtitle}</p>
<div className="newsletter-form"> <div className="newsletter-form">
<input type="email" placeholder={t.newsletter.email} value={email} onChange={(e) => setEmail(e.target.value)} /> <input
<button type="button" className="btn btn-primary btn-sm" onClick={submit}>{t.newsletter.submit}</button> type="email"
placeholder={t.newsletter.email}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void submit()}
disabled={busy}
/>
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void submit()}>
{busy ? "…" : t.newsletter.submit}
</button>
</div> </div>
<p className="newsletter-note">先登记邮箱;邮件推送开通后会同步发送,不会假装已群发</p>
</div> </div>
); );
} }

View File

@ -4,29 +4,46 @@ import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function ReportClient() { export default function ReportClient() {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]); const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]);
const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]); const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
api.getCityRanking().then((r) => setRanking(r.items || [])).catch(() => {}); Promise.all([
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => {}); api.getCityRanking().then((r) => setRanking(r.items || [])).catch(() => setRanking([])),
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => setMembers([])),
]).finally(() => setLoading(false));
}, []); }, []);
return ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav> <nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link>
</nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.report.tag}</span> <span className="section-tag">{t.report.tag}</span>
<h1>{t.report.title}</h1> <h1>{t.report.title}</h1>
<p>{t.report.subtitle}</p> <p>{t.report.subtitle}</p>
</div> </div>
{loading && <p className="dest-empty">加载报告…</p>}
<div className="report-grid"> <div className="report-grid">
<section className="join-card reveal"> <section className="join-card reveal">
<h3>{t.report.ranking}</h3> <h3>{t.report.ranking}</h3>
{!loading && ranking.length === 0 ? (
<div className="notif-empty">
<p className="dest-empty">排名数据暂不可用</p>
<Link href="/next-stop" className="btn btn-sm">
去智能下一站
</Link>
</div>
) : (
<ol className="report-rank"> <ol className="report-rank">
{ranking.map((c, i) => ( {ranking.map((c, i) => (
<li key={c.slug}> <li key={c.slug}>
@ -37,9 +54,13 @@ export default function ReportClient() {
</li> </li>
))} ))}
</ol> </ol>
)}
</section> </section>
<section className="join-card reveal"> <section className="join-card reveal">
<h3>{t.report.members}</h3> <h3>{t.report.members}</h3>
{!loading && members.length === 0 ? (
<p className="dest-empty">会员分布暂不可用</p>
) : (
<div className="member-map-list"> <div className="member-map-list">
{members.map((m) => ( {members.map((m) => (
<div key={m.name + m.city} className="member-map-item"> <div key={m.name + m.city} className="member-map-item">
@ -49,9 +70,13 @@ export default function ReportClient() {
</div> </div>
))} ))}
</div> </div>
<Link href="/map" className="btn btn-sm">{t.report.viewMap}</Link> )}
<Link href="/map" className="btn btn-sm">
{t.report.viewMap}
</Link>
</section> </section>
</div> </div>
<RingNext steps={rings.afterDiscover} />
</div> </div>
</div> </div>
); );