Polish destination actions, submit, likes, newsletter honesty.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0ce48023ae
commit
9f2eee7e83
@ -64,6 +64,16 @@ export default async function BlogDetailPage({ params }: { params: Promise<{ slu
|
||||
</div>
|
||||
</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>
|
||||
</SiteShell>
|
||||
);
|
||||
|
||||
@ -4532,6 +4532,12 @@ img { max-width: 100%; display: block; }
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.dest-depth-loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.dest-detail-extras {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
@ -10641,6 +10647,12 @@ img { max-width: 100%; display: block; }
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.newsletter-note {
|
||||
margin-top: 10px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.report-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@ -104,7 +104,27 @@ export default function ToolsHubPage() {
|
||||
</button>
|
||||
</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>
|
||||
</SiteShell>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
@ -17,43 +17,102 @@ export default function ContentSubmitClient() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [contentType, setContentType] = useState("article");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!token) {
|
||||
router.push("/login?next=/submit");
|
||||
return;
|
||||
}
|
||||
if (!title.trim() || title.trim().length < 4) {
|
||||
toast("请填写更完整的标题", "info");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
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");
|
||||
setTitle("");
|
||||
setUrl("");
|
||||
setNotes("");
|
||||
setDone(true);
|
||||
} catch {
|
||||
toast(t.submit.fail, "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="community-page">
|
||||
<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">
|
||||
<span className="section-tag">{t.submit.tag}</span>
|
||||
<h1>{t.submit.title}</h1>
|
||||
<p>{t.submit.subtitle}</p>
|
||||
</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">
|
||||
<select value={contentType} onChange={(e) => setContentType(e.target.value)}>
|
||||
<option value="article">{t.submit.article}</option>
|
||||
<option value="video">{t.submit.video}</option>
|
||||
<option value="ebook">{t.submit.ebook}</option>
|
||||
</select>
|
||||
<input placeholder={t.submit.subject} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<input placeholder={t.submit.url} value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<textarea rows={4} placeholder={t.submit.notes} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
<button type="button" className="btn btn-primary" onClick={submit}>{t.submit.send}</button>
|
||||
<input
|
||||
placeholder={t.submit.subject}
|
||||
value={title}
|
||||
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>
|
||||
);
|
||||
|
||||
@ -11,10 +11,19 @@ export default function DatingLikesClient() {
|
||||
const { user, token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [likes, setLikes] = useState<MatchProfile[]>([]);
|
||||
const [loading, setLoading] = useState(Boolean(token));
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api.getMatchLikes(token).then(setLikes).catch(() => setLikes([]));
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api
|
||||
.getMatchLikes(token)
|
||||
.then(setLikes)
|
||||
.catch(() => setLikes([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
if (!user) {
|
||||
@ -23,7 +32,9 @@ export default function DatingLikesClient() {
|
||||
<div className="container">
|
||||
<div className="dating-gate reveal">
|
||||
<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>
|
||||
@ -41,15 +52,35 @@ export default function DatingLikesClient() {
|
||||
<h1>{t.dating.likesTitle}</h1>
|
||||
<p>{t.dating.likesSubtitle}</p>
|
||||
</div>
|
||||
<div className="dating-likes-grid">
|
||||
{likes.map((p) => (
|
||||
<div key={p.id} className="dating-like-card reveal">
|
||||
<div className="dating-like-photo">{p.photo || "🧑💻"}</div>
|
||||
<strong>{p.name}</strong>
|
||||
<p className="dating-meta">{p.location}</p>
|
||||
{loading && <p className="dest-empty">加载中…</p>}
|
||||
{!loading && likes.length === 0 && (
|
||||
<div className="notif-empty reveal">
|
||||
<p className="dest-empty">{t.dating.likesEmpty}</p>
|
||||
<Link href="/dating" className="btn btn-primary btn-sm">
|
||||
去滑动匹配
|
||||
</Link>
|
||||
</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>
|
||||
|
||||
@ -41,6 +41,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
const [content, setContent] = useState<CityContentItem[]>([]);
|
||||
const [meetups, setMeetups] = useState<Meetup[]>([]);
|
||||
const [discussions, setDiscussions] = useState<Discussion[]>([]);
|
||||
const [depthLoading, setDepthLoading] = useState(true);
|
||||
const scores = getScores(dest);
|
||||
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
|
||||
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
|
||||
@ -51,12 +52,22 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
.slice(0, 3);
|
||||
|
||||
useEffect(() => {
|
||||
api.getDestinationFull(dest.slug).then((full) => {
|
||||
setDepthLoading(true);
|
||||
api
|
||||
.getDestinationFull(dest.slug)
|
||||
.then((full) => {
|
||||
setDetail(full.detail);
|
||||
setContent(full.content || []);
|
||||
setMeetups(full.meetups || []);
|
||||
setDiscussions(full.discussions || []);
|
||||
}).catch(() => {});
|
||||
})
|
||||
.catch(() => {
|
||||
setDetail(null);
|
||||
setContent([]);
|
||||
setMeetups([]);
|
||||
setDiscussions([]);
|
||||
})
|
||||
.finally(() => setDepthLoading(false));
|
||||
}, [dest.slug]);
|
||||
|
||||
const addToTrip = () => {
|
||||
@ -113,13 +124,17 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
||||
/>
|
||||
月
|
||||
</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>
|
||||
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={share}>📤 分享</button>
|
||||
</div>
|
||||
|
||||
{depthLoading && <p className="dest-depth-loading">加载城市深度信息…</p>}
|
||||
|
||||
{(visa || overBudget) && (
|
||||
<div className="dest-plan-hints">
|
||||
{visa && (
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth";
|
||||
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 }) {
|
||||
const { user, isFavorite, toggleFavorite } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const next = loginNextFrom(pathname || `/destinations/${slug}`);
|
||||
|
||||
if (!user) {
|
||||
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>
|
||||
);
|
||||
@ -16,8 +24,13 @@ export default function FavoriteButton({ slug }: { slug: string }) {
|
||||
|
||||
const fav = isFavorite(slug);
|
||||
return (
|
||||
<button className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
|
||||
onClick={() => toggleFavorite(slug)} style={{ fontSize: "0.85rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
|
||||
onClick={() => void toggleFavorite(slug)}
|
||||
style={{ fontSize: "0.85rem" }}
|
||||
aria-pressed={fav}
|
||||
>
|
||||
{fav ? "❤️ 已收藏" : "🤍 收藏"}
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -41,6 +41,7 @@ const CORE_PAGES: SearchResult[] = [
|
||||
|
||||
/** Secondary pages only surface when the query matches — avoid dumping the sitemap. */
|
||||
const EXTRA_PAGES: SearchResult[] = [
|
||||
{ type: "page", title: "游牧代码电子书", subtitle: "在线阅读与下载版", emoji: "📖", url: "/book" },
|
||||
{ type: "page", title: "游民访谈", subtitle: "真实旅居视频", emoji: "🎬", url: "/videos" },
|
||||
{ type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" },
|
||||
{ type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" },
|
||||
|
||||
@ -9,15 +9,23 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [email, setEmail] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!email.trim()) return;
|
||||
const value = email.trim();
|
||||
if (!value || !value.includes("@")) {
|
||||
toast("请输入有效邮箱", "info");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.subscribeNewsletter(email.trim(), source);
|
||||
const res = await api.subscribeNewsletter(value, source);
|
||||
toast(res.message || t.newsletter.ok, "success");
|
||||
setEmail("");
|
||||
} catch {
|
||||
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>
|
||||
<p>{t.newsletter.subtitle}</p>
|
||||
<div className="newsletter-form">
|
||||
<input type="email" placeholder={t.newsletter.email} value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={submit}>{t.newsletter.submit}</button>
|
||||
<input
|
||||
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>
|
||||
<p className="newsletter-note">先登记邮箱;邮件推送开通后会同步发送,不会假装已群发</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,29 +4,46 @@ import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
export default function ReportClient() {
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]);
|
||||
const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getCityRanking().then((r) => setRanking(r.items || [])).catch(() => {});
|
||||
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => {});
|
||||
Promise.all([
|
||||
api.getCityRanking().then((r) => setRanking(r.items || [])).catch(() => setRanking([])),
|
||||
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => setMembers([])),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="community-page">
|
||||
<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">
|
||||
<span className="section-tag">{t.report.tag}</span>
|
||||
<h1>{t.report.title}</h1>
|
||||
<p>{t.report.subtitle}</p>
|
||||
</div>
|
||||
{loading && <p className="dest-empty">加载报告…</p>}
|
||||
<div className="report-grid">
|
||||
<section className="join-card reveal">
|
||||
<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">
|
||||
{ranking.map((c, i) => (
|
||||
<li key={c.slug}>
|
||||
@ -37,9 +54,13 @@ export default function ReportClient() {
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
<section className="join-card reveal">
|
||||
<h3>{t.report.members}</h3>
|
||||
{!loading && members.length === 0 ? (
|
||||
<p className="dest-empty">会员分布暂不可用</p>
|
||||
) : (
|
||||
<div className="member-map-list">
|
||||
{members.map((m) => (
|
||||
<div key={m.name + m.city} className="member-map-item">
|
||||
@ -49,9 +70,13 @@ export default function ReportClient() {
|
||||
</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>
|
||||
</div>
|
||||
<RingNext steps={rings.afterDiscover} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user