This commit is contained in:
eric
2026-06-07 01:17:46 -05:00
parent 283d65d1d1
commit cd885f5fcd
110 changed files with 17876 additions and 11 deletions

View File

@@ -0,0 +1,183 @@
"use client";
import { useState, type FormEvent } from "react";
import { pbSaveAuth } from "@/app/digital/lib/pocketbase";
interface AuthModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (email: string) => void;
}
type AuthResult = {
ok: boolean;
token?: string;
record?: { id: string; email?: string; [key: string]: unknown };
error?: string;
message?: string;
};
export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (mode === "register") {
if (password !== passwordConfirm) {
setError("Passwords do not match");
setLoading(false);
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
setLoading(false);
return;
}
}
const endpoint = mode === "login" ? "/api/digital/auth/login" : "/api/digital/auth/register";
const payload =
mode === "login"
? { identity: email, password }
: { email, password, passwordConfirm };
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
const data = (await res.json().catch(() => ({}))) as AuthResult;
if (!res.ok || !data?.ok || !data?.token || !data?.record) {
throw new Error(data?.error || data?.message || "Authentication failed");
}
pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? email });
onSuccess(email);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:success"));
}
onClose();
setEmail("");
setPassword("");
setPasswordConfirm("");
} catch (err) {
setError(err instanceof Error ? err.message : "Authentication failed");
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden />
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<button
onClick={onClose}
className="absolute right-4 top-4 text-slate-400 hover:text-slate-600"
aria-label="Close"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold text-slate-800">{mode === "login" ? "Login" : "Register"}</h2>
<p className="mt-1 text-sm text-slate-500">
{mode === "login" ? "Use email and password" : "Create a new account"}
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
required
minLength={mode === "register" ? 8 : 1}
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
/>
</div>
{mode === "register" && (
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">Confirm Password</label>
<input
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
placeholder="Enter password again"
required
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
/>
</div>
)}
{error && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-sky-500 py-3 font-semibold text-white transition-colors hover:bg-sky-600 disabled:opacity-70"
>
{loading ? "Processing..." : mode === "login" ? "Login" : "Register"}
</button>
</form>
<p className="mt-4 text-center text-sm text-slate-500">
{mode === "login" ? (
<>
No account?{" "}
<button
type="button"
onClick={() => {
setMode("register");
setError(null);
}}
className="font-medium text-sky-500 hover:text-sky-600"
>
Register
</button>
</>
) : (
<>
Already have an account?{" "}
<button
type="button"
onClick={() => {
setMode("login");
setError(null);
}}
className="font-medium text-sky-500 hover:text-sky-600"
>
Login
</button>
</>
)}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import { TOPIC_IDS } from "@/config/digital";
import type { ResourceData } from "@/app/digital/lib/theme-data";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
import ResourceNavigation from "./ResourceNavigation";
const mainModuleKeys = ["destinations", "vipEntry", "appDownload"] as const;
const topicKeys = ["indieDev", "aiAgent", "cloudPhone", "unmannedLive"] as const;
const linkIcons: Record<string, string> = {
destinations: "🗺️",
vipEntry: "👑",
appDownload: "📱",
indieDev: "🛠️",
aiAgent: "🤖",
cloudPhone: "☁️",
unmannedLive: "📺",
};
type CommunityProps = {
resourceData?: ResourceData;
};
export default function Community({ resourceData }: CommunityProps) {
const { t } = useTranslation("community");
const [toast, setToast] = useState(false);
const { meetupUrl, vipUrl } = useCrossSiteUrls();
const linkHrefs: Record<string, { href: string; newTab?: boolean }> = {
destinations: { href: meetupUrl, newTab: true },
vipEntry: { href: vipUrl, newTab: true },
appDownload: { href: "/app" },
};
const showUpdating = () => {
setToast(true);
setTimeout(() => setToast(false), 2000);
};
const renderCardContent = (key: string) => (
<>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-50 text-xl dark:bg-slate-800">
{linkIcons[key]}
</span>
<div className="min-w-0 flex-1">
<h4 className="font-bold text-slate-900 dark:text-slate-100">{t(`links.${key}.title`)}</h4>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t(`links.${key}.desc`)}</p>
<span className="mt-2 inline-block text-sm font-medium text-sky-600">{t(`links.${key}.label`)}</span>
</div>
</>
);
return (
<section id="community" className="py-20 sm:py-28 dark:bg-slate-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div id="resources" className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
<span className="gradient-text">{t("resourcesTitle")}</span>
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
{t("contributeSubtitle")}
<br />
{t("contributeSubtitle2")}
</p>
</div>
{/* 图书 / 商业数码 / 网盘 */}
{resourceData && <ResourceNavigation data={resourceData} />}
{/* 目的地数据库 + 私域VIP入口 + App 下载 - 主模块 */}
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{mainModuleKeys.map((key) => {
const { href, newTab } = linkHrefs[key];
return (
<Link
key={key}
href={href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
className="card-hover flex items-start gap-5 rounded-2xl border-2 border-slate-200 bg-white p-6 shadow-md transition-all hover:border-sky-300 hover:shadow-lg dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600"
>
{renderCardContent(key)}
</Link>
);
})}
</div>
{/* 4 个专题 - 小卡片,暂不可点击 */}
<div className="mt-8">
<h3 className="mb-4 text-center text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{t("topicTag")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{topicKeys.map((key) => (
<button
key={key}
type="button"
onClick={showUpdating}
className="flex cursor-not-allowed items-start gap-4 rounded-xl border border-slate-200 bg-slate-50/80 p-4 opacity-90 transition-opacity hover:opacity-100 dark:border-slate-700 dark:bg-slate-800/60"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-200 text-lg dark:bg-slate-700">
{linkIcons[key]}
</span>
<div className="min-w-0 flex-1 text-left">
<h4 className="font-bold text-slate-700 dark:text-slate-300">{t(`links.${key}.title`)}</h4>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{t(`links.${key}.desc`)}</p>
<span className="mt-1.5 inline-block text-xs font-medium text-amber-600 dark:text-amber-400">
{t("updating")}
</span>
</div>
</button>
))}
</div>
</div>
{/* 更新中 Toast */}
{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>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export default function DayContent({ content }: { content: string }) {
return (
<div className="prose-custom">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useTranslation } from "@/app/digital/i18n/navigation";
const featureKeys = ["location", "skill", "balance"] as const;
export default function Features() {
const { t } = useTranslation("features");
const features = featureKeys.map((key) => ({
icon: key === "location" ? "🌏" : key === "skill" ? "⚡" : "🔄",
key,
}));
return (
<section id="features" className="py-20 sm:py-28 dark:bg-slate-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
{t("title")} <span className="gradient-text">{t("titleHighlight")}</span>{t("titleSuffix")}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
{t("subtitle")}
<br className="hidden sm:block" />
{t("subtitle2")}
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map((f) => (
<div
key={f.key}
className="card-hover rounded-2xl border border-slate-100 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-3xl dark:bg-sky-900/50">
{f.icon}
</div>
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
{t(`items.${f.key}.title`)}
</h3>
<p className="leading-relaxed text-slate-600 dark:text-slate-400">{t(`items.${f.key}.desc`)}</p>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
export default function Footer() {
const { t } = useTranslation("footer");
const { meetupUrl, vipUrl } = useCrossSiteUrls();
const footerSections = [
{
titleKey: "guide" as const,
links: [
{ labelKey: "roadmap" as const, href: "#roadmap" },
{ labelKey: "tools" as const, href: "#tools" },
{ labelKey: "destinations" as const, href: meetupUrl, openInNewTab: true },
],
},
{
titleKey: "resources" as const,
links: [
{ labelKey: "remoteJobs" as const, href: "#" },
{ labelKey: "visa" as const, href: "#" },
{ labelKey: "coliving" as const, href: "#" },
{ labelKey: "insurance" as const, href: "#" },
],
},
{
titleKey: "community" as const,
links: [
{ labelKey: "discord" as const, href: "#" },
{ labelKey: "wechat" as const, href: vipUrl, openInNewTab: true },
{ labelKey: "jike" as const, href: "#" },
],
},
{
titleKey: "about" as const,
links: [
{ labelKey: "aboutUs" as const, href: "/about", internal: true },
{ labelKey: "recruit" as const, href: "/jobs", internal: true },
{ labelKey: "privacy" as const, href: "/privacy", internal: true, openInNewTab: true },
{ labelKey: "contact" as const, href: "#" },
],
},
];
return (
<footer className="border-t border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8 lg:py-16">
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-5">
<div className="lg:col-span-1">
<Link href="/" className="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
<span className="text-2xl">🌍</span>
<span>{t("siteName")}</span>
</Link>
<p className="mt-3 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
{t("tagline")}
<br />
{t("tagline2")}
</p>
</div>
{footerSections.map((section) => (
<div key={section.titleKey}>
<h4 className="mb-4 text-sm font-bold text-slate-900 dark:text-slate-100">
{t(`sections.${section.titleKey}`)}
</h4>
<ul className="space-y-2.5">
{section.links.map((link) => (
<li key={link.labelKey}>
{"internal" in link && link.internal ? (
<Link
href={link.href}
target={"openInNewTab" in link && link.openInNewTab ? "_blank" : undefined}
rel={"openInNewTab" in link && link.openInNewTab ? "noopener noreferrer" : undefined}
className="text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
{t(`links.${link.labelKey}`)}
</Link>
) : (
<a
href={link.href}
target={"openInNewTab" in link && link.openInNewTab ? "_blank" : undefined}
rel={"openInNewTab" in link && link.openInNewTab ? "noopener noreferrer" : undefined}
className="text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
{t(`links.${link.labelKey}`)}
</a>
)}
</li>
))}
</ul>
</div>
))}
</div>
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-slate-100 pt-8 dark:border-slate-800 sm:flex-row">
<p className="text-sm text-slate-400 dark:text-slate-500">
{t("madeBy")}
</p>
<p className="text-sm text-slate-400 dark:text-slate-500">{t("openSource")}</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,199 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/app/digital/i18n/navigation";
import { getStoredUserEmail } from "@/app/digital/lib/pocketbase";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
import UserAvatar from "./UserAvatar";
export default function Header() {
const { t } = useTranslation("common");
const { t: tNav } = useTranslation("nav");
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [vip, setVip] = useState(false);
const { vipUrl } = useCrossSiteUrls();
const navLinks = [
{ labelKey: "roadmap" as const, href: "#roadmap" },
{ labelKey: "tools" as const, href: "#tools" },
{ labelKey: "community" as const, href: vipUrl, external: true },
{ labelKey: "resources" as const, href: "#resources" },
];
const fetchAuthAndVip = useCallback(async () => {
try {
const meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
const meData = await meRes.json();
if (meData?.user && meData?.token) {
const { pbSaveAuth } = await import("@/app/digital/lib/pocketbase");
pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email });
setUserEmail(meData.user.email);
// 用邮箱查 VIP,比 cookie userId 更可靠
const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json();
setVip(vipData?.vip === true);
} else {
setUserEmail(getStoredUserEmail());
setVip(false);
}
} catch {
/* ignore */
}
}, []);
useEffect(() => {
fetchAuthAndVip();
}, [fetchAuthAndVip]);
useEffect(() => {
const onVipUpdated = () => fetchAuthAndVip();
const onAuthSuccess = () => fetchAuthAndVip();
window.addEventListener("vip:updated", onVipUpdated);
window.addEventListener("auth:success", onAuthSuccess);
return () => {
window.removeEventListener("vip:updated", onVipUpdated);
window.removeEventListener("auth:success", onAuthSuccess);
};
}, [fetchAuthAndVip]);
return (
<header
className={`fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
scrolled
? "bg-white/80 backdrop-blur-lg shadow-sm dark:bg-slate-900/80 dark:shadow-slate-950/50"
: "bg-transparent"
}`}
>
<div className="mx-auto flex h-16 w-full min-w-0 max-w-7xl items-center justify-between gap-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] lg:pl-[max(2rem,env(safe-area-inset-left))] lg:pr-[max(2rem,env(safe-area-inset-right))]">
<Link href="/" className="flex min-w-0 shrink items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
<span className="text-2xl">🌍</span>
<span className="hidden sm:inline">{t("siteName")}</span>
<span className="sm:hidden">{t("siteNameShort")}</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
target={"external" in link && link.external ? "_blank" : undefined}
rel={"external" in link && link.external ? "noopener noreferrer" : undefined}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
))}
</nav>
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
{userEmail ? (
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); router.replace("/"); }} />
) : (
<button
onClick={() => setAuthOpen(true)}
className="hidden rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 sm:inline-flex"
>
{t("loginRegister")}
</button>
)}
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
title={locale === "zh" ? "EN" : "中文"}
>
{locale === "zh" ? "EN" : "中文"}
</button>
<ThemeToggle />
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 md:hidden"
aria-label={t("toggleMenu")}
>
{mobileOpen ? (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
<a
href="#roadmap"
className="hidden rounded-full bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500 sm:inline-flex"
>
{t("explore")}
</a>
</div>
</div>
{mobileOpen && (
<div className="overflow-x-hidden border-t border-slate-100 bg-white/95 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-900/95 md:hidden">
<nav className="mx-auto w-full max-w-7xl space-y-1 px-4 py-3 pr-[max(1rem,env(safe-area-inset-right))]">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
target={"external" in link && link.external ? "_blank" : undefined}
rel={"external" in link && link.external ? "noopener noreferrer" : undefined}
onClick={() => setMobileOpen(false)}
className="block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
))}
<button
type="button"
onClick={() => {
setMobileOpen(false);
router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" });
}}
className="block w-full rounded-lg px-3 py-2.5 text-left text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{locale === "zh" ? "EN" : "中文"}
</button>
{userEmail ? (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); setMobileOpen(false); router.replace("/"); }} />
<span className="truncate text-sm text-slate-600 dark:text-slate-400">{userEmail}</span>
</div>
) : (
<button
onClick={() => {
setMobileOpen(false);
setAuthOpen(true);
}}
className="mt-2 block w-full rounded-full border border-slate-200 px-4 py-2.5 text-center text-base font-medium text-slate-600 dark:border-slate-700 dark:text-slate-400"
>
{t("loginRegister")}
</button>
)}
<a
href="#roadmap"
onClick={() => setMobileOpen(false)}
className="mt-2 block rounded-full bg-sky-500 px-4 py-2.5 text-center text-base font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500"
>
{t("explore")}
</a>
</nav>
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => fetchAuthAndVip()}
/>
</header>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useTranslation } from "@/app/digital/i18n/navigation";
export default function Hero() {
const { t } = useTranslation("hero");
return (
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-32 sm:pb-20">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-sky-50/80 via-white to-white dark:from-slate-900 dark:via-slate-900 dark:to-slate-950" />
<div className="pointer-events-none absolute top-0 left-1/2 h-[600px] w-[800px] -translate-x-1/2 rounded-full bg-gradient-to-br from-sky-100/60 via-cyan-50/40 to-amber-50/30 blur-3xl dark:from-sky-900/20 dark:via-slate-800/30 dark:to-amber-900/10" />
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700 dark:border-sky-800 dark:bg-sky-900/50 dark:text-sky-300">
<span>🎒</span>
<span>{t("badge")}</span>
</div>
<h1 className="mx-auto max-w-4xl text-4xl font-extrabold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl lg:text-7xl">
<span className="gradient-text">{t("title")}</span>
<br className="sm:hidden" />
{t("titleSuffix")}
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-slate-600 dark:text-slate-400 sm:text-xl">
{t("subtitle")}
<br className="hidden sm:block" />
<span className="text-slate-400 dark:text-slate-500 sm:text-slate-600">
{t("subtitleEn")}
</span>
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4">
<a
href="#roadmap"
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-sky-500 px-8 py-3.5 text-base font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30 sm:w-auto"
>
{t("ctaStart")}
<span>→</span>
</a>
<a
href="#community"
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-slate-200 bg-white px-8 py-3.5 text-base font-semibold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-700 sm:w-auto"
>
{t("ctaResources")}
</a>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { usePayStatusPoll } from "@/app/digital/lib/payment/usePayStatusPoll";
/**
* 全局支付轮询:支付返回任意页面时检测 paid,调用 confirm 后跳转首页
*/
export function PayStatusPollProvider() {
usePayStatusPoll((_orderId) => {
window.location.href = "/";
});
return null;
}

View File

@@ -0,0 +1,274 @@
"use client";
import { useState, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "@/app/digital/i18n/navigation";
import type { ResourceData } from "@/app/digital/lib/theme-data";
const PER_PAGE_MOBILE = 4;
const PER_PAGE_PC = 8;
const BREAKPOINT = 768;
function useIsPC() {
return useSyncExternalStore(
(callback) => {
if (typeof window === "undefined") return () => {};
const mq = window.matchMedia(`(min-width: ${BREAKPOINT}px)`);
const fn = () => callback();
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
},
() =>
typeof window !== "undefined" &&
window.matchMedia(`(min-width: ${BREAKPOINT}px)`).matches,
() => false
);
}
/** 每页条数:H5=4,PC=8;总数不足则全显 */
function getPerPage(total: number, isPC: boolean): number {
const cap = isPC ? PER_PAGE_PC : PER_PAGE_MOBILE;
return total <= cap ? total : cap;
}
type ResourceNavigationProps = {
data: ResourceData;
};
export default function ResourceNavigation({ data }: ResourceNavigationProps) {
const { t } = useTranslation("community");
const isPC = useIsPC();
const firstCategoryId = data.pan.categories[0]?.id ?? "";
const [activePanCategory, setActivePanCategory] = useState(firstCategoryId);
const [booksPage, setBooksPage] = useState(0);
const [productsPage, setProductsPage] = useState(0);
const [toast, setToast] = useState(false);
const showUpdating = () => {
setToast(true);
setTimeout(() => setToast(false), 2000);
};
const activeCategory = data.pan.categories.find((c) => c.id === activePanCategory);
const booksPerPage = getPerPage(data.books.length, isPC);
const productsPerPage = getPerPage(data.products.length, isPC);
const booksTotalPages = Math.ceil(data.books.length / booksPerPage) || 1;
const productsTotalPages = Math.ceil(data.products.length / productsPerPage) || 1;
const safeBooksPage = Math.max(0, Math.min(booksPage, booksTotalPages - 1));
const safeProductsPage = Math.max(0, Math.min(productsPage, productsTotalPages - 1));
const paginatedBooks = useMemo(() => {
const start = safeBooksPage * booksPerPage;
return data.books.slice(start, start + booksPerPage);
}, [data.books, safeBooksPage, booksPerPage]);
const paginatedProducts = useMemo(() => {
const start = safeProductsPage * productsPerPage;
return data.products.slice(start, start + productsPerPage);
}, [data.products, safeProductsPage, productsPerPage]);
return (
<div className="mt-12 space-y-16">
{/* 图书 - 封面+书名,数量少则大封面,多则分页 */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<span>📚</span>
{t("resourceSections.books")}
{data.books.length > 0 && (
<span className="text-sm font-normal text-slate-500 dark:text-slate-400">
({t("resourceSections.totalBooks").replace("{count}", String(data.books.length))})
</span>
)}
</h3>
<div
className="grid grid-cols-2 gap-3 gap-y-4 sm:gap-4 md:grid-cols-8"
>
{paginatedBooks.map((book, i) => (
<button
key={`book-${booksPage}-${i}`}
type="button"
onClick={showUpdating}
className="group flex cursor-not-allowed flex-col text-left"
>
<div
className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100 shadow-sm transition-all group-hover:shadow-md dark:border-slate-700 dark:bg-slate-800"
style={{ aspectRatio: "2/3" }}
>
<img
src={book.cover}
alt={book.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<p className="mt-2 line-clamp-2 text-center text-xs font-medium text-slate-700 dark:text-slate-300 sm:text-sm">
{book.title}
</p>
</button>
))}
</div>
{booksTotalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setBooksPage((p) => Math.max(0, p - 1))}
disabled={safeBooksPage === 0}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="上一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-4 text-sm text-slate-600 dark:text-slate-400">
{safeBooksPage + 1} / {booksTotalPages}
</span>
<button
type="button"
onClick={() => setBooksPage((p) => Math.min(booksTotalPages - 1, p + 1))}
disabled={safeBooksPage === booksTotalPages - 1}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="下一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
{/* 商业数码 - 商品图+跳转电商 */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<span>🛒</span>
{t("resourceSections.products")}
{data.products.length > 0 && (
<span className="text-sm font-normal text-slate-500 dark:text-slate-400">
({t("resourceSections.totalProducts").replace("{count}", String(data.products.length))})
</span>
)}
</h3>
<div
className="grid grid-cols-2 gap-3 gap-y-4 sm:gap-4 md:grid-cols-8"
>
{paginatedProducts.map((product, i) => (
<button
key={`product-${productsPage}-${i}`}
type="button"
onClick={showUpdating}
className="group flex cursor-not-allowed flex-col text-left"
>
<div
className="overflow-hidden rounded-xl border border-slate-200 bg-white p-3 shadow-sm transition-all group-hover:shadow-md dark:border-slate-700 dark:bg-slate-900"
style={{ aspectRatio: "1/1" }}
>
<img
src={product.image}
alt={product.name}
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-105"
/>
</div>
<p className="mt-2 line-clamp-2 text-center text-xs font-medium text-slate-700 dark:text-slate-300 sm:text-sm">
{product.name}
</p>
</button>
))}
</div>
{productsTotalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setProductsPage((p) => Math.max(0, p - 1))}
disabled={safeProductsPage === 0}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="上一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="px-4 text-sm text-slate-600 dark:text-slate-400">
{safeProductsPage + 1} / {productsTotalPages}
</span>
<button
type="button"
onClick={() => setProductsPage((p) => Math.min(productsTotalPages - 1, p + 1))}
disabled={safeProductsPage === productsTotalPages - 1}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
aria-label="下一页"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div>
{/* 网盘 - 分类 Tab */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-slate-100">
<span>☁️</span>
{t("resourceSections.pan")}
</h3>
<div className="rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
<div className="flex flex-wrap gap-1 border-b border-slate-200 p-2 dark:border-slate-700">
{data.pan.categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setActivePanCategory(cat.id)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activePanCategory === cat.id
? "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
<span className="mr-1.5">{cat.icon}</span>
{cat.title}
</button>
))}
</div>
<div className="p-4">
{activeCategory && (
<div
className="grid gap-3"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 260px), 1fr))",
}}
>
{activeCategory.items.map((item, i) => (
<a
key={i}
href={item.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="flex items-center gap-3 rounded-xl border border-slate-100 p-3 transition-colors hover:border-sky-200 hover:bg-sky-50/50 dark:border-slate-700 dark:hover:border-sky-800 dark:hover:bg-sky-900/20"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-lg dark:bg-slate-800">
{activeCategory.icon}
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-900 dark:text-slate-100">{item.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{item.desc}</div>
</div>
<span className="shrink-0 text-sm font-medium text-sky-600">→</span>
</a>
))}
</div>
)}
</div>
</div>
</div>
{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>
)}
</div>
);
}

