This commit is contained in:
eric
2026-03-12 03:54:22 -05:00
parent 421f979e10
commit e7dde61e31
23 changed files with 876 additions and 125 deletions

View File

@@ -7,6 +7,7 @@ import { VideoPlayer } from "@/app/components/course/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
import { getLesson } from "@/config/lessons";
import { canWatchLesson } from "@/app/lib/course-payment";
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
import { setLastWatched, markCompleted } from "@/app/lib/learning";
import { DOWNLOAD_CONFIG } from "@/config/course";
import Header from "@/app/components/Header";
@@ -15,12 +16,13 @@ export default function LessonPage() {
const params = useParams();
const router = useRouter();
const { t } = useTranslation("community");
const { vip } = useAuthAndVip();
const [toast, setToast] = useState(false);
const moduleIndex = Number(params.moduleIndex);
const lessonIndex = Number(params.lessonIndex);
const lesson = getLesson(moduleIndex, lessonIndex);
const unlocked = canWatchLesson(moduleIndex, lessonIndex);
const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip);
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
useEffect(() => {

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect, type FormEvent, type ChangeEvent } from "react";
import { useState, useRef, useEffect, useCallback, type FormEvent, type ChangeEvent } from "react";
import { Link } from "@/i18n/navigation";
import {
getPayEnv,
@@ -11,6 +11,8 @@ import {
} from "@/app/lib/payment";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
import { getStoredToken } from "@/app/lib/pocketbase";
import AuthModal from "@/app/components/AuthModal";
const genderOptions = ["男", "女"];
const singleOptions = ["单身", "恋爱中", "已婚"];
@@ -56,6 +58,18 @@ export default function JoinPage() {
const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
const [payChannel, setPayChannel] = useState<PayChannel>("alipay");
const [authOpen, setAuthOpen] = useState(false);
const checkAuth = useCallback(async () => {
if (getStoredToken()) return true;
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
return !!(data?.user && data?.token);
} catch {
return false;
}
}, []);
useEffect(() => {
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
@@ -79,6 +93,12 @@ export default function JoinPage() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const loggedIn = await checkAuth();
if (!loggedIn) {
setAuthOpen(true);
setError("请先登录或注册后再提交申请");
return;
}
setSubmitting(true);
try {
const res = await fetch("/api/join", {
@@ -515,6 +535,15 @@ export default function JoinPage() {
</Link>
</div>
</div>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => {
setAuthOpen(false);
setError(null);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
return res;
}

61
app/api/auth/me/route.ts Normal file
View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {
return NextResponse.json({ ok: true, user: null });
}
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
const { token, userId, email } = payload;
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/users/refresh`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const r = NextResponse.json({ ok: true, user: null });
r.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
return r;
}
const data = await res.json();
const newToken = data.token;
const newRecord = data.record;
// 刷新成功:回写新 token 到 Cookie保持登录态长期有效
if (newToken && newRecord?.id) {
const newPayload = JSON.stringify({
token: newToken,
userId: newRecord.id,
email: newRecord.email ?? email,
});
const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
const r = NextResponse.json({
ok: true,
user: { id: newRecord.id, email: newRecord.email ?? email },
token: newToken,
});
r.cookies.set(COOKIE_NAME, encoded, {
domain: AUTH_COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
return r;
}
return NextResponse.json({
ok: true,
user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
token: data.token,
});
} catch {
return NextResponse.json({ ok: true, user: null });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const token = body?.token;
const record = body?.record;
if (!token || !record?.id) {
return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 });
}
const payload = JSON.stringify({
token,
userId: record.id,
email: record.email ?? "",
});
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, encoded, {
domain: AUTH_COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
return res;
}

View File

@@ -2,7 +2,18 @@ import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { getThemeConfig } from "@/config";
function getOrCreateUserId(): string {
const COOKIE_NAME = "pb_session";
function getOrCreateUserId(request: NextRequest): string {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (cookie) {
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
if (payload?.userId) return payload.userId;
} catch {
/* ignore */
}
}
return `user${Date.now()}`;
}
@@ -24,7 +35,7 @@ async function getAdminToken(): Promise<string | null> {
export async function POST(request: NextRequest) {
try {
const userId = getOrCreateUserId();
const userId = getOrCreateUserId(request);
const body = await request.json();
const formData = body && typeof body === "object" ? body : {};

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config";
import { getPaymentConfig, getJoinPaymentConfig, SITE_ID } from "@/config/services.config";
import { SITE_URL } from "../../lib/env";
async function createPayOrder(
@@ -17,6 +17,7 @@ async function createPayOrder(
const payload = {
user_id,
site_id: SITE_ID,
total_fee: Number(fee) || joinConfig.amount,
type: orderType,
channel,
@@ -26,7 +27,7 @@ async function createPayOrder(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${config.apiUrl}/payh5`, {
const res = await fetch(`${config.apiUrl}/digital/payh5`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -148,6 +149,7 @@ export async function POST(request: NextRequest) {
// 使用 payjsapi /payh5/redirectmapi.php API接口支付按 device 返回 payurl/payurl2
const redirectPayload = {
user_id,
site_id: SITE_ID,
total_fee,
type,
channel,
@@ -157,7 +159,7 @@ export async function POST(request: NextRequest) {
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const redirectRes = await fetch(`${payConfig.apiUrl}/payh5/redirect`, {
const redirectRes = await fetch(`${payConfig.apiUrl}/digital/payh5/redirect`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(redirectPayload),

View File

@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
const res = await fetch(
`${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
`${config.apiUrl}/digital/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
{ cache: "no-store", signal: controller.signal }
).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({}));

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig, SITE_ID, VIP_MASTER_SITE_ID } from "@/config/services.config";
const COOKIE_NAME = "pb_session";
async function getAdminToken(): Promise<string | null> {
const email = process.env.POCKETBASE_EMAIL;
const password = process.env.POCKETBASE_PASSWORD;
if (!email || !password) return null;
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (!res.ok) return null;
const data = await res.json();
return data?.token ?? null;
}
/** 检查单条 site_vip 是否有效 */
function isValidVip(record: { expires_at?: string | null } | null): boolean {
if (!record) return false;
const now = new Date().toISOString();
return !(record.expires_at && record.expires_at < now);
}
export async function GET(request: NextRequest) {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) {
return NextResponse.json({ ok: true, vip: false });
}
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
const userId = payload?.userId;
if (!userId) return NextResponse.json({ ok: true, vip: false });
const token = await getAdminToken();
if (!token) return NextResponse.json({ ok: true, vip: false });
const pb = getPocketBaseConfig();
// 1) 本专题站 VIPsite_id = SITE_ID2) vip 站 master VIPsite_id = vip可解锁所有 digital 专题站
const filter = `user_id = "${userId}" && (site_id = "${SITE_ID}" || site_id = "${VIP_MASTER_SITE_ID}")`;
const res = await fetch(
`${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=2&sort=-expires_at`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return NextResponse.json({ ok: true, vip: false });
const data = await res.json();
const items = data?.items ?? [];
const record = items.find((r: { expires_at?: string | null }) => isValidVip(r));
const vip = !!record;
return NextResponse.json({
ok: true,
vip,
expires_at: record?.expires_at ?? null,
});
} catch {
return NextResponse.json({ ok: true, vip: false });
}
}

View File

@@ -44,6 +44,15 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
result = await pbLogin(email, password);
}
pbSaveAuth(result.token, result.record);
try {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: result.token, record: result.record }),
});
} catch {
/* ignore */
}
onSuccess(email);
onClose();
setEmail("");

View File

@@ -25,11 +25,28 @@ export default function Header() {
const [userEmail, setUserEmail] = useState<string | null>(null);
useEffect(() => {
setUserEmail(getStoredUserEmail());
let mounted = true;
(async () => {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (!mounted) return;
if (data?.user && data?.token) {
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.user.id, email: data.user.email });
setUserEmail(data.user.email);
return;
}
} catch {
/* ignore */
}
if (mounted) setUserEmail(getStoredUserEmail());
})();
return () => { mounted = false; };
}, []);
const handleLogout = () => {
pbLogout();
const handleLogout = async () => {
await pbLogout();
setUserEmail(null);
};

View File

@@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import { Link, useTranslation } from "@/i18n/navigation";
import { Link, useTranslation, useRouter } from "@/i18n/navigation";
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
import AuthModal from "@/app/components/AuthModal";
const days = [
{ day: 1, icon: "👋", color: "bg-sky-500" },
@@ -15,7 +17,9 @@ const days = [
export default function Roadmap() {
const { t } = useTranslation("roadmap");
const [courseToast, setCourseToast] = useState(false);
const router = useRouter();
const { user, vip, loading, refetch } = useAuthAndVip();
const [authOpen, setAuthOpen] = useState(false);
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">
@@ -86,7 +90,22 @@ export default function Roadmap() {
</span>
</Link>
<div className="flex flex-col overflow-hidden rounded-2xl border-2 border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50/50 p-8 shadow-md transition-all dark:border-amber-800 dark:from-amber-900/20 dark:to-orange-900/10">
<button
type="button"
onClick={async () => {
if (loading) return;
if (!user) {
setAuthOpen(true);
return;
}
if (!vip) {
router.replace("/join");
return;
}
router.replace("/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>
@@ -99,28 +118,22 @@ export default function Roadmap() {
<p className="mt-2 flex-1 text-slate-600 dark:text-slate-400">
{t("course.desc")}
</p>
<button
type="button"
onClick={() => {
setCourseToast(true);
setTimeout(() => setCourseToast(false), 2000);
}}
className="mt-6 inline-flex cursor-not-allowed items-center gap-2 self-start rounded-full bg-amber-500 px-6 py-3 font-semibold text-white opacity-90 transition hover:opacity-100 dark:bg-amber-600"
>
<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")}
</button>
</div>
</span>
</button>
</div>
</div>
{courseToast && (
<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("course.updating")}
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={async () => {
setAuthOpen(false);
const { vip: isVip } = await refetch();
router.replace(isVip ? "/course" : "/join");
}}
/>
</div>
</section>
);

View File

@@ -1,21 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { Link } from "@/i18n/navigation";
import { useTranslation } from "@/i18n/navigation";
import { CTA_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/course-payment";
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
export function CTASection() {
const { t } = useTranslation("community");
const [paid, setPaid] = useState(false);
const [toast, setToast] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
const { user, vip } = useAuthAndVip();
const paid = !!user && vip;
return (
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
@@ -45,26 +36,14 @@ export function CTASection() {
</Link>
) : (
<button
type="button"
onClick={() => {
setToast(true);
setTimeout(() => setToast(false), 2000);
}}
className="inline-flex cursor-not-allowed items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] opacity-90 shadow-lg shadow-[var(--accent)]/25 transition hover:opacity-100 sm:px-10"
<Link
href="/join"
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 sm:px-10"
>
</button>
</Link>
)}
</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>
)}
</section>
);
}

View File

@@ -2,37 +2,30 @@
import { useState, useEffect } from "react";
import { Link } from "@/i18n/navigation";
import { useTranslation } from "@/i18n/navigation";
import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/course-payment";
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
import { getLastWatched, getProgress } from "@/app/lib/learning";
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export function CourseHero() {
const { t } = useTranslation("community");
const [paid, setPaid] = useState(false);
const [toast, setToast] = useState(false);
const { user, vip } = useAuthAndVip();
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
useEffect(() => {
setPaid(isPaid());
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
const onPay = () => setPaid(isPaid());
const onProgress = () => {
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
};
window.addEventListener("pay:updated", onPay);
window.addEventListener("learning:progress", onProgress);
return () => {
window.removeEventListener("pay:updated", onPay);
window.removeEventListener("learning:progress", onProgress);
};
return () => window.removeEventListener("learning:progress", onProgress);
}, []);
const paid = !!user && vip;
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" />
@@ -71,16 +64,12 @@ export function CourseHero() {
</Link>
) : (
<button
type="button"
onClick={() => {
setToast(true);
setTimeout(() => setToast(false), 2000);
}}
className="inline-flex shrink-0 cursor-not-allowed items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] opacity-90 transition hover:opacity-100 sm:px-10"
<Link
href="/join"
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"
>
</button>
</Link>
)}
</div>
{paid && progress.completed > 0 && (
@@ -98,14 +87,6 @@ export function CourseHero() {
</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>
)}
</section>
);
}

View File

@@ -5,9 +5,11 @@ import { Link } from "@/i18n/navigation";
import { Section } from "./Section";
import { CURRICULUM_CONFIG } from "@/config/course";
import { canWatchLesson } from "@/app/lib/course-payment";
import { useAuthAndVip } from "@/app/lib/useAuthAndVip";
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/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]))
);
@@ -98,7 +100,7 @@ export function CurriculumSection() {
<ul className="border-t border-[var(--border)]">
{c.lessons.map((l) => {
const free = l.moduleIndex === 0 && l.lessonIndex < 2;
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex);
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);

View File

@@ -1,31 +1,20 @@
/**
* 课程支付状态 - localStorage来自 nomadlms
* 与 join 报名支付分离,课程解锁用此模块
* 课程解锁 - 校验登录态 + VIP/api/vip/check
* 与 join 报名支付分离,课程解锁用 VIP 状态
*/
const PAY_STORAGE_KEY = "lms-pay";
/** 是否已支付(课程已解锁) */
export function isPaid(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem(PAY_STORAGE_KEY) === "ok";
}
/** 设置已支付(支付成功后调用) */
export function setPaid(): void {
if (typeof window === "undefined") return;
localStorage.setItem(PAY_STORAGE_KEY, "ok");
window.dispatchEvent(new CustomEvent("pay:updated"));
}
/** 前 2 章为试看,无需支付 */
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
if (moduleIndex === 0 && lessonIndex < 2) return true;
return false;
}
/** 章节是否可观看 */
export function canWatchLesson(moduleIndex: number, lessonIndex: number): boolean {
/** 章节是否可观看(需传入 vip 状态,来自 useAuthAndVip 或 /api/vip/check */
export function canWatchLesson(
moduleIndex: number,
lessonIndex: number,
vip: boolean
): boolean {
if (isFreeTrial(moduleIndex, lessonIndex)) return true;
return isPaid();
return vip;
}

View File

@@ -1,19 +1,17 @@
/**
* 环境配置:区分本地调试与生产部署
* 生产https://api.hackrobot.cnpayjsapi
* 域名更换:设置 ROOT_DOMAIN如 nomadro.com可推导默认值
*/
const isDev = process.env.NODE_ENV === "development";
import { DEFAULT_PAYMENT_API_URL, DEFAULT_SITE_URL } from "@/config/domain.config";
/** 支付 API 地址payjsapi */
export const PAYMENT_API_URL =
process.env.PAYMENT_API_URL ||
(isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn");
process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
/** 站点根地址(用于 return_url 等回跳,生产需配置) */
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ||
(isDev ? "http://localhost:3001" : "https://hackrobot.cn");
process.env.NEXT_PUBLIC_SITE_URL || DEFAULT_SITE_URL;
/** 是否为本地开发 */
export const IS_DEV = isDev;
export const IS_DEV = process.env.NODE_ENV === "development";

View File

@@ -67,11 +67,16 @@ export function pbSaveAuth(token: string, record: AuthUser): void {
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
}
/** 登出 - 清除本地存储 */
export function pbLogout(): void {
/** 登出 - 清除本地存储和根域 Cookie */
export async function pbLogout(): Promise<void> {
if (typeof window === "undefined") return;
localStorage.removeItem(PB_STORAGE_KEYS.token);
localStorage.removeItem(PB_STORAGE_KEYS.user);
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
}
/** 获取当前存储的用户邮箱 */

45
app/lib/useAuthAndVip.ts Normal file
View File

@@ -0,0 +1,45 @@
"use client";
import { useState, useEffect, useCallback } from "react";
export interface AuthAndVipState {
user: { id: string; email: string } | null;
vip: boolean;
loading: boolean;
refetch: () => Promise<{ user: { id: string; email: string } | null; vip: boolean }>;
}
export function useAuthAndVip(): AuthAndVipState {
const [user, setUser] = useState<{ id: string; email: string } | null>(null);
const [vip, setVip] = useState(false);
const [loading, setLoading] = useState(true);
const refetch = useCallback(async (): Promise<{ user: typeof user; vip: boolean }> => {
setLoading(true);
try {
const [meRes, vipRes] = await Promise.all([
fetch("/api/auth/me", { credentials: "include" }),
fetch("/api/vip/check", { credentials: "include" }),
]);
const meData = await meRes.json();
const vipData = await vipRes.json();
const newUser = meData?.user ?? null;
const newVip = vipData?.vip === true;
setUser(newUser);
setVip(newVip);
return { user: newUser, vip: newVip };
} catch {
setUser(null);
setVip(false);
return { user: null, vip: false };
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refetch();
}, [refetch]);
return { user, vip, loading, refetch };
}