"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"; // 渠道规则: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; } type VipProbeResult = "yes" | "no" | "unknown"; /** 仅当明确 yes/no;弱网/5xx/服务端 uncertain 一律 unknown,禁止用来清空本地 VIP */ function probeFromCheckByUserBody( data: Record | null, resOk: boolean ): VipProbeResult { if (!resOk || !data || typeof data !== "object") return "unknown"; if (data.uncertain === true) return "unknown"; if (data.vip === true) return "yes"; if (data.ok === true && data.vip === false) return "no"; if (data.vip === false && data.ok !== false) return "no"; return "unknown"; } async function probeCheckByUserId(userId: string): Promise { const trimmed = userId.trim(); if (!trimmed) return "unknown"; try { const res = await fetch("/api/vip/check-by-user", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: trimmed }), credentials: "same-origin", cache: "no-store", }); const data = (await res.json().catch(() => null)) as Record | null; return probeFromCheckByUserBody(data, res.ok); } catch { return "unknown"; } } async function probeSessionVip(): Promise { try { const res = await fetch("/api/vip/check", { credentials: "include", cache: "no-store", }); const data = (await res.json().catch(() => null)) as Record | null; if (!res.ok) return "unknown"; if (!data || typeof data !== "object") return "unknown"; if (data.adminUnavailable === true) return "unknown"; if (data.uncertain === true) return "unknown"; if (data.vip === true) return "yes"; return "no"; } catch { return "unknown"; } } /** 仅在本组件内使用(由 page.tsx dynamic ssr:false 仅客户端挂载,首帧可读 localStorage) */ function readVipBootstrapState(): { paidType: string | null; userName: string | null; userEmail: string | null; } { if (typeof window === "undefined") { return { paidType: null, userName: null, userEmail: null }; } const localType = localStorage.getItem("paidType"); const localUserName = localStorage.getItem("userName"); const localUserEmail = localStorage.getItem("userEmail"); const localUserId = localStorage.getItem("userId"); if ( localType === "vip" && (localUserName || localUserEmail) && localUserId ) { return { paidType: localType, userName: localUserEmail || localUserName || "", userEmail: localUserEmail || (localUserName?.includes("@") ? localUserName : null), }; } return { paidType: null, userName: null, userEmail: null }; } /** 首页:init 完成前若尚未从本地恢复 VIP,不渲染落地页(避免 H5 reload 后先闪未解锁) */ export default function HomeClient() { const [paidType, setPaidType] = useState( () => readVipBootstrapState().paidType ); const [userName, setUserName] = useState( () => readVipBootstrapState().userName ); const [userEmail, setUserEmail] = useState( () => readVipBootstrapState().userEmail ); const [payPendingOrderId, setPayPendingOrderId] = useState(null); const [initDone, setInitDone] = useState(false); 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 => { 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 }), credentials: "same-origin", cache: "no-store", }); const data = (await res.json().catch(() => null)) as Record | null; const probe = probeFromCheckByUserBody(data, res.ok); if (probe === "unknown") { return false; } if (probe === "no") { savePaidType(null); return false; } if (!data?.vip) { return false; } 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; } catch (err) { console.error("验证失败:", err); return false; } }, [recoverLinkedVipSession, savePaidType, saveUserName] ); const checkPaymentFromPB = checkVerifyByUserId; const tryRecoverByAnonymousUserId = useCallback( async (anonymousUserId: string): Promise => { 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(); setInitDone(true); return; } /* paid=1 来自 URL 时不再无条件设置,需通过 confirm 或 PB 校验 */ const init = async () => { const pendingOrderId = getPendingOrderId(); // 已有 VIP 时忽略陈旧支付轮询标记,否则会 early return,init 从不恢复 VIP,表现为「登录后仍显示未解锁」 if (pendingOrderId) { const alreadyVip = getStorage("paidType") === "vip"; if (alreadyVip) { clearPendingOrder(); } else { 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 */ } } // 后台悄悄校验:仅当服务端明确返回「非 VIP」才清本地;弱网/异常/uncertain 一律保留(避免微信内误踢) const verifyInBackground = async () => { const isAnonymous = /^user\d+$/.test(localUserId); const probe = isAnonymous ? await probeCheckByUserId(localUserId) : await probeSessionVip(); if (probe !== "no") { return; } 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); } } } }; void (async () => { try { await init(); } finally { setInitDone(true); } })(); }, [ 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"; const showGuestLanding = initDone && !isLoggedIn; return ( <> {isLoggedIn ? ( ) : showGuestLanding ? ( <> ) : (
)} ); }