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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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: "全站闭环增强",
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
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 [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,13 +190,25 @@ 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}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submitReply()}>
|
<div className="community-compose-foot">
|
||||||
{loading ? t.community.replySending : t.community.replySend}
|
<span className="community-compose-count">
|
||||||
</button>
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<div className="community-reply-hint reveal">
|
<div className="community-reply-hint reveal">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user