This commit is contained in:
eric
2026-03-27 18:20:36 -05:00
parent a8976b2eee
commit 326bad4336
34 changed files with 2787 additions and 996 deletions

View File

@@ -1,656 +1,5 @@
"use client";
import { HomeClientLoader } from "./HomeClientLoader";
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;
const EMAIL_VERIFY_TS_KEY = "emailVerifyAt";
const EMAIL_VERIFY_COOLDOWN_MS = 60 * 1000;
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);
setStorage(EMAIL_VERIFY_TS_KEY, String(Date.now()));
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;
const emailVerifiedAt = Number(getStorage(EMAIL_VERIFY_TS_KEY) || "0");
const skipAnonymousRecover =
!!(getStorage("emailLinked") === "1" && Date.now() - emailVerifiedAt < EMAIL_VERIFY_COOLDOWN_MS);
if (anonymousCandidate && !skipAnonymousRecover) {
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>
</>
);
export default function Page() {
return <HomeClientLoader />;
}