238 lines
9.6 KiB
TypeScript
238 lines
9.6 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { Link, useTranslation } from "@/i18n/navigation";
|
||
import { VideoPlayer } from "@/app/components/course/VideoPlayer";
|
||
import { LessonMarkdown } from "./LessonMarkdown";
|
||
import { getLesson } from "@/config/lessons";
|
||
import { canWatchLesson, isFreeTrial } from "@/app/lib/course-payment";
|
||
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
|
||
import { getStoredToken, getStoredUser } from "@/app/lib/pocketbase";
|
||
import { setLastWatched, markCompleted } from "@/app/lib/learning";
|
||
import { DOWNLOAD_CONFIG } from "@/config/course";
|
||
import Header from "@/app/components/Header";
|
||
import AuthModal from "@/app/components/AuthModal";
|
||
import {
|
||
getPayEnv,
|
||
redirectToPay,
|
||
getDeviceFromEnv,
|
||
} from "@/app/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/auth/me", { credentials: "include" });
|
||
let meData = await meRes.json().catch(() => ({}));
|
||
if (meData?.user?.id) {
|
||
// 已登录:先校验 VIP,已是 VIP 则无需支付
|
||
const vipRes = await fetch(`/api/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/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/auth/me", { credentials: "include" });
|
||
meData = await meRes.json().catch(() => ({}));
|
||
if (meData?.user?.id) {
|
||
const vipRes = await fetch(`/api/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>
|
||
);
|
||
}
|