's'
This commit is contained in:
129
app/[locale]/digital/about/AboutContent.tsx
Normal file
129
app/[locale]/digital/about/AboutContent.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "@/app/digital/i18n/navigation";
|
||||
import { getThemeConfig } from "@/config/digital";
|
||||
import SocialPlatformIcon from "../../../digital/components/SocialPlatformIcon";
|
||||
|
||||
export default function AboutContent() {
|
||||
const { t } = useTranslation("about");
|
||||
|
||||
useEffect(() => {
|
||||
window.history.scrollRestoration = "manual";
|
||||
window.scrollTo(0, 0);
|
||||
return () => {
|
||||
window.history.scrollRestoration = "auto";
|
||||
};
|
||||
}, []);
|
||||
const config = getThemeConfig();
|
||||
const linktreeUrl = config.linktreeUrl;
|
||||
const socialLinks = config.socialLinks ?? [];
|
||||
const teamMembers = config.teamMembers ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<section className="py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 团队成员 */}
|
||||
{teamMembers.length > 0 && (
|
||||
<div className="mt-16">
|
||||
<h2 className="mb-2 text-center text-2xl font-bold text-slate-900 dark:text-slate-100 sm:text-3xl">
|
||||
{t("teamTitle")}
|
||||
</h2>
|
||||
<p className="mb-10 text-center text-slate-600 dark:text-slate-400">
|
||||
{t("teamDesc")}
|
||||
</p>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{teamMembers.map((member) => {
|
||||
const content = (
|
||||
<div className="card-hover flex flex-col items-center rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="mb-4 flex h-20 w-20 items-center justify-center overflow-hidden rounded-full bg-gradient-to-br from-sky-100 to-cyan-50 text-4xl dark:from-sky-900/50 dark:to-cyan-900/50">
|
||||
{member.avatar ?? "👤"}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{member.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{member.role}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
if (member.link) {
|
||||
return (
|
||||
<a
|
||||
key={member.name}
|
||||
href={member.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <div key={member.name}>{content}</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 社交平台 Linktree */}
|
||||
{(linktreeUrl || socialLinks.length > 0) && (
|
||||
<div className="mt-16">
|
||||
<h2 className="mb-2 text-center text-2xl font-bold text-slate-900 dark:text-slate-100 sm:text-3xl">
|
||||
{t("linktreeTitle")}
|
||||
</h2>
|
||||
<p className="mb-10 text-center text-slate-600 dark:text-slate-400">
|
||||
{t("linktreeDesc")}
|
||||
</p>
|
||||
{(["domestic", "overseas"] as const).map((group) => {
|
||||
const items = socialLinks.filter((l) => l.group === group);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group} className="mb-10 last:mb-0">
|
||||
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
||||
{group === "domestic" ? t("groupDomestic") : t("groupOverseas")}
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.platform}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="card-hover group flex items-center gap-4 rounded-2xl border border-slate-100 bg-white p-5 shadow-sm transition-all dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<SocialPlatformIcon platform={item.platform} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold text-slate-900 dark:text-slate-100">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t("linktreeCta")}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-slate-400 transition-transform group-hover:translate-x-1">
|
||||
↗
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
app/[locale]/digital/about/page.tsx
Normal file
20
app/[locale]/digital/about/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import Header from "../../../digital/components/Header";
|
||||
import Footer from "../../../digital/components/Footer";
|
||||
import AboutContent from "./AboutContent";
|
||||
export const metadata: Metadata = {
|
||||
title: "关于我们 | About Us",
|
||||
description: "认识数字游民指南背后的团队,关注我们的社交平台。",
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<AboutContent />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
app/[locale]/digital/app/AppDownloadContent.tsx
Normal file
70
app/[locale]/digital/app/AppDownloadContent.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "@/app/digital/i18n/navigation";
|
||||
|
||||
export default function AppDownloadContent() {
|
||||
const { t } = useTranslation("appDownload");
|
||||
|
||||
useEffect(() => {
|
||||
window.history.scrollRestoration = "manual";
|
||||
window.scrollTo(0, 0);
|
||||
return () => {
|
||||
window.history.scrollRestoration = "auto";
|
||||
};
|
||||
}, []);
|
||||
|
||||
const features = [
|
||||
{ key: "resources", icon: "📚" },
|
||||
{ key: "community", icon: "👥" },
|
||||
{ key: "tools", icon: "🛠️" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<section className="py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-6 inline-flex h-24 w-24 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-5xl shadow-lg shadow-sky-200/50 dark:shadow-sky-900/30">
|
||||
📱
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid gap-6 sm:grid-cols-3">
|
||||
{features.map(({ key, icon }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="card-hover flex flex-col items-center rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-slate-50 text-2xl dark:bg-slate-800">
|
||||
{icon}
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{t(`features.${key}.title`)}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t(`features.${key}.desc`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-100 px-4 py-2 text-sm font-medium text-amber-800 dark:bg-amber-900/50 dark:text-amber-200">
|
||||
{t("comingSoon")}
|
||||
</div>
|
||||
<p className="mt-4 text-slate-500 dark:text-slate-400">
|
||||
{t("comingSoonDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/[locale]/digital/app/page.tsx
Normal file
21
app/[locale]/digital/app/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Header from "../../../digital/components/Header";
|
||||
import Footer from "../../../digital/components/Footer";
|
||||
import AppDownloadContent from "./AppDownloadContent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "App 下载 | App Download",
|
||||
description: "数字游民指南移动端应用,随时随地获取资源与社区动态。",
|
||||
};
|
||||
|
||||
export default function AppDownloadPage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<AppDownloadContent />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/atom-one-dark.min.css";
|
||||
import styles from "./lesson-markdown.module.css";
|
||||
|
||||
type TocItem = { level: number; text: string; id: string };
|
||||
|
||||
function extractToc(content: string): TocItem[] {
|
||||
const items: TocItem[] = [];
|
||||
const lines = content.split("\n");
|
||||
let idCounter = 0;
|
||||
for (const line of lines) {
|
||||
const h1 = line.match(/^# (.+)$/);
|
||||
const h2 = line.match(/^## (.+)$/);
|
||||
if (h1) {
|
||||
items.push({ level: 1, text: h1[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
|
||||
} else if (h2) {
|
||||
items.push({ level: 2, text: h2[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function LessonMarkdown({ content }: { content: string }) {
|
||||
const [readProgress, setReadProgress] = useState(0);
|
||||
const [showBackTop, setShowBackTop] = useState(false);
|
||||
const [showToc, setShowToc] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const toc = extractToc(content);
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
const onScroll = () => {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
setReadProgress(docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0);
|
||||
setShowBackTop(scrollTop > 400);
|
||||
ticking = false;
|
||||
});
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const handleTocJump = useCallback((index: number) => {
|
||||
setShowToc(false);
|
||||
const root = contentRef.current;
|
||||
if (!root) return;
|
||||
const headings = root.querySelectorAll("h1, h2");
|
||||
const el = headings[index] as HTMLElement;
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.readingProgress} style={{ width: `${readProgress}%` }} />
|
||||
|
||||
<div ref={contentRef} className={`${styles.markdownBody} markdown-body`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true }]]}
|
||||
components={{
|
||||
h1: ({ children }) => {
|
||||
const text = String(children);
|
||||
const id = slugify(text);
|
||||
return (
|
||||
<h1 id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2: ({ children }) => {
|
||||
const text = String(children);
|
||||
const id = slugify(text);
|
||||
return (
|
||||
<h2 id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: ({ children }) => <h3 className="scroll-mt-20">{children}</h3>,
|
||||
h4: ({ children }) => <h4>{children}</h4>,
|
||||
h5: ({ children }) => <h5>{children}</h5>,
|
||||
h6: ({ children }) => <h6>{children}</h6>,
|
||||
p: ({ children }) => <p>{children}</p>,
|
||||
ul: ({ children }) => <ul>{children}</ul>,
|
||||
ol: ({ children }) => <ol>{children}</ol>,
|
||||
li: ({ children, className }) => (
|
||||
<li className={className?.includes("task-list-item") ? "flex items-start gap-2" : ""}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className={styles.codeBlock}>{children}</pre>
|
||||
),
|
||||
code: ({ className, children }) => (
|
||||
<code className={className}>{children}</code>
|
||||
),
|
||||
blockquote: ({ children }) => <blockquote>{children}</blockquote>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src || ""} alt={alt || ""} loading="lazy" decoding="async" />
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th>{children}</th>,
|
||||
td: ({ children }) => <td>{children}</td>,
|
||||
hr: () => <hr />,
|
||||
strong: ({ children }) => <strong>{children}</strong>,
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${styles.drawerMask} ${showToc ? styles.drawerMaskOpen : ""}`}
|
||||
onClick={() => setShowToc(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside className={`${styles.drawer} ${showToc ? styles.drawerOpen : ""}`}>
|
||||
<div className={styles.drawerTitle}>目录</div>
|
||||
{toc.map((item, i) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={item.level === 2 ? `${styles.drawerItem} ${styles.drawerItemH2}` : styles.drawerItem}
|
||||
onClick={() => handleTocJump(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleTocJump(i);
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{showBackTop && (
|
||||
<>
|
||||
{toc.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tocButton}
|
||||
onClick={() => setShowToc(true)}
|
||||
aria-label="打开目录"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backToTop}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/* 参考 nomadlms,适配日间/夜间主题 */
|
||||
|
||||
.readingProgress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, #fbbf24 100%);
|
||||
z-index: 100;
|
||||
transition: width 0.15s linear;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.backToTop {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
color: var(--accent);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.backToTop:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.tocButton {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 84px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
color: var(--accent);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.tocButton:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.drawerMask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 140;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.drawerMaskOpen {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
background: var(--background);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
z-index: 150;
|
||||
padding: 80px 0 24px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawerOpen {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawerTitle {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.drawerItem {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.drawerItem:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.drawerItemH2 {
|
||||
padding-left: 28px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
font-size: 16px;
|
||||
line-height: 1.85;
|
||||
color: var(--foreground);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdownBody h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--foreground);
|
||||
margin: 28px 0 12px;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.markdownBody h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdownBody h1::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), #fbbf24);
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
margin: 24px 0 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
|
||||
.markdownBody h4,
|
||||
.markdownBody h5,
|
||||
.markdownBody h6 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
margin: 16px 0 6px;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.markdownBody blockquote {
|
||||
position: relative;
|
||||
margin: 24px 0;
|
||||
padding: 16px 20px 16px 24px;
|
||||
border-left: none;
|
||||
background: var(--muted);
|
||||
border-radius: 0 12px 12px 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.markdownBody blockquote::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.markdownBody pre.codeBlock {
|
||||
margin: 24px 0;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdownBody pre.codeBlock code {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdownBody p code,
|
||||
.markdownBody li code {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.2em 0.4em;
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdownBody a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdownBody a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdownBody img {
|
||||
display: block;
|
||||
margin: 28px auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.markdownBody ul,
|
||||
.markdownBody ol {
|
||||
margin: 16px 0;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.markdownBody li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.markdownBody li > ul,
|
||||
.markdownBody li > ol {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdownBody table {
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
border-collapse: collapse;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdownBody th {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdownBody td {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdownBody tr:nth-child(even) {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.markdownBody hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 28px 0;
|
||||
}
|
||||
|
||||
.markdownBody strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdownBody input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (min-width: 768px) {
|
||||
.markdownBody {
|
||||
font-size: 17px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.markdownBody h1 {
|
||||
font-size: 1.75rem;
|
||||
margin: 32px 0 14px;
|
||||
}
|
||||
|
||||
.markdownBody h1::after {
|
||||
width: 50px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 1.35rem;
|
||||
margin: 28px 0 12px;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 1.2rem;
|
||||
margin: 24px 0 10px;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.backToTop,
|
||||
.tocButton {
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.tocButton {
|
||||
right: 92px;
|
||||
}
|
||||
}
|
||||
237
app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
237
app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
|
||||
import { VideoPlayer } from "@/app/digital/components/course/VideoPlayer";
|
||||
import { LessonMarkdown } from "./LessonMarkdown";
|
||||
import { getLesson } from "@/config/digital/lessons";
|
||||
import { canWatchLesson, isFreeTrial } from "@/app/digital/lib/course-payment";
|
||||
import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
|
||||
import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
|
||||
import { setLastWatched, markCompleted } from "@/app/digital/lib/learning";
|
||||
import { DOWNLOAD_CONFIG } from "@/config/digital/course";
|
||||
import Header from "@/app/digital/components/Header";
|
||||
import AuthModal from "@/app/digital/components/AuthModal";
|
||||
import {
|
||||
getPayEnv,
|
||||
redirectToPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/digital/lib/payment";
|
||||
export default function LessonPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("community");
|
||||
const { user, vip, refetch } = useAuthAndVip();
|
||||
const [toast, setToast] = useState(false);
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [payRedirecting, setPayRedirecting] = useState(false);
|
||||
const moduleIndex = Number(params.moduleIndex);
|
||||
const lessonIndex = Number(params.lessonIndex);
|
||||
|
||||
const lesson = getLesson(moduleIndex, lessonIndex);
|
||||
const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip);
|
||||
|
||||
useEffect(() => {
|
||||
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
|
||||
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
|
||||
|
||||
const startPay = (userId: string) => {
|
||||
const env = getPayEnv();
|
||||
const channel = "wxpay";
|
||||
const device = getDeviceFromEnv(env);
|
||||
const returnUrl = `${window.location.origin}/`;
|
||||
|
||||
redirectToPay({
|
||||
user_id: userId,
|
||||
return_url: returnUrl,
|
||||
channel,
|
||||
device,
|
||||
type: "video",
|
||||
useJoinConfig: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (payRedirecting) return;
|
||||
setPayRedirecting(true);
|
||||
|
||||
try {
|
||||
// 先验证登录态
|
||||
let meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
|
||||
let meData = await meRes.json().catch(() => ({}));
|
||||
if (meData?.user?.id) {
|
||||
// 已登录:先校验 VIP,已是 VIP 则无需支付
|
||||
const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
|
||||
const vipData = await vipRes.json().catch(() => ({}));
|
||||
if (vipData?.vip) {
|
||||
await refetch();
|
||||
return;
|
||||
}
|
||||
startPay(meData.user.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedUser = getStoredUser();
|
||||
const storedToken = getStoredToken();
|
||||
if (storedUser?.id && storedToken) {
|
||||
await fetch("/api/digital/auth/sync-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: storedToken, record: storedUser }),
|
||||
credentials: "include",
|
||||
}).catch(() => null);
|
||||
meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
|
||||
meData = await meRes.json().catch(() => ({}));
|
||||
if (meData?.user?.id) {
|
||||
const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
|
||||
const vipData = await vipRes.json().catch(() => ({}));
|
||||
if (vipData?.vip) {
|
||||
await refetch();
|
||||
return;
|
||||
}
|
||||
startPay(meData.user.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setAuthOpen(true);
|
||||
} finally {
|
||||
setPayRedirecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header />
|
||||
<main className="pt-20 pb-16">
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold">章节不存在</h1>
|
||||
<p className="mb-6 text-[var(--muted-foreground)]">请检查链接或返回课程首页</p>
|
||||
<Link
|
||||
href="/course"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main className="pt-14 pb-16 md:pt-16">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6">
|
||||
<nav className="mb-6 flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回
|
||||
</button>
|
||||
<span className="text-[var(--muted-foreground)]">/</span>
|
||||
<Link href="/course" className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
|
||||
课程首页
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{lesson.name}
|
||||
{isFreeTrial(moduleIndex, lessonIndex) && (
|
||||
<span className="rounded bg-[var(--accent)]/20 px-2 py-0.5 text-sm font-normal text-[var(--accent)]">
|
||||
试看
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="mb-8 text-sm text-[var(--muted-foreground)]">时长 {lesson.duration}</p>
|
||||
|
||||
<div className="relative mb-12">
|
||||
{unlocked ? (
|
||||
<VideoPlayer
|
||||
src={lesson.videoUrl}
|
||||
title={lesson.name}
|
||||
videoId={`course-${moduleIndex}-${lessonIndex}`}
|
||||
className="shadow-xl"
|
||||
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative overflow-hidden rounded-xl bg-black md:rounded-2xl">
|
||||
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
|
||||
<span className="text-6xl">🔒</span>
|
||||
<p className="text-lg text-white/90">报名解锁本章节</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnroll}
|
||||
disabled={payRedirecting}
|
||||
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-70"
|
||||
>
|
||||
{payRedirecting ? "跳转支付中..." : "立即报名"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unlocked && (
|
||||
<div className="relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
|
||||
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold">
|
||||
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
配套文档
|
||||
</h2>
|
||||
<LessonMarkdown content={lesson.markdown} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{DOWNLOAD_CONFIG.enabled && unlocked && (
|
||||
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{DOWNLOAD_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--muted-foreground)]">课程配套资料已上传至网盘,报名后可前往下载</p>
|
||||
<a
|
||||
href={DOWNLOAD_CONFIG.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
{DOWNLOAD_CONFIG.buttonText}
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{toast && (
|
||||
<div
|
||||
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
|
||||
role="alert"
|
||||
>
|
||||
{t("updating")}
|
||||
</div>
|
||||
)}
|
||||
<AuthModal
|
||||
isOpen={authOpen}
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onSuccess={() => {
|
||||
setAuthOpen(false);
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
app/[locale]/digital/course/page.tsx
Normal file
31
app/[locale]/digital/course/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import Header from "@/app/digital/components/Header";
|
||||
import Footer from "@/app/digital/components/Footer";
|
||||
import {
|
||||
CourseHero,
|
||||
AudienceSection,
|
||||
BenefitsSection,
|
||||
CurriculumSection,
|
||||
InstructorsSection,
|
||||
FAQSection,
|
||||
CTASection,
|
||||
} from "@/app/digital/components/course";
|
||||
|
||||
export default function CoursePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main>
|
||||
<CourseHero />
|
||||
<AudienceSection />
|
||||
<BenefitsSection />
|
||||
<CurriculumSection />
|
||||
<InstructorsSection />
|
||||
<FAQSection />
|
||||
<CTASection />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
app/[locale]/digital/day/[id]/page.tsx
Normal file
127
app/[locale]/digital/day/[id]/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Link } from "@/app/digital/i18n/navigation";
|
||||
import { getDayData, daysData } from "../../../../digital/data/days";
|
||||
import DayContent from "../../../../digital/components/DayContent";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return daysData.map((d) => ({ id: String(d.day) }));
|
||||
}
|
||||
|
||||
export default async function DayPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const dayId = parseInt(id, 10);
|
||||
const data = getDayData(dayId);
|
||||
|
||||
if (!data) return notFound();
|
||||
|
||||
const prevDay = getDayData(dayId - 1);
|
||||
const nextDay = getDayData(dayId + 1);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Top bar */}
|
||||
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/80 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-4 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors hover:text-sky-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回首页
|
||||
</Link>
|
||||
<span className="text-sm text-slate-400">
|
||||
Day {data.day} / 7
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Article header */}
|
||||
<div className="mx-auto max-w-4xl px-4 pt-10 pb-6 sm:px-6 sm:pt-16 sm:pb-10">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3 text-sm text-slate-500">
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 font-medium">
|
||||
阅读时间{data.readingTime}
|
||||
</span>
|
||||
<span className="rounded-full bg-sky-50 px-3 py-1 font-medium text-sky-600">
|
||||
第 {data.day} 天
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-500 to-cyan-500 text-3xl text-white shadow-lg shadow-sky-500/20 sm:h-20 sm:w-20 sm:text-4xl">
|
||||
{data.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl">
|
||||
{data.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6">
|
||||
<hr className="border-slate-100" />
|
||||
</div>
|
||||
|
||||
{/* Markdown body */}
|
||||
<article className="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<DayContent content={data.content} />
|
||||
</article>
|
||||
|
||||
{/* Prev / Next navigation */}
|
||||
<nav className="mx-auto max-w-4xl px-4 pb-20 sm:px-6">
|
||||
<div className="flex flex-col gap-4 border-t border-slate-100 pt-8 sm:flex-row sm:justify-between">
|
||||
{prevDay ? (
|
||||
<Link
|
||||
href={`/day/${prevDay.day}`}
|
||||
className="group flex items-center gap-3 rounded-xl border border-slate-100 px-5 py-4 transition-all hover:border-sky-200 hover:bg-sky-50/50 sm:flex-1"
|
||||
>
|
||||
<svg className="h-5 w-5 shrink-0 text-slate-400 transition-colors group-hover:text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-xs text-slate-400">上一天</div>
|
||||
<div className="text-sm font-semibold text-slate-700 group-hover:text-sky-700">
|
||||
Day {prevDay.day}: {prevDay.title}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="sm:flex-1" />
|
||||
)}
|
||||
{nextDay ? (
|
||||
<Link
|
||||
href={`/day/${nextDay.day}`}
|
||||
className="group flex items-center justify-end gap-3 rounded-xl border border-slate-100 px-5 py-4 text-right transition-all hover:border-sky-200 hover:bg-sky-50/50 sm:flex-1"
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs text-slate-400">下一天</div>
|
||||
<div className="text-sm font-semibold text-slate-700 group-hover:text-sky-700">
|
||||
Day {nextDay.day}: {nextDay.title}
|
||||
</div>
|
||||
</div>
|
||||
<svg className="h-5 w-5 shrink-0 text-slate-400 transition-colors group-hover:text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="sm:flex-1" />
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-100 bg-slate-50 py-8 text-center text-sm text-slate-400">
|
||||
<Link href="/" className="font-medium text-slate-600 transition-colors hover:text-sky-600">
|
||||
🌍 数字游民指南
|
||||
</Link>
|
||||
<span className="mx-2">·</span>
|
||||
精选内容 · 持续更新
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
app/[locale]/digital/ebook/EbookClient.tsx
Normal file
222
app/[locale]/digital/ebook/EbookClient.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Link } from "@/app/digital/i18n/navigation";
|
||||
import type { EbookData } from "@/app/digital/lib/ebook";
|
||||
import "github-markdown-css/github-markdown.css";
|
||||
import styles from "./ebook.module.css";
|
||||
|
||||
function LazyChunk({
|
||||
html,
|
||||
estimatedHeight,
|
||||
forceVisible,
|
||||
}: {
|
||||
html: string;
|
||||
estimatedHeight: number;
|
||||
forceVisible: boolean;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(forceVisible);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const show = forceVisible || visible;
|
||||
|
||||
useEffect(() => {
|
||||
if (forceVisible || visible) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "1200px 0px" }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [forceVisible, visible]);
|
||||
|
||||
if (show) {
|
||||
return (
|
||||
<div
|
||||
className={styles.chunkEnter}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.chunkPlaceholder}
|
||||
style={{ minHeight: estimatedHeight }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EbookClient({ data }: { data: EbookData }) {
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [readProgress, setReadProgress] = useState(0);
|
||||
const [showBackTop, setShowBackTop] = useState(false);
|
||||
const [forceShowAll, setForceShowAll] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { chunks, headers } = data;
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
const onScroll = () => {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
const scrollTop =
|
||||
window.scrollY || document.documentElement.scrollTop;
|
||||
const docHeight =
|
||||
document.documentElement.scrollHeight - window.innerHeight;
|
||||
setReadProgress(
|
||||
docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0
|
||||
);
|
||||
setShowBackTop(scrollTop > 600);
|
||||
ticking = false;
|
||||
});
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const handleJump = useCallback(
|
||||
(index: number) => {
|
||||
setForceShowAll(true);
|
||||
setShowDrawer(false);
|
||||
|
||||
const run = () => {
|
||||
const root = contentRef.current;
|
||||
if (!root) return;
|
||||
const h1s = root.querySelectorAll("h1");
|
||||
const h2s = root.querySelectorAll("h2");
|
||||
const targets = h1s.length > 0 ? h1s : h2s;
|
||||
const el = targets[index] as HTMLElement;
|
||||
if (el) {
|
||||
try {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} catch {
|
||||
const top =
|
||||
el.getBoundingClientRect().top +
|
||||
(window.pageYOffset || document.documentElement.scrollTop) -
|
||||
60;
|
||||
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
setTimeout(run, 100);
|
||||
setTimeout(run, 300);
|
||||
setTimeout(run, 600);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={styles.readingProgress}
|
||||
style={{ width: `${readProgress}%` }}
|
||||
/>
|
||||
|
||||
<div className={styles.headerSpacer} />
|
||||
<header className={styles.header}>
|
||||
<Link href="/" className={styles.headerLeft} aria-label="返回首页">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div className={styles.headerMiddle}>数字游民</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.headerRight} ${showDrawer ? styles.headerRightActive : ""}`}
|
||||
onClick={() => setShowDrawer(!showDrawer)}
|
||||
aria-label="目录"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline sm:ml-1">目录</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={styles.book}>
|
||||
<div ref={contentRef} className={`markdown-body ${styles.markdownBody}`}>
|
||||
{chunks.map((chunk, i) => (
|
||||
<LazyChunk
|
||||
key={i}
|
||||
html={chunk.html}
|
||||
estimatedHeight={chunk.estimatedHeight}
|
||||
forceVisible={forceShowAll || i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 抽屉目录 */}
|
||||
<div
|
||||
className={`${styles.drawerMask} ${showDrawer ? styles.drawerMaskOpen : ""}`}
|
||||
onClick={() => setShowDrawer(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside
|
||||
className={`${styles.drawer} ${showDrawer ? styles.drawerOpen : ""}`}
|
||||
>
|
||||
{headers.map((title, index) => (
|
||||
<div
|
||||
key={index}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.drawerItem}
|
||||
onClick={() => handleJump(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleJump(index);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{showBackTop && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backToTop}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
493
app/[locale]/digital/ebook/ebook.module.css
Normal file
493
app/[locale]/digital/ebook/ebook.module.css
Normal file
@@ -0,0 +1,493 @@
|
||||
/* 复刻 nomadbook 电子书样式 */
|
||||
|
||||
.readingProgress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
|
||||
z-index: 201;
|
||||
transition: width 0.15s linear;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.headerSpacer {
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
height: 88px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1a1a2e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.headerMiddle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4facfe;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.headerRight:hover {
|
||||
background: #dceeff;
|
||||
}
|
||||
|
||||
.headerRightActive {
|
||||
background: #4facfe !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.chunkEnter {
|
||||
animation: chunkAppear 0.5s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes chunkAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chunkPlaceholder {
|
||||
background: linear-gradient(90deg, #f6f7f8 25%, #edeef1 37%, #f6f7f8 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.8s ease infinite;
|
||||
border-radius: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 5px solid #f0f0f0;
|
||||
border-top-color: #4facfe;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.backToTop {
|
||||
position: fixed;
|
||||
bottom: 120px;
|
||||
right: 30px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
animation: fadeInUp 0.3s ease;
|
||||
color: #4facfe;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.backToTop:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.book {
|
||||
padding: 30px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
font-size: 16px !important;
|
||||
line-height: 1.85 !important;
|
||||
color: #2c3e50 !important;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdownBody h1 {
|
||||
font-size: 24px !important;
|
||||
font-weight: 800;
|
||||
color: #1a1a2e;
|
||||
margin: 28px 0 10px;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.markdownBody h1::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #4facfe, #00f2fe);
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 21px !important;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
margin: 22px 0 9px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 18px !important;
|
||||
font-weight: 600;
|
||||
color: #34495e;
|
||||
margin: 18px 0 7px;
|
||||
}
|
||||
|
||||
.markdownBody h4,
|
||||
.markdownBody h5,
|
||||
.markdownBody h6 {
|
||||
font-size: 16px !important;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
font-size: 16px !important;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.markdownBody blockquote {
|
||||
position: relative;
|
||||
margin: 28px 0;
|
||||
padding: 20px 24px 20px 30px;
|
||||
border-left: none;
|
||||
background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
|
||||
border-radius: 0 12px 12px 0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.markdownBody blockquote::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
background: linear-gradient(180deg, #4facfe, #00f2fe);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.markdownBody pre {
|
||||
position: relative;
|
||||
margin: 28px 0;
|
||||
padding: 28px 24px 24px;
|
||||
background: #1e1e2e;
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdownBody pre::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #4facfe, #00f2fe);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.markdownBody pre code {
|
||||
font-family: "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6;
|
||||
color: #e2e8f0;
|
||||
background: none !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdownBody code {
|
||||
font-family: "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 14px !important;
|
||||
padding: 2px 6px;
|
||||
background: #f1f5f9;
|
||||
color: #e53e3e;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdownBody a {
|
||||
color: #4facfe;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdownBody a:hover {
|
||||
color: #2b8de3;
|
||||
border-bottom: 1px solid #4facfe;
|
||||
}
|
||||
|
||||
.markdownBody img {
|
||||
display: block;
|
||||
margin: 28px auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.markdownBody ul,
|
||||
.markdownBody ol {
|
||||
margin: 16px 0;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.markdownBody table {
|
||||
width: 100%;
|
||||
margin: 28px 0;
|
||||
border-collapse: collapse;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.markdownBody th {
|
||||
background: linear-gradient(135deg, #f8fafc, #edf2f7);
|
||||
font-weight: 600;
|
||||
padding: 16px 20px;
|
||||
text-align: left;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.markdownBody td {
|
||||
padding: 14px 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.markdownBody tr:nth-child(even) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* 抽屉目录 */
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
background: #fff;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.1);
|
||||
z-index: 150;
|
||||
padding-top: 88px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawerOpen {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawerMask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 140;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.drawerMaskOpen {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.drawerItem {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
color: #2c3e50;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.drawerItem:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
/* PC 适配 */
|
||||
@media (min-width: 768px) {
|
||||
.headerSpacer {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.headerMiddle {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
width: auto;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
margin-right: 16px;
|
||||
gap: 5px;
|
||||
border-radius: 17px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.book {
|
||||
padding: 30px 60px;
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
font-size: 16px !important;
|
||||
line-height: 1.8 !important;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.markdownBody h1 {
|
||||
font-size: 28px !important;
|
||||
margin: 36px 0 14px;
|
||||
}
|
||||
|
||||
.markdownBody h1::after {
|
||||
width: 50px;
|
||||
height: 3px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 23px !important;
|
||||
margin: 28px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 19px !important;
|
||||
margin: 22px 0 10px;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
font-size: 16px !important;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.markdownBody pre code {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.markdownBody code {
|
||||
font-size: 14px !important;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.backToTop {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
bottom: 40px;
|
||||
right: 40px;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
padding-top: 56px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.drawerItem {
|
||||
font-size: 14px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.book {
|
||||
padding: 30px 80px;
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
width: 720px;
|
||||
font-size: 17px !important;
|
||||
line-height: 1.85 !important;
|
||||
}
|
||||
|
||||
.markdownBody h1 {
|
||||
font-size: 30px !important;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 25px !important;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.markdownBody img.loaded:hover {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
17
app/[locale]/digital/ebook/page.tsx
Normal file
17
app/[locale]/digital/ebook/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { parseEbookMarkdown } from "../../../digital/lib/ebook";
|
||||
import EbookClient from "./EbookClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "电子书 | 数字游民",
|
||||
description: "数字游民:地理套利与自动化杠杆,低成本工作室、云手机、无人直播、出海掘金。",
|
||||
};
|
||||
|
||||
export default function EbookPage() {
|
||||
const mdPath = path.join(process.cwd(), "app/digital/data/ebook.md");
|
||||
const source = readFileSync(mdPath, "utf-8");
|
||||
const data = parseEbookMarkdown(source);
|
||||
|
||||
return <EbookClient data={data} />;
|
||||
}
|
||||
291
app/[locale]/digital/jobs/JobsContent.tsx
Normal file
291
app/[locale]/digital/jobs/JobsContent.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, type FormEvent, type ChangeEvent } from "react";
|
||||
import { Link, useLocale, useTranslation } from "@/app/digital/i18n/navigation";
|
||||
|
||||
const positionOptions = [
|
||||
{ value: "content", labelZh: "内容运营", labelEn: "Content Operations" },
|
||||
{ value: "dev", labelZh: "前端/全栈开发", labelEn: "Frontend / Full-stack Dev" },
|
||||
{ value: "design", labelZh: "UI/UX 设计", labelEn: "UI/UX Design" },
|
||||
{ value: "marketing", labelZh: "市场推广", labelEn: "Marketing" },
|
||||
{ value: "other", labelZh: "其他", labelEn: "Other" },
|
||||
];
|
||||
|
||||
interface FormData {
|
||||
position: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
intro: string;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex items-center gap-3 py-3.5 sm:flex-col sm:items-stretch sm:gap-0 sm:py-4 border-b border-slate-100 sm:border-b-0">
|
||||
<label className="w-[90px] shrink-0 text-[15px] font-bold text-slate-700 sm:mb-2 sm:w-auto sm:text-[17px]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDown() {
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 text-slate-400 sm:right-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobsContent() {
|
||||
const { t } = useTranslation("jobs");
|
||||
const locale = useLocale();
|
||||
const [form, setForm] = useState<FormData>({
|
||||
position: "",
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
intro: "",
|
||||
});
|
||||
const [resume, setResume] = useState<File | null>(null);
|
||||
const [resumeError, setResumeError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const set = (key: keyof FormData, value: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setResumeError(null);
|
||||
if (!resume) {
|
||||
setResumeError(locale === "zh" ? "请上传简历(PDF 或 Word)" : "Please upload your resume (PDF or Word)");
|
||||
return;
|
||||
}
|
||||
// 暂不提交,无业务逻辑
|
||||
if (locale === "zh") {
|
||||
alert("表单已填写完成,提交功能即将上线。");
|
||||
} else {
|
||||
alert("Form completed. Submit feature coming soon.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
const allowed = ["pdf", "doc", "docx"];
|
||||
if (!ext || !allowed.includes(ext)) {
|
||||
setResumeError(locale === "zh" ? "请上传 PDF、DOC 或 DOCX 格式" : "Please upload PDF, DOC or DOCX");
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setResumeError(locale === "zh" ? "文件大小不能超过 10MB" : "File size must be under 10MB");
|
||||
return;
|
||||
}
|
||||
setResumeError(null);
|
||||
setResume(file);
|
||||
};
|
||||
|
||||
const removeResume = () => {
|
||||
setResume(null);
|
||||
setResumeError(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const getLabel = (opt: (typeof positionOptions)[0]) =>
|
||||
locale === "zh" ? opt.labelZh : opt.labelEn;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f0f4f8]">
|
||||
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">
|
||||
{/* Banner */}
|
||||
<div className="overflow-hidden bg-white shadow-sm sm:rounded-t-2xl">
|
||||
<div className="relative h-40 overflow-hidden bg-gradient-to-br from-amber-400 via-orange-500 to-rose-500 sm:h-52">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-white">
|
||||
<div className="text-6xl sm:text-7xl">💼</div>
|
||||
<div className="mt-2 text-sm font-medium opacity-90">
|
||||
{locale === "zh" ? "加入我们" : "Join Us"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-10 -left-10 h-40 w-40 rounded-full bg-white/10 blur-2xl" />
|
||||
<div className="absolute -right-10 -bottom-10 h-48 w-48 rounded-full bg-white/10 blur-2xl" />
|
||||
</div>
|
||||
<div className="px-5 py-5 text-center sm:px-8 sm:py-7">
|
||||
<h1 className="text-xl font-bold text-slate-800 sm:text-2xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-500 sm:text-base">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mt-0 bg-white px-5 pb-8 pt-2 shadow-sm sm:mt-5 sm:rounded-2xl sm:px-8 sm:py-8 sm:shadow-md"
|
||||
>
|
||||
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||
<Field label={t("position")}>
|
||||
<select
|
||||
value={form.position}
|
||||
onChange={(e) => set("position", e.target.value)}
|
||||
required
|
||||
className={`form-input appearance-none ${
|
||||
form.position ? "text-slate-800" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{locale === "zh" ? "请选择职位" : "Select position"}
|
||||
</option>
|
||||
{positionOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{getLabel(opt)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown />
|
||||
</Field>
|
||||
|
||||
<Field label={t("name")}>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={80}
|
||||
placeholder={locale === "zh" ? "您的姓名" : "Your name"}
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||
<Field label={t("email")}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={form.email}
|
||||
onChange={(e) => set("email", e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("phone")}>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder={locale === "zh" ? "手机号(选填)" : "Phone (optional)"}
|
||||
value={form.phone}
|
||||
onChange={(e) => set("phone", e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-slate-100 py-4 sm:border-b-0 sm:py-5">
|
||||
<label className="mb-2 block text-base font-bold text-slate-700 sm:text-[17px]">
|
||||
{t("intro")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
maxLength={800}
|
||||
rows={5}
|
||||
placeholder={t("introPlaceholder")}
|
||||
value={form.intro}
|
||||
onChange={(e) => set("intro", e.target.value)}
|
||||
className="form-input min-h-[120px] resize-none sm:min-h-[140px]"
|
||||
/>
|
||||
<span className="absolute right-3 bottom-3 text-xs text-slate-300">
|
||||
{form.intro.length}/800
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resume upload (required) */}
|
||||
<div className="py-4 sm:py-5">
|
||||
<label className="mb-1 block text-[15px] font-bold text-slate-700 sm:text-[17px]">
|
||||
📄 {t("resume")} <span className="ml-1 text-red-500">*</span>
|
||||
</label>
|
||||
<p className="mb-3 text-xs text-slate-500">
|
||||
{t("resumeHint")}
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{resumeError && (
|
||||
<p className="mb-2 text-sm text-red-500">{resumeError}</p>
|
||||
)}
|
||||
|
||||
{resume ? (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<span className="text-lg">📎</span>
|
||||
<span className="flex-1 truncate text-sm font-medium text-slate-700">
|
||||
{resume.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
({(resume.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeResume}
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-600"
|
||||
aria-label="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 py-8 text-slate-400 transition-colors hover:border-amber-300 hover:text-amber-600"
|
||||
>
|
||||
<svg className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span className="text-sm">{t("resumeUpload")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mt-10">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-full bg-amber-500 px-8 py-4 text-base font-semibold text-white transition-colors hover:bg-amber-600"
|
||||
>
|
||||
{t("submit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-medium text-slate-500 transition-colors hover:text-slate-700"
|
||||
>
|
||||
← {locale === "zh" ? "返回首页" : "Back to Home"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/[locale]/digital/jobs/page.tsx
Normal file
21
app/[locale]/digital/jobs/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Header from "../../../digital/components/Header";
|
||||
import Footer from "../../../digital/components/Footer";
|
||||
import JobsContent from "./JobsContent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "招聘 | Careers",
|
||||
description: "加入数字游民指南团队,我们正在招聘。",
|
||||
};
|
||||
|
||||
export default function JobsPage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<JobsContent />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
557
app/[locale]/digital/join/page.tsx
Normal file
557
app/[locale]/digital/join/page.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, type FormEvent, type ChangeEvent } from "react";
|
||||
import { Link } from "@/app/digital/i18n/navigation";
|
||||
import {
|
||||
getPayEnv,
|
||||
redirectToPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/digital/lib/payment";
|
||||
import type { PayEnv } from "@/app/digital/lib/payment";
|
||||
import { getStoredToken } from "@/app/digital/lib/pocketbase";
|
||||
import AuthModal from "@/app/digital/components/AuthModal";
|
||||
|
||||
const genderOptions = ["男", "女"];
|
||||
const singleOptions = ["单身", "恋爱中", "已婚"];
|
||||
const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"];
|
||||
const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i));
|
||||
|
||||
interface FormData {
|
||||
nickname: string;
|
||||
gender: string;
|
||||
single: string;
|
||||
profession: string;
|
||||
intro: string;
|
||||
wechat: string;
|
||||
education: string;
|
||||
graduationYear: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface MediaInfo {
|
||||
preview: string;
|
||||
url: string;
|
||||
type: "image" | "video";
|
||||
}
|
||||
|
||||
export default function JoinPage() {
|
||||
const [form, setForm] = useState<FormData>({
|
||||
nickname: "",
|
||||
gender: "",
|
||||
single: "",
|
||||
profession: "",
|
||||
intro: "",
|
||||
wechat: "",
|
||||
education: "",
|
||||
graduationYear: "",
|
||||
phone: "",
|
||||
});
|
||||
const [media, setMedia] = useState<MediaInfo | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [payRedirecting, setPayRedirecting] = useState(false);
|
||||
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
if (getStoredToken()) return true;
|
||||
try {
|
||||
const res = await fetch("/api/digital/auth/me", { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return !!(data?.user && data?.token);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
|
||||
setSubmitted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const env = getPayEnv();
|
||||
setPayEnv(env);
|
||||
}, []);
|
||||
|
||||
const set = (key: keyof FormData, value: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const loggedIn = await checkAuth();
|
||||
if (!loggedIn) {
|
||||
setAuthOpen(true);
|
||||
setError("请先登录或注册后再提交申请");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/digital/join", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, media: media?.url }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.ok) {
|
||||
throw new Error(data?.error || "提交失败,请稍后重试");
|
||||
}
|
||||
const userId = data.user_id;
|
||||
if (!userId) {
|
||||
setSubmitted(true);
|
||||
return;
|
||||
}
|
||||
setSubmitting(false);
|
||||
setPayRedirecting(true);
|
||||
// Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
|
||||
const returnUrl = `${window.location.origin}/`;
|
||||
const env = getPayEnv();
|
||||
const channel = "wxpay";
|
||||
const device = getDeviceFromEnv(env);
|
||||
redirectToPay({
|
||||
user_id: userId,
|
||||
return_url: returnUrl,
|
||||
channel,
|
||||
device,
|
||||
useJoinConfig: true,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "网络错误,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white p-10 text-center shadow-lg">
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
|
||||
✅
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">申请已提交</h2>
|
||||
<p className="mt-3 text-slate-500">
|
||||
我们会尽快审核你的信息
|
||||
<br />
|
||||
审核通过后将通过微信邀请你入群
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f0f4f8]">
|
||||
{/* ── PC: center column / H5: full width ── */}
|
||||
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">
|
||||
{/* ── Banner ── */}
|
||||
<div className="overflow-hidden bg-white shadow-sm sm:rounded-t-2xl">
|
||||
{/* Hero image area */}
|
||||
<div className="relative h-40 overflow-hidden bg-gradient-to-br from-sky-400 via-cyan-500 to-teal-400 sm:h-52">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-white">
|
||||
<div className="text-6xl sm:text-7xl">🌍</div>
|
||||
<div className="mt-2 text-sm font-medium opacity-80">
|
||||
Digital Nomad Community
|
||||
</div>
|
||||
</div>
|
||||
{/* Decorative blobs */}
|
||||
<div className="absolute -top-10 -left-10 h-40 w-40 rounded-full bg-white/10 blur-2xl" />
|
||||
<div className="absolute -right-10 -bottom-10 h-48 w-48 rounded-full bg-white/10 blur-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Title block */}
|
||||
<div className="px-5 py-5 text-center sm:px-8 sm:py-7">
|
||||
<h1 className="text-xl font-bold text-slate-800 sm:text-2xl">
|
||||
🌍 数字游民社区报名
|
||||
</h1>
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-relaxed text-slate-400 sm:text-base">
|
||||
{"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Form card ── */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mt-0 bg-white px-5 pb-8 pt-2 shadow-sm sm:mt-5 sm:rounded-2xl sm:px-8 sm:py-8 sm:shadow-md"
|
||||
>
|
||||
{/* ---- Row group: nickname + gender (PC side-by-side) ---- */}
|
||||
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||
<Field label="🌟 昵称">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={140}
|
||||
placeholder="请输入您的昵称"
|
||||
value={form.nickname}
|
||||
onChange={(e) => set("nickname", e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="👤 性别">
|
||||
<select
|
||||
value={form.gender}
|
||||
onChange={(e) => set("gender", e.target.value)}
|
||||
required
|
||||
className={`form-input appearance-none ${
|
||||
form.gender ? "text-slate-800" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<option value="" disabled>
|
||||
请选择
|
||||
</option>
|
||||
{genderOptions.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* ---- Row group: single + profession ---- */}
|
||||
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||
<Field label="💕 感情状态">
|
||||
<select
|
||||
value={form.single}
|
||||
onChange={(e) => set("single", e.target.value)}
|
||||
required
|
||||
className={`form-input appearance-none ${
|
||||
form.single ? "text-slate-800" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<option value="" disabled>
|
||||
请选择
|
||||
</option>
|
||||
{singleOptions.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown />
|
||||
</Field>
|
||||
|
||||
<Field label="💼 职业">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={140}
|
||||
placeholder="您的职业或专业"
|
||||
value={form.profession}
|
||||
onChange={(e) => set("profession", e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* ---- wechat + phone ---- */}
|
||||
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||
<Field label="💬 微信号">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={140}
|
||||
placeholder="用于拉群"
|
||||
value={form.wechat}
|
||||
onChange={(e) => set("wechat", e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="📱 手机号">
|
||||
<input
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
pattern="^1[3-9]\d{9}$"
|
||||
placeholder="请输入11位中国手机号"
|
||||
value={form.phone}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/\D/g, "").slice(0, 11);
|
||||
set("phone", v);
|
||||
}}
|
||||
className="form-input"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* ---- Intro (full width) ---- */}
|
||||
<div className="border-b border-slate-100 py-4 sm:border-b-0 sm:py-5">
|
||||
<label className="mb-2 block text-base font-bold text-slate-700 sm:text-[17px]">
|
||||
📝 自我介绍
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
maxLength={500}
|
||||
rows={5}
|
||||
placeholder="分享您的经历、兴趣或加入原因,越详细越好哦~"
|
||||
value={form.intro}
|
||||
onChange={(e) => set("intro", e.target.value)}
|
||||
className="form-input min-h-[120px] resize-none sm:min-h-[140px]"
|
||||
/>
|
||||
<span className="absolute right-3 bottom-3 text-xs text-slate-300">
|
||||
{form.intro.length}/500
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Row group: education + graduation ---- */}
|
||||
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||
<Field label="🎓 学历">
|
||||
<select
|
||||
value={form.education}
|
||||
onChange={(e) => set("education", e.target.value)}
|
||||
className={`form-input appearance-none ${
|
||||
form.education ? "text-slate-800" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<option value="" disabled>
|
||||
请选择
|
||||
</option>
|
||||
{educationOptions.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown />
|
||||
</Field>
|
||||
|
||||
<Field label="📅 毕业时间">
|
||||
<select
|
||||
value={form.graduationYear}
|
||||
onChange={(e) => set("graduationYear", e.target.value)}
|
||||
className={`form-input appearance-none ${
|
||||
form.graduationYear ? "text-slate-800" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<option value="" disabled>
|
||||
请选择
|
||||
</option>
|
||||
{graduationYears.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* ---- Media upload (optional) ---- */}
|
||||
<div className="py-4 sm:py-5">
|
||||
<label className="mb-1 block text-[15px] font-bold text-slate-700 sm:text-[17px]">
|
||||
📷 头像 / 自拍视频
|
||||
<span className="ml-2 text-xs font-normal text-slate-400">
|
||||
(可选)
|
||||
</span>
|
||||
</label>
|
||||
<p className="mb-3 text-xs text-slate-400">
|
||||
支持图片(JPG/PNG)或短视频(MP4),最大 20MB
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||
className="hidden"
|
||||
onChange={async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
alert("文件大小不能超过 20MB");
|
||||
return;
|
||||
}
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
const preview = URL.createObjectURL(file);
|
||||
setMedia({ preview, url: "", type: isVideo ? "video" : "image" });
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/digital/upload-media", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.ok) {
|
||||
throw new Error(data?.error || "上传失败");
|
||||
}
|
||||
setMedia((m) => (m ? { ...m, url: data.url } : null));
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "上传失败");
|
||||
URL.revokeObjectURL(preview);
|
||||
setMedia(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{media ? (
|
||||
<div className="relative inline-block">
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-black/40 text-sm font-medium text-white">
|
||||
上传中…
|
||||
</div>
|
||||
)}
|
||||
{media.type === "image" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={media.preview}
|
||||
alt="preview"
|
||||
className="h-32 w-32 rounded-xl border border-slate-200 object-cover sm:h-40 sm:w-40"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={media.preview}
|
||||
className="h-32 w-32 rounded-xl border border-slate-200 object-cover sm:h-40 sm:w-40"
|
||||
muted
|
||||
playsInline
|
||||
controls
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => {
|
||||
URL.revokeObjectURL(media.preview);
|
||||
setMedia(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white shadow-md transition-colors hover:bg-red-600 disabled:opacity-50"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex h-32 w-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 transition-colors hover:border-sky-300 hover:text-sky-500 disabled:opacity-50 sm:h-40 sm:w-40"
|
||||
>
|
||||
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span className="text-xs">{uploading ? "上传中…" : "点击上传"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 支付方式:默认微信支付,支付宝暂不提供 ── */}
|
||||
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50/50 px-4 py-4 sm:px-5">
|
||||
<p className="mb-3 text-sm font-bold text-slate-700">💳 支付方式</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
使用 <span className="font-medium text-emerald-600">微信支付</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Submit ── */}
|
||||
<div className="mt-8 sm:mt-10">
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-center text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || uploading || payRedirecting}
|
||||
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:py-4"
|
||||
>
|
||||
{uploading ? "媒体上传中…" : payRedirecting ? "跳转支付中…" : submitting ? "提交中…" : "提交申请"}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* ── Back link (PC) ── */}
|
||||
<div className="mt-6 hidden text-center sm:block">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-slate-400 transition-colors hover:text-sky-500"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pb-6 text-center sm:hidden">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-slate-400 transition-colors hover:text-sky-500"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal
|
||||
isOpen={authOpen}
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onSuccess={() => {
|
||||
setAuthOpen(false);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Reusable field wrapper ─── */
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
noBorder,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
noBorder?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center gap-3 py-3.5 sm:flex-col sm:items-stretch sm:gap-0 sm:py-4 ${
|
||||
noBorder ? "" : "border-b border-slate-100 sm:border-b-0"
|
||||
}`}
|
||||
>
|
||||
<label className="w-[90px] shrink-0 text-[15px] font-bold text-slate-700 sm:mb-2 sm:w-auto sm:text-[17px]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Chevron icon for selects ─── */
|
||||
function ChevronDown() {
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 text-slate-400 sm:right-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
39
app/[locale]/digital/join/paid/page.tsx
Normal file
39
app/[locale]/digital/join/paid/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "@/app/digital/i18n/navigation";
|
||||
|
||||
/**
|
||||
* 支付完成回跳页
|
||||
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
|
||||
*/
|
||||
export default function JoinPaidPage() {
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.removeItem("join_pay_order");
|
||||
} catch {}
|
||||
document.cookie = "join_pay_order=; path=/; max-age=0";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white p-10 text-center shadow-lg">
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
|
||||
✅
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">申请已提交</h2>
|
||||
<p className="mt-3 text-slate-500">
|
||||
我们会尽快审核你的信息
|
||||
<br />
|
||||
审核通过后将通过微信邀请你入群
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
app/[locale]/digital/layout.tsx
Normal file
44
app/[locale]/digital/layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { LocaleProvider } from "../../digital/context/LocaleContext";
|
||||
import type { Locale } from "../../digital/context/LocaleContext";
|
||||
import { getThemeConfig } from "@/config/digital";
|
||||
import { getThemeMessages } from "@/app/digital/lib/theme-data";
|
||||
import DigitalProviders from "@/app/digital/DigitalProviders";
|
||||
|
||||
const LOCALES: Locale[] = ["zh", "en"];
|
||||
|
||||
export const metadata: Metadata = (() => {
|
||||
const theme = getThemeConfig();
|
||||
return {
|
||||
title: theme.meta.title,
|
||||
description: theme.meta.description,
|
||||
};
|
||||
})();
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!LOCALES.includes(locale as Locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getThemeMessages(locale as Locale);
|
||||
|
||||
return (
|
||||
<LocaleProvider locale={locale as Locale} messages={messages}>
|
||||
<DigitalProviders>
|
||||
<div className="digital-scope">{children}</div>
|
||||
</DigitalProviders>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
29
app/[locale]/digital/page.tsx
Normal file
29
app/[locale]/digital/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Header from "../../digital/components/Header";
|
||||
import Hero from "../../digital/components/Hero";
|
||||
import Features from "../../digital/components/Features";
|
||||
import Roadmap from "../../digital/components/Roadmap";
|
||||
import Tools from "../../digital/components/Tools";
|
||||
import Community from "../../digital/components/Community";
|
||||
import Footer from "../../digital/components/Footer";
|
||||
import { getThemeToolsData, getResourceData } from "../../digital/lib/theme-data";
|
||||
|
||||
export default async function HomePage() {
|
||||
const [toolsData, resourceData] = await Promise.all([
|
||||
getThemeToolsData(),
|
||||
getResourceData(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<Features />
|
||||
<Roadmap />
|
||||
<Tools data={toolsData} />
|
||||
<Community resourceData={resourceData} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
182
app/[locale]/digital/privacy/PrivacyContent.tsx
Normal file
182
app/[locale]/digital/privacy/PrivacyContent.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@/app/digital/i18n/navigation";
|
||||
|
||||
function renderWithLinks(text: string) {
|
||||
const urlRegex = /(https?:\/\/[^\s)]+)/g;
|
||||
const parts = text.split(urlRegex);
|
||||
return parts.map((part, i) =>
|
||||
part.match(urlRegex) ? (
|
||||
<a
|
||||
key={`${i}-${part}`}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sky-600 underline hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const PRIVACY_ZH = {
|
||||
title: "隐私政策",
|
||||
lastUpdated: "最后更新",
|
||||
sections: [
|
||||
{
|
||||
title: "一、适用范围",
|
||||
content: `本隐私政策适用于「数字游民指南」(以下简称本网站)在您使用网站服务时收集的个人信息。本政策不适用于本网站以外的第三方链接网站,也不适用于非本网站委托或参与管理的人员。`,
|
||||
},
|
||||
{
|
||||
title: "二、个人资料的收集、处理及利用",
|
||||
content: `当您访问本网站或使用本网站提供的功能服务时,我们将根据服务性质请您提供必要的个人资料,并在特定目的范围内处理及利用;未经您书面同意,本网站不会将个人资料用于其他用途。
|
||||
|
||||
本网站在您使用申请表单、问卷、支付等互动功能时,会保留您提供的姓名、电子邮箱、联系方式及使用时间等。
|
||||
|
||||
在一般浏览时,服务器会自动记录相关行为,包括您使用设备的 IP 地址、使用时间、浏览器类型、浏览及点击记录等,作为我们改进网站服务的参考,此记录仅供内部使用,不会对外公布。
|
||||
|
||||
您可以随时向我们提出请求,以更正或删除您的账户或本网站所收集的个人资料。联系方式请见文末。`,
|
||||
},
|
||||
{
|
||||
title: "三、支付处理与 Stripe",
|
||||
content: `本网站使用 Stripe 作为支付服务提供商处理付款。在您进行支付时:
|
||||
|
||||
• 数据控制者:本网站为个人数据的控制者,Stripe 作为数据处理者代表我们处理支付相关数据。
|
||||
• Stripe 隐私政策:您可查阅 Stripe 的隐私政策 https://stripe.com/privacy
|
||||
• 收集的数据类别:Stripe 直接收集的数据包括卡号、有效期等支付信息及设备数据;我们向 Stripe 共享的数据包括邮箱、姓名、账单地址等完成交易所需信息。
|
||||
• 国际数据传输:支付数据可能传输至美国等地区,Stripe 采用标准合同条款及欧盟-美国数据隐私框架等机制保障合规。
|
||||
• 法律依据:支付处理基于合同履行(完成支付义务)、法律义务(税务与会计要求)及合法利益(欺诈防范)。
|
||||
• 数据保留:交易记录通常保留 7–10 年以满足税务及会计要求;支付失败日志可能保留 90 天至 1 年。`,
|
||||
},
|
||||
{
|
||||
title: "四、资料保护",
|
||||
content: `本网站主机设有防火墙、防毒系统等安全设备及必要防护措施,保护网站及您的个人资料。个人资料仅由经授权人员接触,相关处理人员均签署保密协议,违反者将承担法律责任。
|
||||
|
||||
如因业务需要委托其他单位提供服务,本网站将严格要求其遵守保密义务,并采取必要检查程序确保其切实遵守。`,
|
||||
},
|
||||
{
|
||||
title: "五、外部链接",
|
||||
content: `本网站提供其他网站的链接,您可经由本网站提供的链接进入其他网站。该等链接网站不适用本网站的隐私政策,您须参考该链接网站自身的隐私政策。`,
|
||||
},
|
||||
{
|
||||
title: "六、与第三人共用个人资料",
|
||||
content: `本网站不会提供、交换、出租或出售您的个人资料给其他个人、团体、私人企业或公务机关,但有法律依据或合约义务者不在此限,包括但不限于:经您书面同意、法律明文规定、为免除您生命身体自由或财产上之危险、与公务机关或学术研究机构基于公共利益进行统计或学术研究且资料经处理后无法识别特定当事人、或为辨识联络或采取法律行动所必要者。
|
||||
|
||||
本网站委托厂商协助收集、处理或利用您的个人资料时,将对委外厂商善尽监督管理之责。`,
|
||||
},
|
||||
{
|
||||
title: "七、Cookie 使用",
|
||||
content: `为提供您最佳服务,本网站会在您的设备中放置并读取 Cookie。若您不愿接受 Cookie,可在浏览器中设定较高的隐私等级以拒绝 Cookie,但可能导致网站部分功能无法正常执行。`,
|
||||
},
|
||||
{
|
||||
title: "八、政策修订",
|
||||
content: `本隐私政策将因应需求随时修正,修正后的条款将刊登于本网站。`,
|
||||
},
|
||||
{
|
||||
title: "九、联系管道",
|
||||
content: `若对本隐私政策有任何疑问,或欲提出变更、删除个人资料之请求,请前往本网站「联系我们」页面提交表单,或通过以下方式联系我们:
|
||||
|
||||
• 网站:数字游民指南
|
||||
• 邮箱:请通过网站联系我们页面获取
|
||||
• 地址:请通过网站联系我们页面获取`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PRIVACY_EN = {
|
||||
title: "Privacy Policy",
|
||||
lastUpdated: "Last updated",
|
||||
sections: [
|
||||
{
|
||||
title: "1. Scope",
|
||||
content: `This Privacy Policy applies to personal information collected by "Digital Nomad Guide" (the "Site") when you use our services. This policy does not apply to third-party websites linked from this Site, nor to persons not entrusted or involved in the management of this Site.`,
|
||||
},
|
||||
{
|
||||
title: "2. Collection, Processing and Use of Personal Data",
|
||||
content: `When you visit the Site or use our services, we may ask you to provide necessary personal data for specific purposes. We will not use your personal data for other purposes without your written consent.
|
||||
|
||||
When you use application forms, surveys, payments, or other interactive features, we retain information you provide, such as name, email, contact details, and usage time.
|
||||
|
||||
During general browsing, our servers automatically record relevant activity, including your IP address, access time, browser type, and browsing/click records, for improving our services. Such records are for internal use only and are not disclosed to the public.
|
||||
|
||||
You may request correction or deletion of your account or personal data collected by the Site at any time. Contact details are provided at the end of this policy.`,
|
||||
},
|
||||
{
|
||||
title: "3. Payment Processing and Stripe",
|
||||
content: `We use Stripe as our payment service provider. When you make a payment:
|
||||
|
||||
• Data Controller: We are the data controller; Stripe acts as a data processor on our behalf.
|
||||
• Stripe Privacy Policy: https://stripe.com/privacy
|
||||
• Data Categories: Stripe directly collects payment card details and device data; we share email, name, billing address, and other information necessary to complete the transaction.
|
||||
• International Transfers: Payment data may be transferred to the US and other regions. Stripe uses Standard Contractual Clauses and the EU-US Data Privacy Framework to ensure compliance.
|
||||
• Legal Basis: Payment processing is based on contract performance (fulfilling payment obligations), legal obligation (tax and accounting requirements), and legitimate interest (fraud prevention).
|
||||
• Data Retention: Transaction records are typically retained for 7–10 years for tax and accounting purposes; payment failure logs may be retained for 90 days to 1 year.`,
|
||||
},
|
||||
{
|
||||
title: "4. Data Protection",
|
||||
content: `Our servers are protected by firewalls, antivirus systems, and other security measures. Personal data is accessible only to authorized personnel who have signed confidentiality agreements. Violations will result in legal liability.
|
||||
|
||||
When we entrust third parties to process personal data, we require them to comply with confidentiality obligations and conduct necessary audits.`,
|
||||
},
|
||||
{
|
||||
title: "5. External Links",
|
||||
content: `The Site provides links to other websites. Those linked sites are not governed by this Privacy Policy. Please refer to the privacy policy of each linked site.`,
|
||||
},
|
||||
{
|
||||
title: "6. Sharing Personal Data with Third Parties",
|
||||
content: `We will not provide, exchange, rent, or sell your personal data to other individuals, groups, or entities, except where required by law or contract, including but not limited to: your written consent, legal requirements, protection of life or property, statistical or academic research with anonymized data, or identification/contact/legal action as necessary.
|
||||
|
||||
When we entrust third parties to collect, process, or use your personal data, we will supervise them appropriately.`,
|
||||
},
|
||||
{
|
||||
title: "7. Use of Cookies",
|
||||
content: `To provide you with the best service, we place and read cookies on your device. You may set a higher privacy level in your browser to refuse cookies, but some features of the Site may not function properly.`,
|
||||
},
|
||||
{
|
||||
title: "8. Policy Amendments",
|
||||
content: `We may amend this Privacy Policy from time to time. Amended terms will be posted on the Site.`,
|
||||
},
|
||||
{
|
||||
title: "9. Contact",
|
||||
content: `If you have questions about this Privacy Policy or wish to request correction or deletion of your personal data, please submit a form via our "Contact Us" page, or contact us through:
|
||||
|
||||
• Site: Digital Nomad Guide
|
||||
• Email: Available via the Contact Us page
|
||||
• Address: Available via the Contact Us page`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function PrivacyContent() {
|
||||
const locale = useLocale();
|
||||
const content = locale === "zh" ? PRIVACY_ZH : PRIVACY_EN;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 py-16 dark:bg-slate-950 sm:py-20">
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
|
||||
{content.title}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{content.lastUpdated}: 2025-03
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-10">
|
||||
{content.sections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h2 className="mb-4 text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{section.title}
|
||||
</h2>
|
||||
<div className="whitespace-pre-line text-slate-600 dark:text-slate-400">
|
||||
{renderWithLinks(section.content)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/[locale]/digital/privacy/page.tsx
Normal file
21
app/[locale]/digital/privacy/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Header from "../../../digital/components/Header";
|
||||
import Footer from "../../../digital/components/Footer";
|
||||
import PrivacyContent from "./PrivacyContent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "隐私政策 | Privacy Policy",
|
||||
description: "数字游民指南隐私政策,说明我们如何收集、使用和保护您的个人信息。",
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<PrivacyContent />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import { LocaleProvider } from "../context/LocaleContext";
|
||||
import type { Locale } from "../context/LocaleContext";
|
||||
import { getThemeMessages } from "@/app/lib/theme-data";
|
||||
import Navbar from "../components/Navbar";
|
||||
import RouteAwareNavbar from "../components/RouteAwareNavbar";
|
||||
import SetLangAttr from "../components/SetLangAttr";
|
||||
|
||||
const LOCALES: Locale[] = ["zh", "en"];
|
||||
@@ -35,7 +35,7 @@ export default async function LocaleLayout({
|
||||
return (
|
||||
<LocaleProvider locale={locale as Locale} messages={messages}>
|
||||
<SetLangAttr />
|
||||
<Navbar />
|
||||
<RouteAwareNavbar />
|
||||
{children}
|
||||
</LocaleProvider>
|
||||
);
|
||||
|
||||
@@ -82,7 +82,7 @@ const FALLBACK_HOME_CONTENT: ContentItem[] = [
|
||||
subtitle: "完整方法论、工具、案例和长期更新",
|
||||
subtitleEn: "Methods, tools, cases, and long-term updates.",
|
||||
coverImage: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200",
|
||||
targetUrl: "https://digital.hackrobot.cn/zh",
|
||||
targetUrl: "/digital",
|
||||
ctaLabel: "打开指南",
|
||||
ctaLabelEn: "Open guide",
|
||||
},
|
||||
@@ -336,7 +336,6 @@ export default function Home() {
|
||||
|
||||
<div className="items-grid">
|
||||
{homeContent.slice(0, 4).map((item, index) => {
|
||||
const isExternal = item.targetUrl.startsWith("http");
|
||||
const title = locale === "zh" ? item.title : item.titleEn || item.title;
|
||||
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
|
||||
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
|
||||
@@ -344,9 +343,10 @@ export default function Home() {
|
||||
ebook: { header: "电子书", icon: "📘", href: `/ebooks/${item.slug}` },
|
||||
video: { header: "访谈视频", icon: "▶️", href: `/videos/${item.slug}` },
|
||||
app: { header: "App", icon: "📱", href: "/app" },
|
||||
guide: { header: "数字游民指南", icon: "🧭", href: item.targetUrl },
|
||||
guide: { header: "数字游民指南", icon: "🧭", href: "/digital" },
|
||||
};
|
||||
const meta = typeMeta[item.type] || typeMeta.guide;
|
||||
const isExternal = meta.href.startsWith("http");
|
||||
const content = (
|
||||
<>
|
||||
<div className="right-item-header">{meta.header}</div>
|
||||
|
||||
Reference in New Issue
Block a user