i18n day/blog/ebook thanks and polish community reply UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-09-04 09:38:14 -05:00
parent 1c44721062
commit ab9d8f6670
13 changed files with 318 additions and 127 deletions

View File

@ -1,80 +1,27 @@
import Link from "next/link";
import { api } from "@/lib/api";
import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import BlogMarkdown from "@/components/BlogMarkdown";
import BlogReadingProgress from "@/components/BlogReadingProgress";
import ShareButton from "@/components/ShareButton";
import BlogDetailClient from "@/components/BlogDetailClient";
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let post;
let allPosts;
try {
[post, allPosts] = await Promise.all([
api.getBlogPost(slug),
api.getBlog(),
]);
[post, allPosts] = await Promise.all([api.getBlogPost(slug), api.getBlog()]);
} catch {
notFound();
}
const related = allPosts
.filter((p) => p.slug !== slug && p.tags.some((t) => post.tags.includes(t)))
.filter((p) => p.slug !== slug && p.tags.some((tag) => post.tags.includes(tag)))
.slice(0, 3);
return (
<SiteShell>
<BlogReadingProgress />
<div className="detail-page">
<nav className="detail-nav">
<Link href="/#blog">← 返回博客</Link>
<ShareButton title={post.title} compact />
</nav>
<article className="blog-detail">
<div className="blog-detail-header">
<span className="blog-detail-emoji">{post.emoji}</span>
<div className="blog-meta">
<span>📅 {post.published_at}</span>
<span>⏱️ {post.read_time} 分钟阅读</span>
<span>✍️ {post.author}</span>
</div>
<h1>{post.title}</h1>
<div className="blog-tags">
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
</div>
</div>
<div className="blog-detail-content">
<BlogMarkdown content={post.content} />
</div>
</article>
{related.length > 0 && (
<section className="blog-related">
<h2>📚 相关文章</h2>
<div className="blog-related-grid">
{related.map((r) => (
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
<span className="blog-related-emoji">{r.emoji}</span>
<h3>{r.title}</h3>
<p>{r.excerpt}</p>
<span className="blog-read-more">阅读 →</span>
</Link>
))}
</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>
<BlogDetailClient post={post} related={related} />
</SiteShell>
);
}

View File

@ -1,18 +1,28 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import { api } from "@/lib/api";
type DlFile = { format: string; label: string; url: string };
function isRealDownload(f: DlFile) {
const fmt = (f.format || "").toLowerCase();
return fmt === "pdf" || fmt === "epub" || (Boolean(f.url) && !f.url.startsWith("/book/read"));
}
function BookThanksInner() {
const { token } = useAuth();
const { t } = useI18n();
const b = t.bookThanks;
const router = useRouter();
const params = useSearchParams();
const [status, setStatus] = useState<"checking" | "ok" | "pending" | "need-login">("checking");
const [files, setFiles] = useState<{ format: string; label: string; url: string }[]>([]);
const [files, setFiles] = useState<DlFile[]>([]);
const [msg, setMsg] = useState("");
useEffect(() => {
@ -42,59 +52,63 @@ function BookThanksInner() {
}
if (!cancelled) {
setStatus("pending");
setMsg("支付确认中,请稍后刷新;或联系支持核对订单。");
setMsg(b.pending);
}
} catch (e) {
if (!cancelled) {
setStatus("pending");
setMsg(e instanceof Error ? e.message : "无法确认购买状态");
setMsg(e instanceof Error ? e.message : b.statusFail);
}
}
})();
return () => {
cancelled = true;
};
}, [token, params]);
}, [token, params, b.pending, b.statusFail]);
const downloads = useMemo(() => files.filter(isRealDownload), [files]);
const hasOnlineOnly = status === "ok" && downloads.length === 0;
return (
<div className="container" style={{ padding: "48px 24px", maxWidth: 560 }}>
<nav className="detail-nav">
<Link href="/book">← 电子书</Link>
<Link href="/book">← {t.nav.ebook}</Link>
</nav>
<h1>下载版</h1>
<h1>{b.title}</h1>
{status === "need-login" && (
<p>
请先 <Link href="/login?next=/book/thanks">登录</Link> 查看下载。
{b.needLogin}{" "}
<Link href="/login?next=/book/thanks">{t.nav.login}</Link>
</p>
)}
{status === "checking" && <p>正在确认订单…</p>}
{status === "checking" && <p>{b.checking}</p>}
{status === "pending" && (
<div>
<p>{msg}</p>
<button type="button" className="btn btn-primary" onClick={() => router.refresh()}>
刷新状态
{b.refresh}
</button>
</div>
)}
{status === "ok" && files.length === 0 && (
{hasOnlineOnly && (
<div>
<p>购买已确认,下载文件暂未配置。</p>
<p className="section-desc">可先在线阅读,或联系支持获取 PDF/EPUB。</p>
<p>{b.confirmedNoFiles}</p>
<p className="section-desc">{b.confirmedHint}</p>
<div className="dating-empty-actions">
<Link href="/book/read" className="btn btn-primary">
继续在线阅读
{b.continueRead}
</Link>
<Link href="/feedback" className="btn btn-ghost">
联系支持
{b.contact}
</Link>
</div>
</div>
)}
{status === "ok" && files.length > 0 && (
{status === "ok" && downloads.length > 0 && (
<div>
<p>购买成功,可下载:</p>
<p>{b.ready}</p>
<ul className="ebook-download-list">
{files.map((f) => (
{downloads.map((f) => (
<li key={f.format}>
<a href={f.url} className="btn btn-primary btn-sm" target="_blank" rel="noreferrer">
{f.label}
@ -103,7 +117,7 @@ function BookThanksInner() {
))}
</ul>
<p style={{ marginTop: 16 }}>
<Link href="/book/read">继续在线阅读 →</Link>
<Link href="/book/read">{b.continueRead} →</Link>
</p>
</div>
)}
@ -112,9 +126,10 @@ function BookThanksInner() {
}
export default function BookThanksPage() {
const { t } = useI18n();
return (
<SiteShell>
<Suspense fallback={<div className="container" style={{ padding: 48 }}>确认中…</div>}>
<Suspense fallback={<div className="container" style={{ padding: 48 }}>{t.bookThanks.checking}</div>}>
<BookThanksInner />
</Suspense>
</SiteShell>

View File

@ -8,6 +8,14 @@ export const metadata: Metadata = {
};
const LOGS = [
{
date: "2026-09-04",
tag: "学院/博客/社区/电子书",
items: [
"每日指南打通 7 天并中英导航;博客详情与电子书购买页中英文化",
"社区回复支持 Ctrl+Enter、字数与滚动定位;点赞失败可提示;假下载改为在线阅读引导",
],
},
{
date: "2026-09-04",
tag: "全站闭环增强",

View File

@ -1,56 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import Link from "next/link";
import DigitalDayClient from "@/components/DigitalDayClient";
import { api } from "@/lib/api";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null);
return { title: day ? `${day.title} · nomadro` : "每日指南" };
return { title: day ? `${day.title} · nomadro` : "Daily guide · nomadro" };
}
export default async function DigitalDayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null);
if (!day) notFound();
const n = Number(id);
const prev = Number.isFinite(n) && n > 1 ? String(n - 1) : null;
const next = Number.isFinite(n) && n < 3 ? String(n + 1) : null;
return (
<SiteShell showFooter>
<div className="digital-page">
<div className="container" style={{ maxWidth: 720 }}>
<nav className="detail-nav">
<Link href="/digital">← 游民学院</Link>
</nav>
<article className="digital-lesson reveal">
<span className="section-tag">📅 DAY {id}</span>
<h1>{day.title}</h1>
<div className="digital-lesson-body">
{day.body.split("\n").filter(Boolean).map((p, i) => (
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
))}
</div>
<footer className="digital-lesson-nav">
{prev && (
<Link href={`/digital/day/${prev}`} className="btn">
← 上一篇
</Link>
)}
{next && (
<Link href={`/digital/day/${next}`} className="btn btn-primary">
下一篇 →
</Link>
)}
<Link href="/digital/course" className="btn btn-ghost">
课程目录
</Link>
</footer>
</article>
</div>
</div>
<DigitalDayClient id={id} title={day.title} body={day.body} />
</SiteShell>
);
}

View File

@ -10716,6 +10716,23 @@ a.profile-stat-link:hover {
min-height: 80px;
}
.community-compose-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.community-compose-count,
.community-compose-hint {
font-size: 0.8rem;
color: var(--text-muted);
}
.community-compose-hint {
margin: 0;
}
.gigs-page { padding: 100px 0 80px; min-height: 60vh; }
.join-card select {

View File

@ -41,6 +41,10 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${SITE}/digital/day/1`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/2`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/3`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/4`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/5`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/6`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/7`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/support`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },

View File

@ -0,0 +1,82 @@
"use client";
import Link from "next/link";
import BlogMarkdown from "@/components/BlogMarkdown";
import ShareButton from "@/components/ShareButton";
import { useI18n } from "@/lib/i18n";
import type { BlogPost, BlogPostDetail } from "@/lib/types";
export default function BlogDetailClient({
post,
related,
}: {
post: BlogPostDetail;
related: BlogPost[];
}) {
const { t } = useI18n();
const b = t.blogDetail;
return (
<div className="detail-page">
<nav className="detail-nav">
<Link href="/#blog">← {b.back}</Link>
<ShareButton title={post.title} compact />
</nav>
<article className="blog-detail">
<div className="blog-detail-header">
<span className="blog-detail-emoji">{post.emoji}</span>
<div className="blog-meta">
<span>📅 {post.published_at}</span>
<span>⏱️ {b.readTime.replace("{n}", String(post.read_time))}</span>
<span>✍️ {post.author}</span>
</div>
<h1>{post.title}</h1>
<div className="blog-tags">
{post.tags.map((tag) => (
<span key={tag} className="blog-tag">
{tag}
</span>
))}
</div>
</div>
<div className="blog-detail-content">
<BlogMarkdown content={post.content} />
</div>
</article>
{related.length > 0 && (
<section className="blog-related">
<h2>📚 {b.related}</h2>
<div className="blog-related-grid">
{related.map((r) => (
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
<span className="blog-related-emoji">{r.emoji}</span>
<h3>{r.title}</h3>
<p>{r.excerpt}</p>
<span className="blog-read-more">{b.readMore}</span>
</Link>
))}
</div>
</section>
)}
<section className="blog-related" style={{ marginTop: "2rem" }}>
<h2>{b.explore}</h2>
<div className="dating-empty-actions" style={{ justifyContent: "flex-start" }}>
<Link href="/community" className="btn btn-primary btn-sm">
{t.nav.community}
</Link>
<Link href="/book" className="btn btn-sm">
{t.nav.ebook}
</Link>
<Link href="/next-stop" className="btn btn-sm">
{t.nav.nextStop}
</Link>
<Link href="/submit" className="btn btn-ghost btn-sm">
{b.submit}
</Link>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
const MAX_DAY = 7;
export default function DigitalDayClient({
id,
title,
body,
}: {
id: string;
title: string;
body: string;
}) {
const { t } = useI18n();
const rings = useRingSteps();
const n = Number(id);
const prev = Number.isFinite(n) && n > 1 ? String(n - 1) : null;
const next = Number.isFinite(n) && n < MAX_DAY ? String(n + 1) : null;
return (
<div className="digital-page">
<div className="container" style={{ maxWidth: 720 }}>
<nav className="detail-nav">
<Link href="/digital">← {t.digital.back}</Link>
</nav>
<article className="digital-lesson reveal">
<span className="section-tag">📅 DAY {id}</span>
<h1>{title}</h1>
<div className="digital-lesson-body">
{body
.split("\n")
.filter(Boolean)
.map((p, i) => (
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
))}
</div>
<footer className="digital-lesson-nav">
{prev && (
<Link href={`/digital/day/${prev}`} className="btn">
← {t.digital.prev}
</Link>
)}
{next ? (
<Link href={`/digital/day/${next}`} className="btn btn-primary">
{t.digital.next} →
</Link>
) : (
<div className="dating-empty-actions">
<Link href="/digital/course" className="btn btn-primary">
{t.digital.backCatalog}
</Link>
<Link href="/meetups" className="btn btn-ghost">
{t.nav.meetups}
</Link>
</div>
)}
{next && (
<Link href="/digital/course" className="btn btn-ghost">
{t.digital.backCourse}
</Link>
)}
</footer>
</article>
<RingNext steps={rings.afterDigital} />
</div>
</div>
);
}

View File

@ -32,6 +32,18 @@ export default function DiscussionDetailClient({
const [cityNames, setCityNames] = useState<string[]>([]);
const draftKey = `nomadro-reply-draft:${discussionId}`;
const catLabel = (value: string) => {
const map: Record<string, string> = {
全部: t.community.catAll,
签证: t.community.catVisa,
远程工作: t.community.catRemote,
住宿: t.community.catHousing,
安全: t.community.catSafety,
社区: t.community.catCommunity,
};
return map[value] || value;
};
useEffect(() => {
const draft = loadDraft<{ text?: string }>(draftKey);
if (draft?.text?.trim()) {
@ -93,9 +105,11 @@ export default function DiscussionDetailClient({
setReply("");
clearDraft(draftKey);
await refresh();
toast(t.community.replyOk, "success", {
href: meetupsHref,
label: inferredCity ? t.common.cityMeetups : t.dating.meetAtEvents,
toast(t.community.replyOk, "success");
requestAnimationFrame(() => {
const nodes = document.querySelectorAll(".community-reply");
const last = nodes[nodes.length - 1];
last?.scrollIntoView({ behavior: "smooth", block: "center" });
});
} catch {
toast(t.community.replyFail, "error");
@ -113,7 +127,7 @@ export default function DiscussionDetailClient({
const res = await api.likeDiscussion(token, discussionId);
setLikes(res.like_count);
} catch {
/* ignore */
toast(t.community.likeFail, "error");
}
};
@ -130,7 +144,7 @@ export default function DiscussionDetailClient({
<article className="community-detail reveal">
<header>
<span className="community-detail-cat">{discussion.category}</span>
<span className="community-detail-cat">{catLabel(discussion.category)}</span>
<h1>{discussion.title}</h1>
<p className="community-meta">
{discussion.author_emoji} {discussion.author} · {discussion.created_at}
@ -176,13 +190,25 @@ export default function DiscussionDetailClient({
<textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
void submitReply();
}
}}
rows={3}
placeholder={t.community.replyPlaceholder}
maxLength={2000}
/>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submitReply()}>
{loading ? t.community.replySending : t.community.replySend}
</button>
<div className="community-compose-foot">
<span className="community-compose-count">
{reply.length}/2000
</span>
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submitReply()}>
{loading ? t.community.replySending : t.community.replySend}
</button>
</div>
<p className="community-compose-hint">{t.community.replyShortcut}</p>
</div>
) : (
<div className="community-reply-hint reveal">

View File

@ -20,7 +20,7 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
const features = getFeatures();
const enabled = features.payment?.checkout?.enabled !== false;
const productName = features.payment?.checkout?.productName || "Download Edition";
const buttonText = features.payment?.checkout?.buttonText || "购买下载版";
const buttonText = features.payment?.checkout?.buttonText || t("payment.buyDownload");
useEffect(() => {
if (!token) return;
@ -47,7 +47,6 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
if (res.order_id) {
localStorage.setItem("nomadro-ebook-order", res.order_id);
}
// DEV_AUTO_PAY or already-paid: confirm then go to thanks
if (res.status === "paid" && res.order_id) {
await api.completePayment(token, res.order_id).catch(() => null);
router.push(`/book/thanks?order_id=${encodeURIComponent(res.order_id)}`);
@ -68,11 +67,12 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
<h2 className="buy-ebook-title">{t("payment.buyGet", { name: productName })}</h2>
<p className="buy-ebook-sub">{t("payment.buySub")}</p>
<button type="button" className="buy-ebook-btn" disabled={busy} onClick={buy}>
{busy ? t("payment.openingCheckout") : owned ? "已购买 · 去下载" : buttonText}
{busy ? t("payment.openingCheckout") : owned ? t("payment.ownedDownload") : buttonText}
</button>
{error ? <p className="buy-ebook-error">{error}</p> : null}
<p className="buy-ebook-hint">
也可免费 <Link href="/book/read">在线阅读</Link>
{t("payment.orReadOnline")}{" "}
<Link href="/book/read">{t("thanks.purchaseContinue")}</Link>
</p>
</section>
);

View File

@ -137,6 +137,9 @@ export const messages = {
payFailed: 'Payment failed',
buyGet: 'Get the {name}',
buySub: 'Read free online, or grab PDF & EPUB for offline and permanent access.',
buyDownload: 'Buy download edition',
ownedDownload: 'Owned · Downloads',
orReadOnline: 'Or read free',
checkoutFailed: 'Checkout failed',
},
thanks: {

View File

@ -137,6 +137,9 @@ export const messages = {
payFailed: '支付失败',
buyGet: '购买 {name}',
buySub: '可免费在线阅读,或购买 PDF 与 EPUB 离线永久保存。',
buyDownload: '购买下载版',
ownedDownload: '已购买 · 去下载',
orReadOnline: '也可免费',
checkoutFailed: '结账失败',
},
thanks: {

View File

@ -595,7 +595,7 @@ export const zh = {
missingLesson: "课时不存在",
backCatalog: "返回课程目录",
dayGuide: "每日指南",
dayGuideDesc: "从税务居民到落地验网,三天起步清单",
dayGuideDesc: "从税务居民到固定作息,七天落地清单",
back: "学院首页",
backCourse: "课程目录",
locked: "此课时需要 VIP",
@ -757,6 +757,8 @@ export const zh = {
replyNeed: "请先写一点回复内容",
replyOk: "回复已发布",
replyFail: "回复失败",
likeFail: "点赞失败,请稍后重试",
replyShortcut: "Ctrl/⌘ + Enter 发送",
noRepliesYet: "还没有回复,来做第一个吧",
catAll: "全部",
catVisa: "签证",
@ -769,6 +771,27 @@ export const zh = {
draftHint: "内容会自动保存在本机",
replyDraftRestored: "已恢复未发送的回复草稿",
},
bookThanks: {
title: "下载版",
needLogin: "请先登录后查看下载。",
checking: "正在确认订单…",
pending: "支付确认中,请稍后刷新;或联系支持核对订单。",
statusFail: "无法确认购买状态",
refresh: "刷新状态",
confirmedNoFiles: "购买已确认。PDF/EPUB 下载暂未开放。",
confirmedHint: "可先在线阅读完整版,或联系支持获取文件。",
continueRead: "继续在线阅读",
contact: "联系支持",
ready: "购买成功,可下载:",
},
blogDetail: {
back: "返回博客",
readTime: "{n} 分钟阅读",
related: "相关文章",
readMore: "阅读 →",
explore: "接着探索",
submit: "投稿",
},
gigs: {
tag: "💼 GIGS",
title: "赏金任务",
@ -1908,7 +1931,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
missingLesson: "Lesson not found",
backCatalog: "Back to catalog",
dayGuide: "Daily guides",
dayGuideDesc: "Tax residency to landing Wi‑Fi — a 3-day starter checklist",
dayGuideDesc: "Tax residency to daily rhythm — a 7-day landing checklist",
back: "Academy home",
backCourse: "Course catalog",
locked: "VIP required",
@ -2070,6 +2093,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
replyNeed: "Write a reply first",
replyOk: "Reply posted",
replyFail: "Reply failed",
likeFail: "Couldn't like — try again",
replyShortcut: "Ctrl/⌘ + Enter to send",
noRepliesYet: "No replies yet — be the first",
catAll: "All",
catVisa: "Visa",
@ -2082,6 +2107,27 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
draftHint: "Draft autosaves on this device",
replyDraftRestored: "Unsent reply draft restored",
},
bookThanks: {
title: "Download edition",
needLogin: "Sign in to view your downloads.",
checking: "Confirming your order…",
pending: "Payment is still confirming — refresh shortly or contact support.",
statusFail: "Could not confirm purchase status",
refresh: "Refresh status",
confirmedNoFiles: "Purchase confirmed. PDF/EPUB files are not configured yet.",
confirmedHint: "You can keep reading online, or contact support for files.",
continueRead: "Continue reading online",
contact: "Contact support",
ready: "Purchase complete — download:",
},
blogDetail: {
back: "Back to blog",
readTime: "{n} min read",
related: "Related posts",
readMore: "Read →",
explore: "Keep exploring",
submit: "Submit",
},
gigs: {
tag: "💼 GIGS",
title: "Bounty gigs",