469 lines
14 KiB
TypeScript
469 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import PocketBase from "pocketbase";
|
||
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
|
||
import { getPaymentConfig } from "@/config/services.config";
|
||
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
||
import { VipLayout } from "@/app/vip/components/VipLayout";
|
||
import { LandingPage } from "@/app/vip/components/LandingPage";
|
||
import { DashboardPage } from "@/app/vip/components/DashboardPage";
|
||
import { PayAuthModal } from "@/app/vip/components/landing/PayAuthModal";
|
||
import styles from "@/app/vip/components/vip.module.css";
|
||
|
||
const PB_URL =
|
||
process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
|
||
|
||
/** 微信内支付渠道:zpay | xorpay */
|
||
const WECHAT_PAY_PROVIDER: "zpay" | "xorpay" = "xorpay";
|
||
|
||
function redirectToPayZpay(params: {
|
||
user_id: string;
|
||
return_url: string;
|
||
total_fee: number;
|
||
type: string;
|
||
channel: "alipay" | "wxpay";
|
||
device: "pc" | "h5" | "wechat";
|
||
}) {
|
||
const form = document.createElement("form");
|
||
form.method = "POST";
|
||
form.action = "/api/pay";
|
||
form.target = "_self";
|
||
form.style.display = "none";
|
||
const fields: [string, string][] = [
|
||
["user_id", params.user_id],
|
||
["return_url", params.return_url],
|
||
["total_fee", String(params.total_fee)],
|
||
["type", params.type],
|
||
["channel", params.channel],
|
||
["device", params.device],
|
||
["provider", "zpay"],
|
||
["html", "1"],
|
||
];
|
||
fields.forEach(([k, v]) => {
|
||
const input = document.createElement("input");
|
||
input.type = "hidden";
|
||
input.name = k;
|
||
input.value = v;
|
||
form.appendChild(input);
|
||
});
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
}
|
||
|
||
async function redirectToPayZpayH5(params: {
|
||
user_id: string;
|
||
return_url: string;
|
||
total_fee: number;
|
||
type: string;
|
||
channel: "alipay" | "wxpay";
|
||
}): Promise<string | null> {
|
||
const res = await fetch("/api/pay", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
...params,
|
||
device: "h5",
|
||
provider: "zpay",
|
||
html: "0",
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!data?.ok || !data?.params || !data?.payUrl) {
|
||
alert(data?.error || "支付发起失败");
|
||
return null;
|
||
}
|
||
const orderId = data?.order_id?.trim() || null;
|
||
if (data?.redirect_url) {
|
||
window.open(data.redirect_url, "_blank");
|
||
return orderId;
|
||
}
|
||
const form = document.createElement("form");
|
||
form.method = "POST";
|
||
form.action = data.payUrl;
|
||
form.target = "_blank";
|
||
form.style.display = "none";
|
||
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
|
||
if (v == null) continue;
|
||
const input = document.createElement("input");
|
||
input.type = "hidden";
|
||
input.name = k;
|
||
input.value = String(v);
|
||
form.appendChild(input);
|
||
}
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
document.body.removeChild(form);
|
||
return orderId;
|
||
}
|
||
|
||
async function payWechatXorpay(params: {
|
||
user_id: string;
|
||
return_url: string;
|
||
total_fee: number;
|
||
type: string;
|
||
channel: "alipay" | "wxpay";
|
||
}) {
|
||
const res = await fetch("/api/pay", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
...params,
|
||
total_fee: params.total_fee,
|
||
device: "wechat",
|
||
provider: "xorpay",
|
||
html: "0",
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!data?.ok || !data?.params || !data?.payUrl) {
|
||
alert(data?.error || "支付发起失败");
|
||
return;
|
||
}
|
||
const form = document.createElement("form");
|
||
form.method = "POST";
|
||
form.action = data.payUrl;
|
||
form.target = "_self";
|
||
form.style.display = "none";
|
||
for (const [k, v] of Object.entries(data.params as Record<string, string>)) {
|
||
if (v == null) continue;
|
||
const input = document.createElement("input");
|
||
input.type = "hidden";
|
||
input.name = k;
|
||
input.value = String(v);
|
||
form.appendChild(input);
|
||
}
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
}
|
||
|
||
function getStorage(key: string): string | null {
|
||
if (typeof window === "undefined") return null;
|
||
return localStorage.getItem(key);
|
||
}
|
||
|
||
function setStorage(key: string, value: string): void {
|
||
if (typeof window === "undefined") return;
|
||
localStorage.setItem(key, value);
|
||
}
|
||
|
||
function removeStorage(key: string): void {
|
||
if (typeof window === "undefined") return;
|
||
localStorage.removeItem(key);
|
||
}
|
||
|
||
export default function Home() {
|
||
const [paidType, setPaidType] = useState<string | null>(null);
|
||
const [userName, setUserName] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
|
||
|
||
const savePaidType = useCallback((type: string | null) => {
|
||
if (type) setStorage("paidType", type);
|
||
else removeStorage("paidType");
|
||
setPaidType(type);
|
||
}, []);
|
||
|
||
const saveUserName = useCallback((name: string) => {
|
||
setStorage("userName", name);
|
||
setUserName(name);
|
||
}, []);
|
||
|
||
const getOrCreateUserId = useCallback(async () => {
|
||
// 优先使用 PocketBase 用户 ID(已登录时)
|
||
try {
|
||
const meRes = await fetch("/api/auth/me");
|
||
const meData = await meRes.json();
|
||
if (meData?.user?.id) {
|
||
const pbUserId = meData.user.id;
|
||
setStorage("userId", pbUserId);
|
||
return pbUserId;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
let userId = getStorage("userId");
|
||
if (userId?.startsWith("{") && userId.includes("data")) {
|
||
try {
|
||
const parsed = JSON.parse(userId) as { data?: string };
|
||
if (parsed?.data) {
|
||
userId = parsed.data;
|
||
setStorage("userId", userId);
|
||
}
|
||
} catch {
|
||
userId = null;
|
||
}
|
||
}
|
||
if (!userId) {
|
||
userId = `user${Date.now()}`;
|
||
setStorage("userId", userId);
|
||
}
|
||
return userId;
|
||
}, []);
|
||
|
||
const checkPaymentFromPB = useCallback(
|
||
async (userId: string): Promise<boolean> => {
|
||
try {
|
||
const pb = new PocketBase(PB_URL);
|
||
const records = await pb.collection("payments").getList(1, 1, {
|
||
filter: `user_id = "${userId}"`,
|
||
sort: "-created",
|
||
});
|
||
|
||
if (records.items.length > 0) {
|
||
const latestType = records.items[0].type as string;
|
||
const dbUserId = records.items[0].user_id as string;
|
||
setStorage("userId", dbUserId);
|
||
savePaidType(latestType);
|
||
saveUserName(dbUserId);
|
||
return latestType === "vip";
|
||
}
|
||
savePaidType(null);
|
||
return false;
|
||
} catch (err) {
|
||
console.error("查询 PocketBase 失败:", err);
|
||
return false;
|
||
}
|
||
},
|
||
[savePaidType, saveUserName]
|
||
);
|
||
|
||
/** 会员账号恢复:通过邮箱+密码验证,关联 users 后检查 site_vip / payments */
|
||
const checkVerifyByEmail = useCallback(
|
||
async (email: string, password: string): Promise<boolean> => {
|
||
try {
|
||
const { pbLogin, pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||
const result = await pbLogin(email, password);
|
||
pbSaveAuth(result.token, result.record);
|
||
await fetch("/api/auth/sync-session", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ token: result.token, record: result.record }),
|
||
});
|
||
const userId = result.record.id;
|
||
const vipRes = await fetch("/api/vip/check");
|
||
const vipData = await vipRes.json();
|
||
if (vipData?.vip) {
|
||
setStorage("userId", userId);
|
||
savePaidType("vip");
|
||
saveUserName(email);
|
||
return true;
|
||
}
|
||
const found = await checkPaymentFromPB(userId);
|
||
if (found) return true;
|
||
return false;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
[checkPaymentFromPB, savePaidType, saveUserName]
|
||
);
|
||
|
||
const clearAllStorage = useCallback(() => {
|
||
try {
|
||
if (typeof window === "undefined") return;
|
||
const keys = ["userId", "paidType", "userName"];
|
||
keys.forEach((k) => {
|
||
try {
|
||
localStorage.removeItem(k);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
setPaidType(null);
|
||
setUserName(null);
|
||
setLoading(false);
|
||
const path = window.location.pathname || "/";
|
||
window.location.replace(path);
|
||
} catch (error) {
|
||
console.error("清空存储失败:", error);
|
||
}
|
||
}, []);
|
||
|
||
const [authForPayOpen, setAuthForPayOpen] = useState(false);
|
||
|
||
const doPay = useCallback(async () => {
|
||
const userId = await getOrCreateUserId();
|
||
const returnUrl = `${window.location.origin}/paid`;
|
||
const env = getPayEnv();
|
||
const device = getDeviceFromEnv(env);
|
||
const totalFee = getPaymentConfig().defaultAmount ?? 8800;
|
||
const base = {
|
||
user_id: userId,
|
||
return_url: returnUrl,
|
||
total_fee: totalFee,
|
||
type: "vip",
|
||
channel: "wxpay" as const,
|
||
};
|
||
if (device === "wechat" && WECHAT_PAY_PROVIDER === "xorpay") {
|
||
payWechatXorpay(base);
|
||
} else if (device === "h5") {
|
||
const orderId = await redirectToPayZpayH5(base);
|
||
if (orderId) setPayPendingOrderId(orderId);
|
||
} else {
|
||
redirectToPayZpay({ ...base, device });
|
||
}
|
||
}, [getOrCreateUserId]);
|
||
|
||
const handlePay = useCallback(async () => {
|
||
try {
|
||
const meRes = await fetch("/api/auth/me");
|
||
const meData = await meRes.json();
|
||
if (meData?.user?.id) {
|
||
doPay();
|
||
return;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setAuthForPayOpen(true);
|
||
}, [doPay]);
|
||
|
||
const handleVerifySuccess = useCallback(() => {
|
||
window.location.reload();
|
||
}, []);
|
||
|
||
const env = getPayEnv();
|
||
const device = getDeviceFromEnv(env);
|
||
const isH5 = device === "h5";
|
||
usePayStatusPoll(
|
||
(orderId) => {
|
||
let uid = getStorage("userId");
|
||
if (!uid && orderId?.includes("_order_")) {
|
||
const prefix = orderId.split("_order_")[0];
|
||
const parts = prefix.split("_");
|
||
if (parts.length >= 2) uid = parts.slice(0, -1).join("_");
|
||
}
|
||
if (uid) saveUserName(uid);
|
||
savePaidType("vip");
|
||
if (isH5) setPayPendingOrderId(null);
|
||
window.location.reload();
|
||
},
|
||
() => {
|
||
if (isH5) setPayPendingOrderId(null);
|
||
},
|
||
isH5 ? payPendingOrderId : undefined
|
||
);
|
||
|
||
useEffect(() => {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
if (urlParams.get("type")?.trim() === "clean") {
|
||
clearAllStorage();
|
||
return;
|
||
}
|
||
if (urlParams.get("paid") === "1") {
|
||
savePaidType("vip");
|
||
saveUserName(getStorage("userId") || "");
|
||
}
|
||
|
||
const init = async () => {
|
||
await getOrCreateUserId();
|
||
|
||
const localType = getStorage("paidType");
|
||
const localUserName = getStorage("userName");
|
||
if (localType && localUserName) {
|
||
setPaidType(localType);
|
||
setUserName(localUserName);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
// SSO: 优先检查根域 Cookie(跨站登录态)
|
||
try {
|
||
const meRes = await fetch("/api/auth/me");
|
||
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 });
|
||
const vipRes = await fetch("/api/vip/check");
|
||
const vipData = await vipRes.json();
|
||
if (vipData?.vip) {
|
||
savePaidType("vip");
|
||
saveUserName(meData.user.email || meData.user.id);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
|
||
const userId = getStorage("userId");
|
||
if (userId) {
|
||
const found = await checkPaymentFromPB(userId);
|
||
if (found) {
|
||
setUserName(getStorage("userName"));
|
||
}
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
init();
|
||
}, [clearAllStorage, getOrCreateUserId, checkPaymentFromPB, savePaidType, saveUserName]);
|
||
|
||
useEffect(() => {
|
||
const syncFromStorage = () => {
|
||
const localType = getStorage("paidType");
|
||
const localUserName = getStorage("userName");
|
||
if (localType && localUserName && !paidType) {
|
||
setPaidType(localType);
|
||
setUserName(localUserName);
|
||
}
|
||
};
|
||
const onVisible = () => syncFromStorage();
|
||
const onPageShow = (e: PageTransitionEvent) => {
|
||
if (e.persisted) syncFromStorage();
|
||
};
|
||
if (typeof document !== "undefined" && document.addEventListener) {
|
||
document.addEventListener("visibilitychange", onVisible);
|
||
window.addEventListener("pageshow", onPageShow);
|
||
return () => {
|
||
document.removeEventListener("visibilitychange", onVisible);
|
||
window.removeEventListener("pageshow", onPageShow);
|
||
};
|
||
}
|
||
}, [paidType]);
|
||
|
||
const handleLogout = useCallback(async () => {
|
||
const { pbLogout } = await import("@/app/lib/pocketbase");
|
||
await pbLogout();
|
||
clearAllStorage();
|
||
}, [clearAllStorage]);
|
||
|
||
const isLoggedIn = paidType === "vip";
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className={styles.loading}>
|
||
<div className={styles.loadingSpinner} />
|
||
<p>加载中...</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<VipLayout isLoggedIn={isLoggedIn} userName={userName} onLogout={handleLogout}>
|
||
{isLoggedIn ? (
|
||
<DashboardPage userName={userName} />
|
||
) : (
|
||
<LandingPage
|
||
onJoin={handlePay}
|
||
onVerify={checkPaymentFromPB}
|
||
onVerifyByEmail={checkVerifyByEmail}
|
||
onVerifySuccess={handleVerifySuccess}
|
||
/>
|
||
)}
|
||
</VipLayout>
|
||
<PayAuthModal
|
||
open={authForPayOpen}
|
||
onClose={() => setAuthForPayOpen(false)}
|
||
onSuccess={() => {
|
||
setAuthForPayOpen(false);
|
||
doPay();
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|