s'c1'
This commit is contained in:
@@ -64,6 +64,9 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
||||
|
||||
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("");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
|
||||
import { getStoredUserEmail } from "@/app/lib/pocketbase";
|
||||
import { useCrossSiteUrls } from "@/app/lib/useCrossSiteUrls";
|
||||
@@ -18,6 +18,7 @@ export default function Header() {
|
||||
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" },
|
||||
@@ -26,27 +27,42 @@ export default function Header() {
|
||||
{ labelKey: "resources" as const, href: "#resources" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
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 */
|
||||
const fetchAuthAndVip = useCallback(async () => {
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const meData = await meRes.json();
|
||||
if (meData?.user && meData?.token) {
|
||||
const { pbSaveAuth } = await import("@/app/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/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
|
||||
const vipData = await vipRes.json();
|
||||
setVip(vipData?.vip === true);
|
||||
} else {
|
||||
setUserEmail(getStoredUserEmail());
|
||||
setVip(false);
|
||||
}
|
||||
if (mounted) setUserEmail(getStoredUserEmail());
|
||||
})();
|
||||
return () => { mounted = 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 ${
|
||||
@@ -78,7 +94,7 @@ export default function Header() {
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
|
||||
{userEmail ? (
|
||||
<UserAvatar email={userEmail} onLogout={() => setUserEmail(null)} />
|
||||
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); router.replace("/"); }} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAuthOpen(true)}
|
||||
@@ -148,7 +164,7 @@ export default function Header() {
|
||||
</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} onLogout={() => { setUserEmail(null); setMobileOpen(false); }} />
|
||||
<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>
|
||||
) : (
|
||||
@@ -176,7 +192,7 @@ export default function Header() {
|
||||
<AuthModal
|
||||
isOpen={authOpen}
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onSuccess={(email) => setUserEmail(email)}
|
||||
onSuccess={() => fetchAuthAndVip()}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
|
||||
13
app/components/PayStatusPollProvider.tsx
Normal file
13
app/components/PayStatusPollProvider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
|
||||
|
||||
/**
|
||||
* 全局支付轮询:支付返回任意页面时检测 paid,调用 confirm 后跳转首页
|
||||
*/
|
||||
export function PayStatusPollProvider() {
|
||||
usePayStatusPoll(() => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -12,11 +12,13 @@ function getAvatarLetter(email: string): string {
|
||||
|
||||
interface UserAvatarProps {
|
||||
email: string;
|
||||
vip?: boolean;
|
||||
onLogout?: () => void;
|
||||
onRefreshVip?: () => void;
|
||||
}
|
||||
|
||||
/** 圆形头像 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */
|
||||
export default function UserAvatar({ email, onLogout }: UserAvatarProps) {
|
||||
/** 圆形头像 + 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);
|
||||
@@ -40,13 +42,26 @@ export default function UserAvatar({ email, onLogout }: UserAvatarProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="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"
|
||||
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"
|
||||
|
||||
@@ -7,30 +7,29 @@ import { getStoredToken, getStoredUser } from "@/app/lib/pocketbase";
|
||||
import AuthModal from "@/app/components/AuthModal";
|
||||
import {
|
||||
getPayEnv,
|
||||
getRecommendedChannel,
|
||||
mustUseWxPay,
|
||||
redirectToPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payment";
|
||||
import { useState } from "react";
|
||||
|
||||
export function CTASection() {
|
||||
const { user, vip } = useAuthAndVip();
|
||||
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 = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
|
||||
const channel = "wxpay";
|
||||
const device = getDeviceFromEnv(env);
|
||||
const returnUrl = `${window.location.origin}/course/0/0`;
|
||||
const returnUrl = `${window.location.origin}/`;
|
||||
|
||||
redirectToPay({
|
||||
user_id: userId,
|
||||
return_url: returnUrl,
|
||||
channel,
|
||||
device,
|
||||
type: "video",
|
||||
useJoinConfig: true,
|
||||
});
|
||||
};
|
||||
@@ -40,14 +39,17 @@ export function CTASection() {
|
||||
setPayRedirecting(true);
|
||||
|
||||
try {
|
||||
if (user?.id) {
|
||||
startPay(user.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const meData = await meRes.json().catch(() => ({}));
|
||||
// 先验证登录态
|
||||
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;
|
||||
}
|
||||
@@ -61,8 +63,18 @@ export function CTASection() {
|
||||
body: JSON.stringify({ token: storedToken, record: storedUser }),
|
||||
credentials: "include",
|
||||
}).catch(() => null);
|
||||
startPay(storedUser.id);
|
||||
return;
|
||||
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);
|
||||
@@ -110,7 +122,7 @@ export function CTASection() {
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onSuccess={() => {
|
||||
setAuthOpen(false);
|
||||
void handleEnroll();
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -9,8 +9,6 @@ import { getLastWatched, getProgress } from "@/app/lib/learning";
|
||||
import AuthModal from "@/app/components/AuthModal";
|
||||
import {
|
||||
getPayEnv,
|
||||
getRecommendedChannel,
|
||||
mustUseWxPay,
|
||||
redirectToPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payment";
|
||||
@@ -18,7 +16,7 @@ import {
|
||||
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
|
||||
export function CourseHero() {
|
||||
const { user, vip } = useAuthAndVip();
|
||||
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);
|
||||
@@ -39,15 +37,16 @@ export function CourseHero() {
|
||||
|
||||
const startPay = (userId: string) => {
|
||||
const env = getPayEnv();
|
||||
const channel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
|
||||
const channel = "wxpay";
|
||||
const device = getDeviceFromEnv(env);
|
||||
const returnUrl = `${window.location.origin}/course/0/0`;
|
||||
const returnUrl = `${window.location.origin}/`;
|
||||
|
||||
redirectToPay({
|
||||
user_id: userId,
|
||||
return_url: returnUrl,
|
||||
channel,
|
||||
device,
|
||||
type: "video",
|
||||
useJoinConfig: true,
|
||||
});
|
||||
};
|
||||
@@ -57,14 +56,17 @@ export function CourseHero() {
|
||||
setPayRedirecting(true);
|
||||
|
||||
try {
|
||||
if (user?.id) {
|
||||
startPay(user.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const meData = await meRes.json().catch(() => ({}));
|
||||
// 先验证登录态
|
||||
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;
|
||||
}
|
||||
@@ -78,8 +80,18 @@ export function CourseHero() {
|
||||
body: JSON.stringify({ token: storedToken, record: storedUser }),
|
||||
credentials: "include",
|
||||
}).catch(() => null);
|
||||
startPay(storedUser.id);
|
||||
return;
|
||||
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);
|
||||
@@ -149,7 +161,7 @@ export function CourseHero() {
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onSuccess={() => {
|
||||
setAuthOpen(false);
|
||||
void handleEnroll();
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user