"use client"; import { useCallback, useEffect, useState } from "react"; 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 styles from "@/app/vip/components/vip.module.css"; /** 微信内支付渠道:zpay | xorpay */ const WECHAT_PAY_PROVIDER: "zpay" | "xorpay" = "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 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"; }, onPendingOrder?: (orderId: string) => void): Promise { 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 (orderId) { savePendingOrder(orderId); onPendingOrder?.(orderId); } if (data?.redirect_url) { window.location.href = data.redirect_url; return orderId; } 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)) { 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; const orderId = data?.order_id?.trim(); if (orderId) { savePendingOrder(orderId); } form.target = "_self"; form.style.display = "none"; for (const [k, v] of Object.entries(data.params as Record)) { 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); } 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; } export default function Home() { const [paidType, setPaidType] = useState(null); const [userName, setUserName] = useState(null); const [userEmail, setUserEmail] = useState(null); const [loading, setLoading] = useState(true); const [payPendingOrderId, setPayPendingOrderId] = useState(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, data.record); 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 => { 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 => { 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); setLoading(false); const path = window.location.pathname || "/"; window.location.replace(path); } catch (error) { console.error("清空存储失败:", error); } }, []); const doPay = useCallback(async () => { const userId = await getOrCreateUserId(); if (/^user\d+$/.test(userId)) { setStorage("memosAccount", userId); setStorage("userId", userId); setAnonymousUserCookie(userId); } 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, setPayPendingOrderId); if (orderId) setPayPendingOrderId(orderId); } else { redirectToPayZpay({ ...base, device }); } }, [getOrCreateUserId]); const handlePay = useCallback(() => { doPay(); }, [doPay]); 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); setLoading(false); return; } // 本地已有 VIP 标识时优先使用,不因跨站登录态覆盖 const localType = getStorage("paidType"); const localUserName = getStorage("userName"); const localUserEmail = getStorage("userEmail"); const localUserId = getStorage("userId"); if (localType === "vip" && (localUserName || localUserEmail) && localUserId) { const isAnonymous = /^user\d+$/.test(localUserId); let verified = false; 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; } if (verified) { 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 */ } } setLoading(false); return; } else { removeStorage("paidType"); removeStorage("userName"); removeStorage("userEmail"); } } const anonymousCandidate = getStoredAnonymousUserId(); 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")); setLoading(false); 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)); setLoading(false); 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); setLoading(false); return; } } } catch { /* ignore */ } const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId"); if (userIdForCheck) { const found = await checkPaymentFromPB(userIdForCheck); if (found) { const name = getStorage("userEmail") || getStorage("userName"); if (name) setUserName(name); } } setLoading(false); }; 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"; if (loading) { return (

加载中...

); } return ( <> {isLoggedIn ? ( ) : ( <> )} ); }