Files
gitlab-instance-0a899031_no…/app/page.tsx
2026-03-13 21:38:04 -05:00

651 lines
22 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 { useCallback, useEffect, useState } from "react";
import { triggerPay } from "@/app/lib/triggerPay";
import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv";
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 styles from "@/app/vip/components/vip.module.css";
// 渠道规则pc/手机浏览器 -> zpay仅微信内 -> xorpay
const DEFAULT_EMAIL_PASSWORD = "12345678";
const ANONYMOUS_USER_COOKIE = "nomadvip_anon_uid";
const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
const PAY_ORDER_COOKIE = "nomadvip_pay_order";
const PAY_ORDER_MAX_AGE = 60 * 15;
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);
}
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function setAnonymousUserCookie(userId: string): void {
if (typeof document === "undefined") return;
if (!/^user\d+$/.test(userId)) return;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${ANONYMOUS_USER_COOKIE}=${encodeURIComponent(
userId
)}; Path=/; Max-Age=${ANONYMOUS_USER_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
}
function clearCookie(name: string): void {
if (typeof document === "undefined") return;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
}
function clearAnonymousUserCookie(): void {
clearCookie(ANONYMOUS_USER_COOKIE);
}
function setPayOrderCookie(orderId: string): void {
if (typeof document === "undefined") return;
const normalizedOrderId = orderId.trim();
if (!normalizedOrderId) return;
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${PAY_ORDER_COOKIE}=${encodeURIComponent(
`${normalizedOrderId}|${Date.now()}`
)}; Path=/; Max-Age=${PAY_ORDER_MAX_AGE}; SameSite=Lax${secure}`;
}
function savePendingOrder(orderId: string): void {
const normalizedOrderId = orderId.trim();
if (!normalizedOrderId) return;
setPayOrderCookie(normalizedOrderId);
setStorage(PAY_ORDER_COOKIE, `${normalizedOrderId}|${Date.now()}`);
}
function clearPendingOrder(): void {
clearCookie(PAY_ORDER_COOKIE);
removeStorage(PAY_ORDER_COOKIE);
}
function getPendingOrderId(): string | null {
const raw = getCookie(PAY_ORDER_COOKIE) || getStorage(PAY_ORDER_COOKIE);
const orderId = raw?.split("|")[0]?.trim() || "";
return orderId || null;
}
/** 首页永远不显示 loading所有接口在后台静默执行 */
export default function Home() {
const [paidType, setPaidType] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
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 getStoredAnonymousUserId = useCallback((): string | null => {
const memosAccount = getStorage("memosAccount");
if (memosAccount && /^user\d+$/.test(memosAccount)) {
return memosAccount;
}
const userId = getStorage("userId");
if (userId && /^user\d+$/.test(userId)) {
return userId;
}
const cookieUserId = getCookie(ANONYMOUS_USER_COOKIE);
if (cookieUserId && /^user\d+$/.test(cookieUserId)) {
setStorage("memosAccount", cookieUserId);
return cookieUserId;
}
return null;
}, []);
const applyRecoveredEmailSession = useCallback(
async (
data: {
token?: string;
record?: { id?: string; email?: string };
anonymousUserId?: string | null;
memosAccount?: string | null;
},
normalizedEmail: string,
fallbackAnonymousUserId?: string
) => {
if (!data?.token || !data?.record?.id) {
return false;
}
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? normalizedEmail });
const anonymousUserId =
(typeof data?.anonymousUserId === "string" &&
/^user\d+$/.test(data.anonymousUserId)
? data.anonymousUserId
: typeof data?.memosAccount === "string" &&
/^user\d+$/.test(data.memosAccount)
? data.memosAccount
: /^user\d+$/.test(fallbackAnonymousUserId?.trim() || "")
? fallbackAnonymousUserId?.trim()
: "") || "";
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: data.token,
record: data.record,
anonymousUserId: anonymousUserId || undefined,
}),
credentials: "include",
});
setStorage("userId", data.record.id);
setStorage("userEmail", normalizedEmail);
setStorage("userName", normalizedEmail);
setStorage("emailLinked", "1");
if (anonymousUserId) {
setStorage("memosAccount", anonymousUserId);
setAnonymousUserCookie(anonymousUserId);
}
savePaidType("vip");
setUserEmail(normalizedEmail);
saveUserName(normalizedEmail);
return true;
},
[savePaidType, saveUserName]
);
const recoverLinkedVipSession = useCallback(
async (
email: string,
password: string,
user_id?: string
): Promise<{ ok: boolean; error?: string }> => {
try {
const normalizedEmail = email.trim().toLowerCase();
const res = await fetch("/api/vip/check-by-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: normalizedEmail,
password,
...(user_id?.trim() && { user_id: user_id.trim() }),
}),
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!data?.vip || !data?.token || !data?.record) {
return {
ok: false,
error:
data?.error ||
"未找到关联的会员记录,请确认已支付并绑定邮箱",
};
}
const ok = await applyRecoveredEmailSession(
data,
normalizedEmail,
user_id
);
return ok ? { ok: true } : { ok: false, error: "登录恢复失败" };
} catch (error) {
const msg = error instanceof Error ? error.message : "验证失败";
return { ok: false, error: msg };
}
},
[applyRecoveredEmailSession]
);
const getOrCreateUserId = useCallback(async () => {
const anonymousUserId = getStoredAnonymousUserId();
if (anonymousUserId) {
setStorage("userId", anonymousUserId);
setAnonymousUserCookie(anonymousUserId);
return anonymousUserId;
}
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
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;
}, [getStoredAnonymousUserId]);
/** user_id 验证:调用后端 API优先 payjsapi回退 PocketBase */
const checkVerifyByUserId = useCallback(
async (userId: string): Promise<boolean> => {
try {
const normalizedUserId = userId.trim();
const res = await fetch("/api/vip/check-by-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: normalizedUserId }),
});
const data = await res.json().catch(() => ({}));
if (data?.vip) {
setStorage("userId", normalizedUserId);
if (/^user\d+$/.test(normalizedUserId)) {
setStorage("memosAccount", normalizedUserId);
setAnonymousUserCookie(normalizedUserId);
}
savePaidType("vip");
if (data?.alreadyLinked && data?.email) {
const linkedEmail = String(data.email).trim().toLowerCase();
setStorage("emailLinked", "1");
setStorage("userEmail", linkedEmail);
setStorage("userName", linkedEmail);
setUserEmail(linkedEmail);
saveUserName(linkedEmail);
await recoverLinkedVipSession(
linkedEmail,
DEFAULT_EMAIL_PASSWORD,
normalizedUserId
).catch(() => {});
} else {
const linked = getStorage("emailLinked") === "1";
const hasEmail = getStorage("userEmail") || getStorage("userName")?.includes("@");
if (!linked && !hasEmail) saveUserName(normalizedUserId);
}
return true;
}
savePaidType(null);
return false;
} catch (err) {
console.error("验证失败:", err);
return false;
}
},
[recoverLinkedVipSession, savePaidType, saveUserName]
);
const checkPaymentFromPB = checkVerifyByUserId;
const tryRecoverByAnonymousUserId = useCallback(
async (anonymousUserId: string): Promise<boolean> => {
if (!anonymousUserId || !/^user\d+$/.test(anonymousUserId)) {
return false;
}
const firstHit = await checkPaymentFromPB(anonymousUserId);
if (firstHit) {
return true;
}
const isWechat = getDeviceFromEnv(getPayEnv()) === "wechat";
if (!isWechat) {
return false;
}
// WeChat can close page immediately after pay success; notify write may lag.
for (let attempt = 0; attempt < 4; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const recovered = await checkPaymentFromPB(anonymousUserId);
if (recovered) {
return true;
}
}
return false;
},
[checkPaymentFromPB]
);
/** 会员账号恢复:用用户输入的邮箱+密码调后端查库验证 VIP可选 user_id 用于自动关联 */
const checkVerifyByEmail = useCallback(
async (
email: string,
password: string,
user_id?: string
): Promise<{ ok: boolean; error?: string }> => {
return recoverLinkedVipSession(email, password, user_id);
},
[recoverLinkedVipSession]
);
const clearAllStorage = useCallback(() => {
try {
if (typeof window === "undefined") return;
try {
localStorage.clear();
} catch {
/* ignore */
}
clearAnonymousUserCookie();
clearPendingOrder();
setPaidType(null);
setUserName(null);
const path = window.location.pathname || "/";
window.location.replace(path);
} catch (error) {
console.error("清空存储失败:", error);
}
}, []);
const handlePay = useCallback(() => {
triggerPay(setPayPendingOrderId);
}, []);
const handleVerifySuccess = useCallback(() => {
window.location.reload();
}, []);
const env = getPayEnv();
const device = getDeviceFromEnv(env);
const isH5 = device === "h5";
usePayStatusPoll(
async (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) {
let confirmed = false;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const confirmRes = await fetch("/api/pay/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId, user_id: uid }),
});
const confirmData = await confirmRes.json().catch(() => ({}));
if (confirmData?.ok) {
confirmed = true;
break;
}
} catch (e) {
console.error("pay/confirm request error:", e);
}
if (attempt < 2) await new Promise((r) => setTimeout(r, 1000));
}
if (confirmed) {
saveUserName(uid);
savePaidType("vip");
}
/* confirm 失败时不设置 paidType避免显示 VIP 但 PB/Memos 无记录 */
}
clearPendingOrder();
if (isH5) setPayPendingOrderId(null);
window.location.reload();
},
() => {
clearPendingOrder();
if (isH5) setPayPendingOrderId(null);
},
isH5 ? payPendingOrderId ?? undefined : undefined
);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("type")?.trim() === "clean") {
clearAllStorage();
return;
}
/* paid=1 来自 URL 时不再无条件设置,需通过 confirm 或 PB 校验 */
const init = async () => {
const pendingOrderId = getPendingOrderId();
if (pendingOrderId) {
setPayPendingOrderId(pendingOrderId);
return;
}
// 本地已有 VIP 标识时优先使用,立即展示;后台悄悄校验,若 user 被删或 VIP 被撤则更新本地状态
const localType = getStorage("paidType");
const localUserName = getStorage("userName");
const localUserEmail = getStorage("userEmail");
const localUserId = getStorage("userId");
if (localType === "vip" && (localUserName || localUserEmail) && localUserId) {
// 立即展示 VIP 状态,不阻塞首屏
setPaidType(localType);
setUserName(localUserEmail || localUserName || "");
setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null));
if (!localUserEmail && !localUserName?.includes("@")) {
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
const meData = await meRes.json();
if (meData?.user?.email) setUserEmail(meData.user.email);
} catch { /* ignore */ }
}
// 后台悄悄校验user 被删或 VIP 被撤则清除本地存储并更新状态
const verifyInBackground = async () => {
const isAnonymous = /^user\d+$/.test(localUserId);
let verified = false;
try {
if (isAnonymous) {
verified = await checkPaymentFromPB(localUserId);
} else {
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
verified = !!vipData?.vip;
}
} catch { /* ignore */ }
if (!verified) {
removeStorage("paidType");
removeStorage("userName");
removeStorage("userEmail");
savePaidType(null);
setUserName(null);
setUserEmail(null);
}
};
void verifyInBackground();
return;
}
// 校验逻辑:有本地存储(登录资料+VIP时优先使用并校验无身份/VIP 时点击按钮直接进入支付,不校验
const anonymousCandidate = getStoredAnonymousUserId();
const hadStoredUserId = !!anonymousCandidate;
if (anonymousCandidate) {
const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate);
if (recovered) {
setStorage("userId", anonymousCandidate);
setStorage("memosAccount", anonymousCandidate);
setAnonymousUserCookie(anonymousCandidate);
const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate;
setUserName(name);
setUserEmail(name.includes("@") ? name : getStorage("userEmail"));
return;
}
}
await getOrCreateUserId();
const afterLocalType = getStorage("paidType");
const afterLocalUserName = getStorage("userName");
const afterLocalUserEmail = getStorage("userEmail");
if (afterLocalType && (afterLocalUserName || afterLocalUserEmail)) {
setPaidType(afterLocalType);
setUserName(afterLocalUserEmail || afterLocalUserName || "");
setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null));
return;
}
// 检查本站 Cookie 登录态
try {
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
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 || null);
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
if (vipData?.vip) {
// 匿名 id 不覆盖;仅当当前无匿名 id 时才写入(可能是纯邮箱登录)
const cur = getStorage("userId");
if (cur && /^user\d+$/.test(cur)) {
setStorage("memosAccount", cur);
} else if (
typeof meData.user.anonymousUserId === "string" &&
/^user\d+$/.test(meData.user.anonymousUserId)
) {
setStorage("memosAccount", meData.user.anonymousUserId);
}
setStorage("userId", meData.user.id);
setStorage("emailLinked", "1");
if (meData.user.email) {
setStorage("userEmail", meData.user.email);
}
savePaidType("vip");
saveUserName(meData.user.email || meData.user.id);
return;
}
}
} catch {
/* ignore */
}
// 仅当有本地存储的 userId 时才校验(无身份/VIP 时跳过,点击按钮直接进入支付流程)
if (hadStoredUserId) {
const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId");
if (userIdForCheck) {
const found = await checkPaymentFromPB(userIdForCheck);
if (found) {
const name = getStorage("userEmail") || getStorage("userName");
if (name) setUserName(name);
}
}
}
};
init();
}, [
clearAllStorage,
getOrCreateUserId,
getStoredAnonymousUserId,
checkPaymentFromPB,
savePaidType,
saveUserName,
tryRecoverByAnonymousUserId,
]);
useEffect(() => {
if (!isH5) return;
const syncPendingOrder = () => {
const pendingOrderId = getPendingOrderId();
if (!pendingOrderId) return;
setPayPendingOrderId((current) =>
current === pendingOrderId ? current : pendingOrderId
);
};
const onVisible = () => {
if (document.visibilityState === "visible") {
syncPendingOrder();
}
};
syncPendingOrder();
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("pageshow", syncPendingOrder);
window.addEventListener("focus", syncPendingOrder);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("pageshow", syncPendingOrder);
window.removeEventListener("focus", syncPendingOrder);
};
}, [isH5]);
useEffect(() => {
const syncFromStorage = () => {
const localType = getStorage("paidType");
const localName = getStorage("userEmail") || getStorage("userName");
if (localType && localName && !paidType) {
setPaidType(localType);
setUserName(localName);
}
};
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();
setUserEmail(null);
clearAllStorage();
}, [clearAllStorage]);
const isLoggedIn = paidType === "vip";
return (
<>
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
{isLoggedIn ? (
<DashboardPage userName={userName} userEmail={userEmail} />
) : (
<>
<LandingPage
onJoin={handlePay}
onVerify={checkPaymentFromPB}
onVerifyByEmail={checkVerifyByEmail}
onVerifySuccess={handleVerifySuccess}
/>
</>
)}
</VipLayout>
</>
);
}