i18n day/blog/ebook thanks and polish community reply UX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1c44721062
commit
ab9d8f6670
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -8,6 +8,14 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
const LOGS = [
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "学院/博客/社区/电子书",
|
||||
items: [
|
||||
"每日指南打通 7 天并中英导航;博客详情与电子书购买页中英文化",
|
||||
"社区回复支持 Ctrl+Enter、字数与滚动定位;点赞失败可提示;假下载改为在线阅读引导",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "全站闭环增强",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
|
||||
82
frontend/src/components/BlogDetailClient.tsx
Normal file
82
frontend/src/components/BlogDetailClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/DigitalDayClient.tsx
Normal file
73
frontend/src/components/DigitalDayClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,14 +190,26 @@ 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}
|
||||
/>
|
||||
<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">
|
||||
<p>{t.community.replyHint}</p>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -137,6 +137,9 @@ export const messages = {
|
||||
payFailed: '支付失败',
|
||||
buyGet: '购买 {name}',
|
||||
buySub: '可免费在线阅读,或购买 PDF 与 EPUB 离线永久保存。',
|
||||
buyDownload: '购买下载版',
|
||||
ownedDownload: '已购买 · 去下载',
|
||||
orReadOnline: '也可免费',
|
||||
checkoutFailed: '结账失败',
|
||||
},
|
||||
thanks: {
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user