View File

@@ -0,0 +1,175 @@
const resources = [
{
lang: "中文",
tag: "📖 入门指南",
title: "数字游民完全入门手册",
desc: "从概念到实践,系统化的数字游民入门指南,含远程工作、签证、财务全攻略",
source: "少数派",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
{
lang: "EN",
tag: "📖 经典书籍",
title: "The 4-Hour Workweek — Tim Ferriss",
desc: "数字游民圣经,重新定义工作与生活的关系,构建「新富族」生活方式",
source: "Amazon",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
{
lang: "中文",
tag: "☁️ 远程求职",
title: "国内远程工作机会汇总 — 电鸭社区",
desc: "最大的中文远程工作社区,收录数千个远程岗位,涵盖开发、设计、运营等领域",
source: "电鸭社区",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
{
lang: "EN",
tag: "🗺️ 目的地",
title: "Nomad List — Best Cities for Digital Nomads",
desc: "全球数字游民城市数据库,含生活成本、网速、安全指数、社区活跃度等指标",
source: "Nomad List",
color: "bg-violet-50 text-violet-700 border-violet-200",
},
{
lang: "中文",
tag: "📹 视频教程",
title: "从程序员到数字游民 — 我的转型之路",
desc: "真实经历分享:如何从 996 转型为自由的数字游民,含收入、签证、生活细节",
source: "Bilibili",
color: "bg-rose-50 text-rose-700 border-rose-200",
},
{
lang: "EN",
tag: "💰 财务规划",
title: "Tax Planning for Digital Nomads — Complete Guide",
desc: "数字游民税务规划权威指南:税务居民身份、双重征税协定、合规架构搭建",
source: "SafetyWing",
color: "bg-teal-50 text-teal-700 border-teal-200",
},
{
lang: "中文",
tag: "🛠️ 工具评测",
title: "远程工作者必备工具清单 2026",
desc: "50+ 工具横评:协作、会议、设计、开发、生产力工具深度体验与推荐",
source: "即刻",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
{
lang: "EN",
tag: "🔬 深度文章",
title: "State of Digital Nomads 2026 — Annual Report",
desc: "2026年度数字游民状态报告:3500万全球数字游民的收入、工作、生活数据洞察",
source: "A Brother Abroad",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
{
lang: "中文",
tag: "📱 社区",
title: "数字游民部落 — 中文社区指南",
desc: "连接全球数字游民:经验分享、城市攻略、合租搭伴、线下 Meetup",
source: "微信公众号",
color: "bg-cyan-50 text-cyan-700 border-cyan-200",
},
{
lang: "EN",
tag: "📋 签证",
title: "Digital Nomad Visa Guide — 50+ Countries",
desc: "全球 50+ 国家数字游民签证政策汇总,含申请条件、费用、停留期限对比",
source: "VisaGuide.World",
color: "bg-lime-50 text-lime-700 border-lime-200",
},
{
lang: "中文",
tag: "🏠 共居空间",
title: "全球最佳 Coliving 空间推荐",
desc: "精选 30+ 数字游民共居空间:巴厘岛、清迈、里斯本、麦德林……含价格与评测",
source: "数字游民部落",
color: "bg-pink-50 text-pink-700 border-pink-200",
},
{
lang: "EN",
tag: "💼 自由职业",
title: "Freelancing 101 — From Zero to First Client",
desc: "从零开始接单指南:Upwork/Fiverr/Toptal 平台对比、定价策略、客户沟通技巧",
source: "freeCodeCamp",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
];
const sourceStats = [
{ value: "200+", label: "篇资源" },
{ value: "80", label: "中文资源" },
{ value: "120+", label: "英文资源" },
{ value: "12", label: "大分类" },
];
export default function Resources() {
return (
<section id="resources" className="bg-slate-50 py-20 sm:py-28">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="mb-2 text-sm font-semibold text-slate-400">
📚 精选收录
</div>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
全网<span className="gradient-text">优质资源</span>聚合
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600">
从少数派到 Nomad List,从 B站 到 freeCodeCamp
—— 一站式获取数字游民最佳资源。
</p>
</div>
{/* Stats */}
<div className="mt-10 flex flex-wrap items-center justify-center gap-4 text-sm">
{sourceStats.map((s) => (
<div key={s.label} className="flex items-center gap-1.5">
<span className="font-bold text-sky-600">{s.value}</span>
<span className="text-slate-500">{s.label}</span>
</div>
))}
</div>
{/* Resource grid */}
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{resources.map((r, i) => (
<div
key={i}
className="resource-card flex flex-col rounded-xl border border-slate-100 bg-white p-5 shadow-sm"
>
<div className="mb-3 flex items-center gap-2">
<span
className={`rounded-md border px-2 py-0.5 text-xs font-semibold ${r.color}`}
>
{r.lang}
</span>
<span className="text-xs text-slate-400">{r.tag}</span>
</div>
<h3 className="mb-2 text-sm font-bold leading-snug text-slate-900">
{r.title}
</h3>
<p className="mb-4 flex-1 text-xs leading-relaxed text-slate-500">
{r.desc}
</p>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-400">
{r.source}
</span>
<span className="cursor-pointer text-xs font-medium text-sky-600 transition-colors hover:text-sky-700">
访问 ↗
</span>
</div>
</div>
))}
</div>
<div className="mt-10 text-center">
<button className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-6 py-3 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50">
查看全部 200+ 篇资源 →
</button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
const days = [
{ day: 1, icon: "👋", color: "bg-sky-500" },
{ day: 2, icon: "💡", color: "bg-cyan-500" },
{ day: 3, icon: "💰", color: "bg-teal-500" },
{ day: 4, icon: "🛠️", color: "bg-emerald-500" },
{ day: 5, icon: "📋", color: "bg-amber-500" },
{ day: 6, icon: "🗺️", color: "bg-orange-500" },
{ day: 7, icon: "🚀", color: "bg-rose-500" },
];
export default function Roadmap() {
const { t } = useTranslation("roadmap");
return (
<section id="roadmap" className="bg-slate-50 py-20 sm:py-28 dark:bg-slate-900">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl">
{t("title")} <span className="gradient-text">{t("titleHighlight")}</span>
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
{t("subtitle")}
</p>
</div>
{/* 7天入门文档 - 独立区块 */}
<div className="mt-14">
<h3 className="mb-4 text-center text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{t("daysSection")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{days.map((d) => (
<Link
key={d.day}
href={`/day/${d.day}`}
className="card-hover group flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-sky-200 hover:shadow-md dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-600"
>
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white ${d.color}`}
>
DAY {d.day}
</div>
<span className="text-xl">{d.icon}</span>
<h3 className="text-base font-bold text-slate-900 group-hover:text-sky-600 dark:text-slate-100 dark:group-hover:text-sky-400">
{t(`days.${d.day}.title`)}
</h3>
</div>
<p className="mt-3 flex-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{t(`days.${d.day}.desc`)}
</p>
<span className="mt-3 inline-flex items-center text-sm font-medium text-sky-600 dark:text-sky-400">
{t("viewDetails")}
</span>
</Link>
))}
</div>
</div>
{/* 电子书 + 实战课 - 平级展示 */}
<div className="mt-16">
<h3 className="mb-6 text-center text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{t("productsSection")}
</h3>
<div className="grid gap-6 sm:grid-cols-2">
<Link
href="/ebook"
className="group flex flex-col overflow-hidden rounded-2xl border-2 border-slate-200 bg-gradient-to-br from-sky-50 to-cyan-50/50 p-8 shadow-md transition-all hover:border-sky-300 hover:shadow-lg dark:border-slate-700 dark:from-sky-900/20 dark:to-cyan-900/10 dark:hover:border-sky-600"
>
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-500 text-3xl shadow-lg shadow-sky-200/50 dark:shadow-sky-900/30">
📖
</div>
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{t("ebook.title")}
</h3>
<p className="mt-2 flex-1 text-slate-600 dark:text-slate-400">
{t("ebook.desc")}
</p>
<span className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-sky-600 dark:text-sky-400">
{t("ebook.cta")}
</span>
</Link>
<Link
href="/course"
className="group flex w-full flex-col overflow-hidden rounded-2xl border-2 border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50/50 p-8 text-left shadow-md transition-all hover:border-amber-300 hover:shadow-lg dark:border-amber-800 dark:from-amber-900/20 dark:to-orange-900/10 dark:hover:border-amber-700"
>
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-3xl shadow-lg shadow-amber-200/50 dark:shadow-amber-900/30">
🎓
</div>
<span className="mb-2 inline-block text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
{t("course.tag")}
</span>
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{t("course.title")}
</h3>
<p className="mt-2 flex-1 text-slate-600 dark:text-slate-400">
{t("course.desc")}
</p>
<span className="mt-6 inline-flex items-center gap-2 self-start rounded-full bg-amber-500 px-6 py-3 font-semibold text-white transition group-hover:bg-amber-600 dark:bg-amber-600 dark:group-hover:bg-amber-500">
{t("course.cta")}
</span>
</Link>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useTranslation } from "@/app/digital/i18n/navigation";
import { getThemeConfig } from "@/config/digital";
export default function ShopCard() {
const { t } = useTranslation("shop");
const config = getThemeConfig();
const shopUrl = config.services?.shopUrl;
if (!shopUrl) return null;
return (
<div className="mb-12">
<a
href={shopUrl}
target="_blank"
rel="noopener noreferrer"
className="group block overflow-hidden rounded-2xl border border-emerald-200/80 bg-gradient-to-br from-emerald-50 via-white to-teal-50/60 p-8 shadow-sm transition-all duration-300 hover:border-emerald-300 hover:shadow-xl hover:shadow-emerald-100/50 dark:border-emerald-800/80 dark:from-emerald-900/30 dark:via-slate-900 dark:to-teal-900/20 dark:hover:border-emerald-700 dark:hover:shadow-emerald-900/30 sm:p-10"
>
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-emerald-100 px-4 py-1.5 text-sm font-semibold text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300">
<span className="text-lg">🛒</span>
{t("badge")}
</div>
<h3 className="mb-3 text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-3xl">
{t("title")}
</h3>
<p className="mb-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
{t("desc")}
</p>
</div>
<div className="flex shrink-0 items-center gap-4 rounded-2xl bg-white/90 p-6 shadow-md backdrop-blur-sm transition-transform dark:bg-slate-800/90 group-hover:scale-[1.02] sm:p-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-3xl shadow-lg shadow-emerald-200/50">
🛍️
</div>
<div className="text-left">
<div className="font-bold text-slate-800 dark:text-slate-200">{t("cta")}</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t("ctaHint")}</div>
</div>
</div>
</div>
</a>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
const PLATFORM_ICONS: Record<string, React.ReactNode> = {
// 国内平台
douyin: (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
),
"wechat-official": (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor">
<path d="M8.5 6C5.46 6 3 8.24 3 11c0 1.64.72 3.14 1.87 4.17L3 17l2.13-1.13A6.5 6.5 0 0 0 8.5 16c3.04 0 5.5-2.24 5.5-5s-2.46-5-5.5-5zm0 8c-1.38 0-2.63-.56-3.54-1.46C4.43 11.54 4 10.31 4 9s.43-2.54 1.46-3.54C6.37 4.56 7.62 4 9 4s2.63.56 3.54 1.46c.93.93 1.46 2.16 1.46 3.54s-.53 2.61-1.46 3.54A4.99 4.99 0 0 1 8.5 14z" />
</svg>
),
xiaohongshu: (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z" />
</svg>
),
// 海外平台
tiktok: (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
),
youtube: (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
),
twitter: (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
),
};
export default function SocialPlatformIcon({ platform }: { platform: string }) {
return (
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{PLATFORM_ICONS[platform] ?? (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93z" />
</svg>
)}
</span>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useTheme } from "../context/ThemeContext";
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
type="button"
onClick={toggleTheme}
className="rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
aria-label={theme === "dark" ? "切换到日间模式" : "切换到夜间模式"}
title={theme === "dark" ? "日间模式" : "夜间模式"}
>
{theme === "dark" ? (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
) : (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
)}
</button>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useTranslation } from "@/app/digital/i18n/navigation";
import type { ToolsCategory } from "@/app/digital/lib/theme-data";
type ToolsProps = {
data: { categories: ToolsCategory[] };
};
export default function Tools({ data }: ToolsProps) {
const categories = data.categories;
const { t } = useTranslation("tools");
return (
<section id="tools" className="py-20 sm:py-28 dark:bg-slate-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700 dark:border-sky-800 dark:bg-sky-900/50 dark:text-sky-300">
{t("badge")}
</div>
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
<span className="gradient-text">{t("title")}</span>
</h2>
<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-14 grid gap-5 overflow-visible sm:grid-cols-2 lg:grid-cols-4">
{categories.map((cat) => (
<div
key={cat.title}
className="card-hover group overflow-visible rounded-2xl border border-slate-100 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div className={`h-1.5 bg-gradient-to-r ${cat.color}`} />
<div className="p-5">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{cat.icon}</span>
<h3 className="font-bold text-slate-900 dark:text-slate-100">{cat.title}</h3>
</div>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cat.bg} ${cat.text}`}>
{cat.tools.length}
</span>
</div>
<div className="space-y-2">
{cat.tools.map((tool) => (
<a
key={tool.name}
href={tool.href}
target="_blank"
rel="noopener noreferrer"
className="group/item flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm transition-colors hover:bg-slate-100/80 hover:text-sky-600 dark:bg-slate-800 dark:hover:bg-slate-700 dark:hover:text-sky-400"
>
<span className="font-medium text-slate-700 dark:text-slate-300">{tool.name}</span>
{tool.desc && (
<span className="relative shrink-0">
<span className="pointer-events-none absolute bottom-full right-0 z-10 mb-1 w-max max-w-[90vw] whitespace-normal break-words rounded-lg bg-slate-800 px-3 py-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/item:opacity-100">
{tool.desc}
</span>
</span>
)}
</a>
))}
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "@/app/digital/i18n/navigation";
import { pbLogout } from "@/app/digital/lib/pocketbase";
function getAvatarLetter(email: string): string {
const local = email.split("@")[0];
if (!local) return "?";
return local.slice(0, 1).toUpperCase();
}
interface UserAvatarProps {
email: string;
vip?: boolean;
onLogout?: () => void;
onRefreshVip?: () => void;
}
/** 圆形头像 + VIP 标识 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */
export default function UserAvatar({ email, vip, onLogout }: UserAvatarProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleLogout = async () => {
await pbLogout();
setOpen(false);
onLogout?.();
};
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-slate-900 hover:bg-sky-600"
aria-label={t("logout")}
>
{getAvatarLetter(email)}
{vip && (
<span
className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-amber-400 text-[10px] text-amber-900"
title="VIP"
>
👑
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
{vip && (
<div className="border-b border-slate-100 px-4 py-2 text-xs font-medium text-amber-600 dark:border-slate-700 dark:text-amber-400">
👑 VIP
</div>
)}
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
>
{t("logout")}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { Section } from "./Section";
import { AUDIENCE_CONFIG } from "@/config/digital/course";
export function AudienceSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{AUDIENCE_CONFIG.title}
</h2>
<p className="mx-auto mb-10 max-w-[17rem] break-all text-center text-[var(--muted-foreground)] sm:mb-12 sm:max-w-2xl md:mb-16">
{AUDIENCE_CONFIG.subtitle}
</p>
<div className="grid gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
{AUDIENCE_CONFIG.items.map((a) => (
<div
key={a.title}
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:bg-[var(--muted)] hover:-translate-y-0.5 sm:rounded-2xl sm:p-6"
>
<div className="mb-3 text-2xl sm:mb-4 sm:text-3xl">{a.emoji}</div>
<h3 className="mb-2 font-semibold">{a.title}</h3>
<p className="text-sm text-[var(--muted-foreground)]">{a.desc}</p>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,31 @@
import { Section } from "./Section";
import { BENEFITS_CONFIG } from "@/config/digital/course";
export function BenefitsSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{BENEFITS_CONFIG.title}
</h2>
<p className="mx-auto mb-10 max-w-[17rem] break-all text-center text-[var(--muted-foreground)] sm:mb-12 sm:max-w-2xl md:mb-16">
{BENEFITS_CONFIG.subtitle}
</p>
<div className="space-y-4 sm:space-y-6">
{BENEFITS_CONFIG.items.map((b) => (
<div
key={b.num}
className="hover-shimmer flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:-translate-y-0.5 sm:flex-row sm:gap-6 sm:rounded-2xl sm:p-6"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--accent-muted)] text-base font-bold text-[var(--accent)] sm:h-12 sm:w-12 sm:text-lg">
{b.num}
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-1 font-semibold">{b.title}</h3>
<p className="text-sm text-[var(--muted-foreground)]">{b.desc}</p>
</div>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { Link } from "@/app/digital/i18n/navigation";
import { CTA_CONFIG } from "@/config/digital/course";
import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
import AuthModal from "@/app/digital/components/AuthModal";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/digital/lib/payment";
import { useState } from "react";
export function CTASection() {
const { user, vip, refetch } = useAuthAndVip();
const paid = !!user && vip;
const [authOpen, setAuthOpen] = useState(false);
const [payRedirecting, setPayRedirecting] = useState(false);
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);
}
};
return (
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
<h2 className="mb-4 text-xl font-bold sm:text-2xl md:text-3xl">{CTA_CONFIG.title}</h2>
<p className="mx-auto mb-6 max-w-[17rem] break-all text-[var(--muted-foreground)] sm:mb-8 sm:max-w-2xl">{CTA_CONFIG.subtitle}</p>
<div className="mb-8 flex flex-wrap justify-center gap-4 text-sm text-[var(--muted-foreground)] sm:mb-10 sm:gap-6">
{CTA_CONFIG.badges.map((b, i) => (
<span
key={b}
className="animate-float-badge rounded-full border border-[var(--border)] bg-[var(--card)] px-4 py-2"
style={{ animationDelay: `${i * 0.2}s` }}
>
{b}
</span>
))}
</div>
{paid ? (
<Link
href="/course/0/0"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
>
开始学习 →
</Link>
) : (
<button
type="button"
onClick={handleEnroll}
disabled={payRedirecting}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition hover:opacity-95 disabled:opacity-70 sm:px-10"
>
{payRedirecting ? "跳转支付中..." : "立即报名 →"}
</button>
)}
</div>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => {
setAuthOpen(false);
void refetch();
}}
/>
</section>
);
}

View File

@@ -0,0 +1,169 @@
"use client";
import { useState, useEffect } from "react";
import { Link } from "@/app/digital/i18n/navigation";
import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/digital/course";
import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
import { getLastWatched, getProgress } from "@/app/digital/lib/learning";
import AuthModal from "@/app/digital/components/AuthModal";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/digital/lib/payment";
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export function CourseHero() {
const { user, vip, refetch } = useAuthAndVip();
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
const [authOpen, setAuthOpen] = useState(false);
const [payRedirecting, setPayRedirecting] = useState(false);
useEffect(() => {
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
const onProgress = () => {
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
};
window.addEventListener("learning:progress", onProgress);
return () => window.removeEventListener("learning:progress", onProgress);
}, []);
const paid = !!user && vip;
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);
}
};
return (
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
<div className="relative mx-auto max-w-4xl px-4 text-center sm:px-6">
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl lg:text-6xl">
{HERO_CONFIG.title}
</h1>
<p className="mx-auto mb-10 max-w-[18rem] whitespace-normal break-all text-lg leading-relaxed text-[var(--muted-foreground)] sm:mb-12 sm:max-w-3xl sm:text-xl md:text-2xl">
{HERO_CONFIG.subtitle}
</p>
<div className="mb-10 flex flex-wrap justify-center gap-6 sm:mb-12 sm:gap-8">
{HERO_CONFIG.stats.map((s) => (
<div key={s.label} className="text-center transition-transform duration-300 hover:scale-105">
<div className="text-2xl font-bold text-[var(--accent)] sm:text-3xl md:text-4xl">{s.value}</div>
<div className="mt-1 text-xs text-[var(--muted-foreground)] sm:text-sm">{s.label}</div>
</div>
))}
</div>
<div className="flex flex-wrap justify-center gap-3 sm:gap-4">
{paid && last ? (
<Link
href={`/course/${last.moduleIndex}/${last.lessonIndex}`}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
继续学习 →
</Link>
) : paid ? (
<Link
href="/course/0/0"
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
进入课程 →
</Link>
) : (
<button
type="button"
onClick={handleEnroll}
disabled={payRedirecting}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-70 sm:px-10"
>
{payRedirecting ? "跳转支付中..." : "立即报名 →"}
</button>
)}
</div>
{paid && progress.completed > 0 && (
<div className="mt-6 w-full max-w-xs">
<div className="mb-1 flex justify-between text-xs text-[var(--muted-foreground)]">
<span>学习进度</span>
<span>{progress.completed}/{TOTAL_LESSONS} 节</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress.percent}%` }} />
</div>
</div>
)}
</div>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => {
setAuthOpen(false);
void refetch();
}}
/>
</section>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useState, useEffect } from "react";
import { Link } from "@/app/digital/i18n/navigation";
import { Section } from "./Section";
import { CURRICULUM_CONFIG } from "@/config/digital/course";
import { canWatchLesson, isFreeTrial } from "@/app/digital/lib/course-payment";
import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/digital/lib/learning";
export function CurriculumSection() {
const { vip } = useAuthAndVip();
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
);
const [search, setSearch] = useState("");
const [bookmarks, setBookmarks] = useState<Set<string>>(() =>
typeof window === "undefined" ? new Set() : getBookmarks()
);
const [completed, setCompleted] = useState<Set<string>>(() =>
typeof window === "undefined" ? new Set() : getCompletedLessons()
);
useEffect(() => {
const onB = () => setBookmarks(getBookmarks());
const onP = () => setCompleted(getCompletedLessons());
window.addEventListener("learning:bookmarks", onB);
window.addEventListener("learning:progress", onP);
return () => {
window.removeEventListener("learning:bookmarks", onB);
window.removeEventListener("learning:progress", onP);
};
}, []);
const toggle = (i: number) => {
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
};
const allLessons = CURRICULUM_CONFIG.modules.flatMap((m, mi) =>
m.lessons.map((l, li) => ({ moduleIndex: mi, lessonIndex: li, ...l, moduleTitle: m.title }))
);
const filtered = search.trim()
? allLessons.filter(
(l) =>
l.name.toLowerCase().includes(search.toLowerCase()) ||
l.moduleTitle.toLowerCase().includes(search.toLowerCase())
)
: allLessons;
const filteredByModule = CURRICULUM_CONFIG.modules
.map((m, mi) => ({
...m,
lessons: filtered.filter((l) => l.moduleIndex === mi),
}))
.filter((m) => m.lessons.length > 0);
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{CURRICULUM_CONFIG.title}
</h2>
<p className="mb-6 text-center text-[var(--muted-foreground)] sm:mb-8 md:mb-10">
{CURRICULUM_CONFIG.subtitle}
</p>
<div className="mx-auto mb-8 max-w-md">
<input
type="search"
placeholder="搜索课程..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-full border border-[var(--border)] bg-[var(--background)] px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
</div>
<div className="space-y-2 md:space-y-3">
{filteredByModule.map((c) => {
const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title);
const isOpen = expanded[moduleIndex] ?? true;
return (
<div
key={c.title}
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] md:rounded-2xl"
>
<button
type="button"
onClick={() => toggle(moduleIndex)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5 sm:py-4"
>
<span className="flex items-center gap-2 text-base font-semibold sm:text-lg">
<span
className={`transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
>
▶
</span>
<span>{c.emoji}</span>
{c.title}
</span>
<span className="text-sm text-[var(--muted-foreground)]">
{c.lessons.length} 节
</span>
</button>
{isOpen && (
<ul className="border-t border-[var(--border)]">
{c.lessons.map((l) => {
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex, vip);
const key = `${l.moduleIndex}-${l.lessonIndex}`;
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
const isCompleted = completed.has(key);
return (
<li key={key}>
<Link
href={`/course/${l.moduleIndex}/${l.lessonIndex}`}
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)] sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
>
<span className="flex items-center gap-2 truncate sm:gap-3">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleBookmark(l.moduleIndex, l.lessonIndex);
}}
className="shrink-0 text-xs"
aria-label={bookmarked ? "取消收藏" : "收藏"}
>
{bookmarked ? "⭐" : "☆"}
</button>
<span className={unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}>
{l.name}
</span>
{isFreeTrial(l.moduleIndex, l.lessonIndex) && (
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
试看
</span>
)}
{isCompleted && (
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-400">
✓
</span>
)}
{!unlocked && (
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
🔒
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-[var(--muted-foreground)] sm:text-sm">
{l.duration}
<span className="text-[var(--accent)]">→</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
</Section>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Section } from "./Section";
import { FAQ_CONFIG } from "@/config/digital/course";
export function FAQSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<Section>
<h2 className="mb-10 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{FAQ_CONFIG.title}
</h2>
<div className="mx-auto max-w-2xl space-y-2">
{FAQ_CONFIG.items.map((item, i) => (
<div
key={i}
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] transition"
>
<button
type="button"
onClick={() => setOpenIndex(openIndex === i ? null : i)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5"
>
<span className="font-medium">{item.q}</span>
<span
className={`shrink-0 text-[var(--muted-foreground)] transition-transform ${openIndex === i ? "rotate-180" : ""}`}
>
▼
</span>
</button>
{openIndex === i && (
<div className="border-t border-[var(--border)] px-4 py-3 text-sm text-[var(--muted-foreground)] sm:px-5">
{item.a}
</div>
)}
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,37 @@
import { Section } from "./Section";
import { INSTRUCTORS_CONFIG } from "@/config/digital/course";
export function InstructorsSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{INSTRUCTORS_CONFIG.title}
</h2>
<p className="mx-auto mb-10 max-w-[17rem] break-all text-center text-[var(--muted-foreground)] sm:mb-12 sm:max-w-2xl md:mb-16">
{INSTRUCTORS_CONFIG.subtitle}
</p>
<div className="grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
{INSTRUCTORS_CONFIG.items.map((i) => (
<div
key={i.name}
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-5 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-[var(--accent)]/30 sm:rounded-2xl sm:p-6"
>
<div className="mb-3 flex justify-center sm:mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-muted)] text-xl font-bold text-[var(--accent)] sm:h-20 sm:w-20 sm:text-2xl">
{i.name.slice(0, 1)}
</div>
</div>
<h3 className="mb-1 font-semibold">{i.name}</h3>
<p className="mb-1 text-sm text-[var(--accent)]">{i.role}</p>
<p className="mb-2 text-sm text-[var(--muted-foreground)]">
{i.desc}
</p>
<span className="inline-block rounded-full bg-[var(--accent-muted)] px-3 py-1 text-xs text-[var(--accent)]">
{i.tag}
</span>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,17 @@
import { type ReactNode } from "react";
type SectionProps = {
children: ReactNode;
className?: string;
};
/** 通用区块容器 - 统一内边距与最大宽度,响应式(来自 nomadlms) */
export function Section({ children, className = "" }: SectionProps) {
return (
<section
className={`border-t border-[var(--border)] py-12 md:py-16 lg:py-20 ${className}`}
>
<div className="mx-auto max-w-6xl px-4 sm:px-6">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,362 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const STORAGE_PREFIX = "lms-video-";
const SAVE_INTERVAL = 5000;
const MIN_PROGRESS_TO_SHOW = 10;
function getStoredProgress(videoId: string): number {
if (typeof window === "undefined") return 0;
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${videoId}`);
if (!raw) return 0;
const data = JSON.parse(raw);
return typeof data?.time === "number" ? data.time : 0;
} catch {
return 0;
}
}
function saveProgress(videoId: string, time: number): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(`${STORAGE_PREFIX}${videoId}`, JSON.stringify({ time }));
} catch {
/* ignore */
}
}
type VideoPlayerProps = {
src: string;
poster?: string;
title?: string;
className?: string;
videoId?: string;
onComplete?: () => void;
};
export function VideoPlayer({ src, poster, title, className = "", videoId, onComplete }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speed, setSpeed] = useState(1);
const [showControls, setShowControls] = useState(true);
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [savedTime, setSavedTime] = useState(0);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSaveRef = useRef(0);
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play();
setPlaying(true);
} else {
v.pause();
setPlaying(false);
}
}, []);
const handleTimeUpdate = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const time = v.currentTime;
const dur = v.duration;
setCurrentTime(time);
setProgress(Number.isFinite(dur) && dur > 0 ? (time / dur) * 100 : 0);
if (videoId && v.currentTime > 0) {
const now = Date.now();
if (now - lastSaveRef.current > SAVE_INTERVAL) {
lastSaveRef.current = now;
saveProgress(videoId, v.currentTime);
}
}
}, [videoId]);
const handleLoadedMetadata = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
setDuration(dur);
if (videoId) {
const stored = getStoredProgress(videoId);
if (Number.isFinite(stored) && stored >= MIN_PROGRESS_TO_SHOW && stored < dur - 5) {
setSavedTime(stored);
setShowResumePrompt(true);
}
}
}, [videoId]);
const handleEnded = useCallback(() => {
setPlaying(false);
setProgress(0);
setCurrentTime(0);
if (videoId) saveProgress(videoId, 0);
onComplete?.();
}, [videoId, onComplete]);
const handlePause = useCallback(() => {
const v = videoRef.current;
if (videoId && v && v.currentTime > 0) {
saveProgress(videoId, v.currentTime);
}
}, [videoId]);
const handleResume = useCallback(() => {
const v = videoRef.current;
if (!v || savedTime <= 0) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
const time = Math.min(savedTime, dur - 0.1);
v.currentTime = time;
setCurrentTime(time);
setProgress((time / dur) * 100);
v.play();
setPlaying(true);
setShowResumePrompt(false);
}, [savedTime]);
const handleRestart = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (videoId) saveProgress(videoId, 0);
v.currentTime = 0;
setCurrentTime(0);
setProgress(0);
setShowResumePrompt(false);
}, [videoId]);
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const v = videoRef.current;
if (!v) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width <= 0) return;
const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const time = p * dur;
v.currentTime = time;
setCurrentTime(time);
setProgress(p * 100);
}, []);
const toggleMute = useCallback(() => {
const v = videoRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
}, []);
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = videoRef.current;
if (!v) return;
const val = parseFloat(e.target.value);
v.volume = val;
setVolume(val);
setMuted(val === 0);
}, []);
const cycleSpeed = useCallback(() => {
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
const i = speeds.indexOf(speed);
const next = speeds[(i + 1) % speeds.length];
const v = videoRef.current;
if (v) v.playbackRate = next;
setSpeed(next);
}, [speed]);
const toggleFullscreen = useCallback(() => {
const container = videoRef.current?.parentElement;
if (!container) return;
if (!document.fullscreenElement) {
container.requestFullscreen();
} else {
document.exitFullscreen();
}
}, []);
const showControlsTemporarily = useCallback(() => {
setShowControls(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
}, []);
useEffect(() => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }, []);
const formatTime = (s: number) => {
if (!Number.isFinite(s) || s < 0) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
};
const formatResumeTime = (s: number) => {
if (!Number.isFinite(s) || s < 0) return "0秒";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
if (m > 0) return `${m}分${sec}秒`;
return `${sec}秒`;
};
return (
<div
className={`group relative overflow-hidden rounded-xl bg-black md:rounded-2xl ${className}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => setShowControls(false)}
>
<video
ref={videoRef}
src={src}
poster={poster}
title={title}
className="aspect-video w-full object-contain"
onClick={togglePlay}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
onPause={handlePause}
playsInline
/>
{showResumePrompt && (
<div className="absolute bottom-20 left-3 z-10 max-w-[280px] rounded-lg bg-black/85 px-4 py-3 shadow-xl backdrop-blur sm:left-4 sm:bottom-24">
<p className="mb-3 text-sm text-white/90">
上次看到 <span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleResume}
className="flex-1 rounded-md bg-[var(--accent)] px-3 py-2 text-sm font-medium text-black transition hover:opacity-90"
>
从上次继续
</button>
<button
type="button"
onClick={handleRestart}
className="rounded-md border border-white/30 bg-white/10 px-3 py-2 text-sm text-white transition hover:bg-white/20"
>
重新观看
</button>
</div>
</div>
)}
{!playing && (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center bg-black/20 transition hover:bg-black/30"
aria-label="播放"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition hover:scale-110 sm:h-20 sm:w-20">
<svg
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
)}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-3 transition-opacity duration-300 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
<div
className="mb-3 h-1 cursor-pointer rounded-full bg-white/30"
onClick={handleSeek}
role="slider"
aria-valuenow={progress}
>
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-2 sm:gap-4">
<button
type="button"
onClick={togglePlay}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={playing ? "暂停" : "播放"}
>
{playing ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="min-w-[4rem] text-xs text-white/90 sm:text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={toggleMute}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={muted ? "取消静音" : "静音"}
>
{muted || volume === 0 ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={muted ? 0 : volume}
onChange={handleVolumeChange}
className="h-1 w-16 cursor-pointer accent-[var(--accent)] sm:w-20"
/>
</div>
<button
type="button"
onClick={cycleSpeed}
className="rounded px-2 py-1 text-xs font-medium text-white/90 transition hover:bg-white/20 sm:text-sm"
>
{speed}x
</button>
<button
type="button"
onClick={toggleFullscreen}
className="ml-auto rounded p-1 text-white transition hover:bg-white/20"
aria-label="全屏"
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export { Section } from "./Section";
export { VideoPlayer } from "./VideoPlayer";
export { CurriculumSection } from "./CurriculumSection";
export { AudienceSection } from "./AudienceSection";
export { BenefitsSection } from "./BenefitsSection";
export { InstructorsSection } from "./InstructorsSection";
export { FAQSection } from "./FAQSection";
export { CTASection } from "./CTASection";
export { CourseHero } from "./CourseHero";