Files
gitlab-instance-0a899031_di…/app/components/course/CourseHero.tsx
2026-03-13 04:41:51 -05:00

170 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { Link } from "@/i18n/navigation";
import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course";
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
import { getStoredToken, getStoredUser } from "@/app/lib/pocketbase";
import { getLastWatched, getProgress } from "@/app/lib/learning";
import AuthModal from "@/app/components/AuthModal";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/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/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);
}
};
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="mb-10 text-lg text-[var(--muted-foreground)] sm:mb-12 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>
);
}