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 { api } from "@/lib/api";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import BlogMarkdown from "@/components/BlogMarkdown";
import BlogReadingProgress from "@/components/BlogReadingProgress"; 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 }> }) { export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; const { slug } = await params;
let post; let post;
let allPosts; let allPosts;
try { try {
[post, allPosts] = await Promise.all([ [post, allPosts] = await Promise.all([api.getBlogPost(slug), api.getBlog()]);
api.getBlogPost(slug),
api.getBlog(),
]);
} catch { } catch {
notFound(); notFound();
} }
const related = allPosts 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); .slice(0, 3);
return ( return (
<SiteShell> <SiteShell>
<BlogReadingProgress /> <BlogReadingProgress />
<div className="detail-page"> <BlogDetailClient post={post} related={related} />
<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>
</SiteShell> </SiteShell>
); );
} }

View File

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

View File

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

View File

@ -1,56 +1,23 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import Link from "next/link"; import DigitalDayClient from "@/components/DigitalDayClient";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null); 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 }> }) { export default async function DigitalDayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null); const day = await api.getDigitalDay(id).catch(() => null);
if (!day) notFound(); 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 ( return (
<SiteShell showFooter> <SiteShell showFooter>
<div className="digital-page"> <DigitalDayClient id={id} title={day.title} body={day.body} />
<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>
</SiteShell> </SiteShell>
); );
} }

View File

@ -10716,6 +10716,23 @@ a.profile-stat-link:hover {
min-height: 80px; 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; } .gigs-page { padding: 100px 0 80px; min-height: 60vh; }
.join-card select { .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/1`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
{ url: `${SITE}/digital/day/2`, 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/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`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 }, { url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/support`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { 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 [cityNames, setCityNames] = useState<string[]>([]);
const draftKey = `nomadro-reply-draft:${discussionId}`; 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(() => { useEffect(() => {
const draft = loadDraft<{ text?: string }>(draftKey); const draft = loadDraft<{ text?: string }>(draftKey);
if (draft?.text?.trim()) { if (draft?.text?.trim()) {
@ -93,9 +105,11 @@ export default function DiscussionDetailClient({
setReply(""); setReply("");
clearDraft(draftKey); clearDraft(draftKey);
await refresh(); await refresh();
toast(t.community.replyOk, "success", { toast(t.community.replyOk, "success");
href: meetupsHref, requestAnimationFrame(() => {
label: inferredCity ? t.common.cityMeetups : t.dating.meetAtEvents, const nodes = document.querySelectorAll(".community-reply");
const last = nodes[nodes.length - 1];
last?.scrollIntoView({ behavior: "smooth", block: "center" });
}); });
} catch { } catch {
toast(t.community.replyFail, "error"); toast(t.community.replyFail, "error");
@ -113,7 +127,7 @@ export default function DiscussionDetailClient({
const res = await api.likeDiscussion(token, discussionId); const res = await api.likeDiscussion(token, discussionId);
setLikes(res.like_count); setLikes(res.like_count);
} catch { } catch {
/* ignore */ toast(t.community.likeFail, "error");
} }
}; };
@ -130,7 +144,7 @@ export default function DiscussionDetailClient({
<article className="community-detail reveal"> <article className="community-detail reveal">
<header> <header>
<span className="community-detail-cat">{discussion.category}</span> <span className="community-detail-cat">{catLabel(discussion.category)}</span>
<h1>{discussion.title}</h1> <h1>{discussion.title}</h1>
<p className="community-meta"> <p className="community-meta">
{discussion.author_emoji} {discussion.author} · {discussion.created_at} {discussion.author_emoji} {discussion.author} · {discussion.created_at}
@ -176,14 +190,26 @@ export default function DiscussionDetailClient({
<textarea <textarea
value={reply} value={reply}
onChange={(e) => setReply(e.target.value)} onChange={(e) => setReply(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
void submitReply();
}
}}
rows={3} rows={3}
placeholder={t.community.replyPlaceholder} placeholder={t.community.replyPlaceholder}
maxLength={2000} maxLength={2000}
/> />
<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()}> <button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submitReply()}>
{loading ? t.community.replySending : t.community.replySend} {loading ? t.community.replySending : t.community.replySend}
</button> </button>
</div> </div>
<p className="community-compose-hint">{t.community.replyShortcut}</p>
</div>
) : ( ) : (
<div className="community-reply-hint reveal"> <div className="community-reply-hint reveal">
<p>{t.community.replyHint}</p> <p>{t.community.replyHint}</p>

View File

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

View File

@ -137,6 +137,9 @@ export const messages = {
payFailed: 'Payment failed', payFailed: 'Payment failed',
buyGet: 'Get the {name}', buyGet: 'Get the {name}',
buySub: 'Read free online, or grab PDF & EPUB for offline and permanent access.', 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', checkoutFailed: 'Checkout failed',
}, },
thanks: { thanks: {

View File

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

View File

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