From ab9d8f667034fd088f01321d04e792ef43eb548c Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 4 Sep 2026 09:38:14 -0500 Subject: [PATCH] i18n day/blog/ebook thanks and polish community reply UX. Co-authored-by: Cursor --- frontend/src/app/blog/[slug]/page.tsx | 61 +------------- frontend/src/app/book/thanks/page.tsx | 55 ++++++++----- frontend/src/app/changelog/page.tsx | 8 ++ frontend/src/app/digital/day/[id]/page.tsx | 39 +-------- frontend/src/app/globals.css | 17 ++++ frontend/src/app/sitemap.ts | 4 + frontend/src/components/BlogDetailClient.tsx | 82 +++++++++++++++++++ frontend/src/components/DigitalDayClient.tsx | 73 +++++++++++++++++ .../src/components/DiscussionDetailClient.tsx | 42 ++++++++-- frontend/src/components/ebook/BuyEbook.tsx | 8 +- frontend/src/lib/ebook/i18n/messages/en.ts | 3 + frontend/src/lib/ebook/i18n/messages/zh.ts | 3 + frontend/src/lib/i18n/dictionaries.ts | 50 ++++++++++- 13 files changed, 318 insertions(+), 127 deletions(-) create mode 100644 frontend/src/components/BlogDetailClient.tsx create mode 100644 frontend/src/components/DigitalDayClient.tsx diff --git a/frontend/src/app/blog/[slug]/page.tsx b/frontend/src/app/blog/[slug]/page.tsx index fa000c4..0974640 100644 --- a/frontend/src/app/blog/[slug]/page.tsx +++ b/frontend/src/app/blog/[slug]/page.tsx @@ -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 ( -
- -
-
- {post.emoji} -
- 📅 {post.published_at} - ⏱️ {post.read_time} 分钟阅读 - ✍️ {post.author} -
-

{post.title}

-
- {post.tags.map((t) => {t})} -
-
-
- -
-
- - {related.length > 0 && ( -
-

📚 相关文章

-
- {related.map((r) => ( - - {r.emoji} -

{r.title}

-

{r.excerpt}

- 阅读 → - - ))} -
-
- )} - -
-

接着探索

-
- 社区讨论 - 电子书 - 下一站 - 投稿 -
-
-
+
); } diff --git a/frontend/src/app/book/thanks/page.tsx b/frontend/src/app/book/thanks/page.tsx index a848f4c..504fbd6 100644 --- a/frontend/src/app/book/thanks/page.tsx +++ b/frontend/src/app/book/thanks/page.tsx @@ -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([]); 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 (
-

下载版

+

{b.title}

{status === "need-login" && (

- 请先 登录 查看下载。 + {b.needLogin}{" "} + {t.nav.login}

)} - {status === "checking" &&

正在确认订单…

} + {status === "checking" &&

{b.checking}

} {status === "pending" && (

{msg}

)} - {status === "ok" && files.length === 0 && ( + {hasOnlineOnly && (
-

购买已确认,下载文件暂未配置。

-

可先在线阅读,或联系支持获取 PDF/EPUB。

+

{b.confirmedNoFiles}

+

{b.confirmedHint}

- 继续在线阅读 + {b.continueRead} - 联系支持 + {b.contact}
)} - {status === "ok" && files.length > 0 && ( + {status === "ok" && downloads.length > 0 && (
-

购买成功,可下载:

+

{b.ready}

- 继续在线阅读 → + {b.continueRead} →

)} @@ -112,9 +126,10 @@ function BookThanksInner() { } export default function BookThanksPage() { + const { t } = useI18n(); return ( - 确认中…
}> + {t.bookThanks.checking}}> diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index f8487b7..ebbd279 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,14 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-09-04", + tag: "学院/博客/社区/电子书", + items: [ + "每日指南打通 7 天并中英导航;博客详情与电子书购买页中英文化", + "社区回复支持 Ctrl+Enter、字数与滚动定位;点赞失败可提示;假下载改为在线阅读引导", + ], + }, { date: "2026-09-04", tag: "全站闭环增强", diff --git a/frontend/src/app/digital/day/[id]/page.tsx b/frontend/src/app/digital/day/[id]/page.tsx index 2cdb36d..f3d4e41 100644 --- a/frontend/src/app/digital/day/[id]/page.tsx +++ b/frontend/src/app/digital/day/[id]/page.tsx @@ -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 { 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 ( -
-
- -
- 📅 DAY {id} -

{day.title}

-
- {day.body.split("\n").filter(Boolean).map((p, i) => ( -

{p}

- ))} -
-
- {prev && ( - - ← 上一篇 - - )} - {next && ( - - 下一篇 → - - )} - - 课程目录 - -
-
-
-
+
); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c293935..7766be4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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 { diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index a7a1858..ded9dd4 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -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 }, diff --git a/frontend/src/components/BlogDetailClient.tsx b/frontend/src/components/BlogDetailClient.tsx new file mode 100644 index 0000000..e9c7193 --- /dev/null +++ b/frontend/src/components/BlogDetailClient.tsx @@ -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 ( +
+ +
+
+ {post.emoji} +
+ 📅 {post.published_at} + ⏱️ {b.readTime.replace("{n}", String(post.read_time))} + ✍️ {post.author} +
+

{post.title}

+
+ {post.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ +
+
+ + {related.length > 0 && ( +
+

📚 {b.related}

+
+ {related.map((r) => ( + + {r.emoji} +

{r.title}

+

{r.excerpt}

+ {b.readMore} + + ))} +
+
+ )} + +
+

{b.explore}

+
+ + {t.nav.community} + + + {t.nav.ebook} + + + {t.nav.nextStop} + + + {b.submit} + +
+
+
+ ); +} diff --git a/frontend/src/components/DigitalDayClient.tsx b/frontend/src/components/DigitalDayClient.tsx new file mode 100644 index 0000000..d0d6599 --- /dev/null +++ b/frontend/src/components/DigitalDayClient.tsx @@ -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 ( +
+
+ +
+ 📅 DAY {id} +

{title}

+
+ {body + .split("\n") + .filter(Boolean) + .map((p, i) => ( +

{p}

+ ))} +
+
+ {prev && ( + + ← {t.digital.prev} + + )} + {next ? ( + + {t.digital.next} → + + ) : ( +
+ + {t.digital.backCatalog} + + + {t.nav.meetups} + +
+ )} + {next && ( + + {t.digital.backCourse} + + )} +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/DiscussionDetailClient.tsx b/frontend/src/components/DiscussionDetailClient.tsx index 9c7e3a6..a5702ac 100644 --- a/frontend/src/components/DiscussionDetailClient.tsx +++ b/frontend/src/components/DiscussionDetailClient.tsx @@ -32,6 +32,18 @@ export default function DiscussionDetailClient({ const [cityNames, setCityNames] = useState([]); const draftKey = `nomadro-reply-draft:${discussionId}`; + const catLabel = (value: string) => { + const map: Record = { + 全部: 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({
- {discussion.category} + {catLabel(discussion.category)}

{discussion.title}

{discussion.author_emoji} {discussion.author} · {discussion.created_at} @@ -176,13 +190,25 @@ export default function DiscussionDetailClient({