From 326bad433630c8580bb2f5f1328cf1c64e6c0506 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 27 Mar 2026 18:20:36 -0500 Subject: [PATCH] 's' --- .env.example | 12 +- .env.local | 17 +- app/HomeClient.tsx | 716 +++++++++++++ app/HomeClientLoader.tsx | 17 + app/api/vip/check/route.ts | 7 +- app/cloudphone/components/ChatFloatButton.tsx | 30 +- app/cloudphone/components/Footer.tsx | 46 +- app/cloudphone/components/Header.tsx | 46 +- app/components/HomeBootOverlay.tsx | 47 + app/config/netdisk-downloads.ts | 6 +- app/data/vip.mock.ts | 6 +- app/page.tsx | 657 +----------- app/topic/components/TopicComingSoon.tsx | 2 +- app/topic/components/TopicFooter.tsx | 46 +- app/topic/components/TopicHeader.tsx | 46 +- app/topic/components/TopicHero.tsx | 27 + app/topic/components/TopicPrepThreeScene.tsx | 405 ++++++++ app/topic/config.ts | 15 +- app/vip/components/DashboardPage.tsx | 9 +- app/vip/components/LandingPage.tsx | 28 +- app/vip/components/VipHeader.tsx | 7 +- .../dashboard/CommunityAccessModal.tsx | 133 +++ app/vip/components/dashboard/WelcomeBlock.tsx | 38 +- app/vip/components/landing/AssetStats.tsx | 2 +- app/vip/components/landing/FAQSection.tsx | 109 +- app/vip/components/landing/LandingCTA.tsx | 107 +- app/vip/components/landing/LandingHero.tsx | 45 +- app/vip/components/landing/LoginForm.tsx | 29 +- app/vip/components/landing/LoginModal.tsx | 85 +- .../components/landing/TopicEbookShowcase.tsx | 21 +- app/vip/components/vip.module.css | 955 ++++++++++++++++++ next.config.ts | 4 +- package.json | 4 +- pnpm-lock.yaml | 59 ++ 34 files changed, 2787 insertions(+), 996 deletions(-) create mode 100644 app/HomeClient.tsx create mode 100644 app/HomeClientLoader.tsx create mode 100644 app/components/HomeBootOverlay.tsx create mode 100644 app/topic/components/TopicPrepThreeScene.tsx create mode 100644 app/vip/components/dashboard/CommunityAccessModal.tsx diff --git a/.env.example b/.env.example index 0a45fb5..65c3492 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,20 @@ # ========== 与 payjsapi/digital/cnomadcna 同源 ========== # 复制为 .env.local,PB/Memos 与 payjsapi 保持一致 +# +# 本地无 PocketBase:仓库已提供写好的 .env.local(与 payjsapi/.env.local 配套)。 +# payjsapi 侧需 PB_INTERNAL_URL;Next 侧需 POCKETBASE_INTERNAL_URL,勿只配 NEXT_PUBLIC_*。 # ========== PocketBase ========== NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn -# 服务端 API 可优先走内网/本机,避免公网超时 -# POCKETBASE_INTERNAL_URL=http://127.0.0.1:8090 +# 服务端直连 PB(本地未起 8090 时必填线上域名) +POCKETBASE_INTERNAL_URL=https://pocketbase.hackrobot.cn POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com POCKETBASE_PASSWORD=Xiao4669805@ # ========== 支付(payjsapi)========== -# 本地开发:127.0.0.1 表示本机 -# 生产同机部署:设 PAYMENT_API_INTERNAL_URL 避免服务器访问自身公网 IP 失败(hairpin NAT) PAYMENT_API_URL=http://127.0.0.1:8007 NEXT_PUBLIC_API_URL=http://127.0.0.1:8007 -# 生产同机部署时取消注释(Next.js 与 payjsapi 同服务器) -# PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007 +PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007 # 内网/慢网络可适当提高超时(毫秒):check_vip 默认 15s,payh5/redirect 默认 60s # PAYJSAPI_CHECK_TIMEOUT=15000 # PAYJSAPI_CHECK_BY_EMAIL_TIMEOUT=20000 diff --git a/.env.local b/.env.local index 2648153..e53effb 100644 --- a/.env.local +++ b/.env.local @@ -1,13 +1,20 @@ -# 本地调试 - 支付测试 1 元、zpay 渠道 +# ========== 本地调试(npm run dev + 本机 payjsapi:8007,无本机 PocketBase)========== +# 服务端 API 必须走 POCKETBASE_INTERNAL_URL,否则会默认 127.0.0.1:8090 NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn +POCKETBASE_INTERNAL_URL=https://pocketbase.hackrobot.cn +POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com +POCKETBASE_PASSWORD=Xiao4669805@ + PAYMENT_API_URL=http://127.0.0.1:8007 +NEXT_PUBLIC_API_URL=http://127.0.0.1:8007 +PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007 + PAYMENT_PROVIDER=zpay PAYMENT_DEFAULT_AMOUNT=100 NEXT_PUBLIC_SITE_ID=vip -NEXT_PUBLIC_API_URL=http://127.0.0.1:8007 +ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php -# 与 payjsapi 同源(pay/confirm 写入 payments/site_vip、创建 Memos) -POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com -POCKETBASE_PASSWORD=Xiao4669805@ MEMOS_API_URL=https://qun.hackrobot.cn MEMOS_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg + +PAYJSAPI_CHECK_BY_EMAIL_TIMEOUT=20000 diff --git a/app/HomeClient.tsx b/app/HomeClient.tsx new file mode 100644 index 0000000..d4355cc --- /dev/null +++ b/app/HomeClient.tsx @@ -0,0 +1,716 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { HomeBootOverlay } from "@/app/components/HomeBootOverlay"; +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; +} + +/** 仅在本组件内使用(由 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 }), + }); + 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); + 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 */ } + } + + // 后台悄悄校验:user 被删或 VIP 被撤则清除本地存储并更新状态 + const verifyInBackground = async () => { + const isAnonymous = /^user\d+$/.test(localUserId); + let verified = false; + let skipClear = 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(); + if (vipData?.adminUnavailable) { + skipClear = true; + } else { + verified = !!vipData?.vip; + } + } + } catch { /* ignore */ } + if (!verified && !skipClear) { + 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 ( + <> + {!initDone && !isLoggedIn ? ( + + ) : null} + + {isLoggedIn ? ( + + ) : showGuestLanding ? ( + <> + + + ) : ( +
+ )} + + + ); +} diff --git a/app/HomeClientLoader.tsx b/app/HomeClientLoader.tsx new file mode 100644 index 0000000..90b1a26 --- /dev/null +++ b/app/HomeClientLoader.tsx @@ -0,0 +1,17 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { HomeBootOverlay } from "@/app/components/HomeBootOverlay"; + +const HomeClient = dynamic(() => import("./HomeClient"), { + ssr: false, + loading: () => ( +
+ +
+ ), +}); + +export function HomeClientLoader() { + return ; +} diff --git a/app/api/vip/check/route.ts b/app/api/vip/check/route.ts index 72e2de1..248a7e6 100644 --- a/app/api/vip/check/route.ts +++ b/app/api/vip/check/route.ts @@ -42,7 +42,12 @@ export async function GET(request: NextRequest) { try { const adminToken = await getAdminToken(); if (!adminToken) { - return NextResponse.json({ ok: true, vip: false }); + // 未配置 POCKETBASE_EMAIL/PASSWORD 等时无法校验;前端勿据此清空本地已登录态 + return NextResponse.json({ + ok: true, + vip: false, + adminUnavailable: true, + }); } const userRecord = await getActiveSiteVipRecord(adminToken, userId); diff --git a/app/cloudphone/components/ChatFloatButton.tsx b/app/cloudphone/components/ChatFloatButton.tsx index 67cd238..743fcfd 100644 --- a/app/cloudphone/components/ChatFloatButton.tsx +++ b/app/cloudphone/components/ChatFloatButton.tsx @@ -24,23 +24,25 @@ export function ChatFloatButton() { if (paid) { return ( - - - - - + + + + + + + ); } return ( - <> + setModalOpen(false)} /> - + ); } diff --git a/app/cloudphone/components/Footer.tsx b/app/cloudphone/components/Footer.tsx index b39f3d0..ba3e33a 100644 --- a/app/cloudphone/components/Footer.tsx +++ b/app/cloudphone/components/Footer.tsx @@ -30,28 +30,32 @@ export function Footer() { 课程首页 {paid ? ( - - - - - 即时问答 - + + + + + + 即时问答 + + ) : ( - + + + )}
diff --git a/app/cloudphone/components/Header.tsx b/app/cloudphone/components/Header.tsx index 071335b..d546f18 100644 --- a/app/cloudphone/components/Header.tsx +++ b/app/cloudphone/components/Header.tsx @@ -28,28 +28,32 @@ export function Header() {
{paid ? ( - - - - - 即时问答 - + + + + + + 即时问答 + + ) : ( - + + + )} 首页 diff --git a/app/components/HomeBootOverlay.tsx b/app/components/HomeBootOverlay.tsx new file mode 100644 index 0000000..39a0a4c --- /dev/null +++ b/app/components/HomeBootOverlay.tsx @@ -0,0 +1,47 @@ +"use client"; + +type HomeBootOverlayProps = { + visible: boolean; + /** 默认:正在同步会员状态… */ + subtitle?: string; +}; + +/** + * 全屏加载(登录验证、首屏等按需使用) + */ +export function HomeBootOverlay({ visible, subtitle }: HomeBootOverlayProps) { + if (!visible) return null; + + const line = subtitle ?? "正在同步会员状态…"; + + return ( +
+
+
+
+ + 🌍 + +
+
+

+ 异度星球 +

+

{line}

+
+
+ ); +} diff --git a/app/config/netdisk-downloads.ts b/app/config/netdisk-downloads.ts index c56426f..6c4a651 100644 --- a/app/config/netdisk-downloads.ts +++ b/app/config/netdisk-downloads.ts @@ -52,11 +52,11 @@ export const NETDISK_RESOURCES: NetdiskResource[] = [ { provider: "quark", label: "夸克网盘", - url: "https://pan.quark.cn/s/f9f489da5777?pwd=qD61", + url: "https://pan.quark.cn/s/f46a37ac3937?pwd=dLwD", codeLabel: "提取码", - code: "qD61", + code: "dLwD", copyText: - "我用夸克网盘给你分享了「直播助手.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。\n/~05213Xseol~:/\n链接:https://pan.quark.cn/s/f9f489da5777?pwd=qD61\n提取码:qD61", + "我用夸克网盘给你分享了「无人直播助手 1.0.1.exe.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。\n/b43c3XtQ8y:/\n链接:https://pan.quark.cn/s/f46a37ac3937?pwd=dLwD\n提取码:dLwD", }, ], }, diff --git a/app/data/vip.mock.ts b/app/data/vip.mock.ts index d087cfa..f4373c3 100644 --- a/app/data/vip.mock.ts +++ b/app/data/vip.mock.ts @@ -61,7 +61,7 @@ export const EBOOKS_DISPLAY = [ { id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone", locked: true }, ] as const; -/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */ +/** 视频教程展示(首页),至少 4 个 */ export const VIDEO_COURSES_DISPLAY = [ { id: "cloudphone", icon: "☁️", title: "云手机", desc: "低成本自建云手机,TikTok/YouTube 无人直播实战指南", href: "/cloudphone" }, { id: "livestream", icon: "📺", title: "无人直播", desc: "TikTok/YouTube 无人直播,24 小时自动化", href: "/topic/livestream" }, @@ -93,8 +93,8 @@ export const FAQ_ITEMS = [ a: "点击「立即加入」完成支付即可,支付成功后自动开通,无需人工审核。", }, { - q: "支持退款吗?", - a: "本内容为虚拟数字交付,不支持退款。购买前请确认适合您的需求。", + q: "为什么越早加入越划算?", + a: "会员采用阶梯定价:越早锁定席位,单价越低;在恢复原价前完成支付,可长期享受全部内容与后续更新,整体投入更省、权益不打折。", }, ] as const; diff --git a/app/page.tsx b/app/page.tsx index 9f3d8fd..f788218 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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(null); - const [userName, setUserName] = useState(null); - const [userEmail, setUserEmail] = useState(null); - 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, { 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 }), - }); - 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); - 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 ( - <> - - {isLoggedIn ? ( - - ) : ( - <> - - - )} - - - ); +export default function Page() { + return ; } diff --git a/app/topic/components/TopicComingSoon.tsx b/app/topic/components/TopicComingSoon.tsx index e035895..fe77959 100644 --- a/app/topic/components/TopicComingSoon.tsx +++ b/app/topic/components/TopicComingSoon.tsx @@ -23,7 +23,7 @@ export function TopicComingSoon({ topicId }: TopicComingSoonProps) { rel="noopener noreferrer" className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90" > - 前往数字游民指南了解更多 → + 了解更多 →
diff --git a/app/topic/components/TopicFooter.tsx b/app/topic/components/TopicFooter.tsx index 7171b8e..1d2b245 100644 --- a/app/topic/components/TopicFooter.tsx +++ b/app/topic/components/TopicFooter.tsx @@ -38,28 +38,32 @@ export function TopicFooter({ topicId }: TopicFooterProps) { 模块列表 {paid ? ( - - - - - 即时问答 - + + + + + + 即时问答 + + ) : ( - + + + )}
diff --git a/app/topic/components/TopicHeader.tsx b/app/topic/components/TopicHeader.tsx index c93add8..2f4b0f4 100644 --- a/app/topic/components/TopicHeader.tsx +++ b/app/topic/components/TopicHeader.tsx @@ -48,28 +48,32 @@ export function TopicHeader({ topicId }: TopicHeaderProps) { 首页 {paid ? ( - - - - - 即时问答 - + + + + + + 即时问答 + + ) : ( - + + + )} {config?.externalUrl ? ( import("./TopicPrepThreeScene").then((m) => m.TopicPrepThreeScene), + { + ssr: false, + loading: () => ( +
+ ), + }, +); type TopicHeroProps = { topicId: string; @@ -36,6 +51,10 @@ export function TopicHero({ topicId }: TopicHeroProps) { const isEnrollCta = !config.externalUrl && !last; + const showPrepThree = + (topicId === "indie" || topicId === "aiagent") && !curriculum?.hasContent; + const prepVariant: TopicPrepVariant = topicId === "indie" ? "indie" : "aiagent"; + return (
@@ -74,6 +93,14 @@ export function TopicHero({ topicId }: TopicHeroProps) {
))}
+ {showPrepThree ? ( +
+ +
+ ) : null} {curriculum?.hasContent && (
(); + const geometries = new Set(); + root.traverse((object) => { + if (object instanceof THREE.Mesh || object instanceof THREE.LineSegments) { + const g = object.geometry; + if (g && !geometries.has(g)) { + geometries.add(g); + g.dispose(); + } + const mat = object.material; + if (Array.isArray(mat)) mat.forEach((m) => materials.add(m)); + else if (mat) materials.add(mat); + } + }); + materials.forEach((m) => m.dispose()); +} + +/** 独立开发:居家书桌 + 显示器 + homelab 机架 */ +function buildIndieDeskScene(root: THREE.Group) { + const wood = new THREE.MeshStandardMaterial({ + color: 0x6b5344, + roughness: 0.88, + metalness: 0.05, + }); + const deskDark = new THREE.MeshStandardMaterial({ + color: 0x18181b, + roughness: 0.55, + metalness: 0.25, + }); + const screenGlow = new THREE.MeshStandardMaterial({ + color: 0x052e16, + emissive: 0x4ade80, + emissiveIntensity: 0.55, + roughness: 0.35, + }); + const keyCap = new THREE.MeshStandardMaterial({ + color: 0x3f3f46, + roughness: 0.45, + metalness: 0.15, + }); + const rackLed = new THREE.MeshStandardMaterial({ + color: 0x0c0a09, + emissive: 0xf59e0b, + emissiveIntensity: 0.5, + roughness: 0.4, + }); + + const floor = new THREE.Mesh(new THREE.PlaneGeometry(7, 5.5), wood); + floor.rotation.x = -Math.PI / 2; + floor.position.y = -1.15; + root.add(floor); + + const rug = new THREE.Mesh(new THREE.PlaneGeometry(2.8, 2), new THREE.MeshStandardMaterial({ + color: 0x3d3832, + roughness: 0.95, + })); + rug.rotation.x = -Math.PI / 2; + rug.position.set(0, -1.14, 0.1); + root.add(rug); + + const deskTop = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.07, 1), deskDark); + deskTop.position.set(0, -0.38, 0.15); + root.add(deskTop); + + const legGeo = new THREE.BoxGeometry(0.08, 0.45, 0.08); + const legPositions: [number, number, number][] = [ + [-1, -0.65, -0.25], + [1, -0.65, -0.25], + [-1, -0.65, 0.55], + [1, -0.65, 0.55], + ]; + for (const [x, y, z] of legPositions) { + const leg = new THREE.Mesh(legGeo, deskDark); + leg.position.set(x, y, z); + root.add(leg); + } + + const monitorBezel = new THREE.Mesh(new THREE.BoxGeometry(1, 0.62, 0.045), deskDark); + monitorBezel.position.set(0, 0.12, -0.28); + root.add(monitorBezel); + + const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.88, 0.52), screenGlow); + screen.position.set(0, 0.12, -0.28 + 0.023); + root.add(screen); + + const stand = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.12, 0.15), deskDark); + stand.position.set(0, -0.28, -0.22); + root.add(stand); + + const keyboard = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.035, 0.3), keyCap); + keyboard.position.set(0, -0.32, 0.38); + keyboard.rotation.x = -0.06; + root.add(keyboard); + + const mug = new THREE.Mesh( + new THREE.CylinderGeometry(0.09, 0.08, 0.12, 16), + new THREE.MeshStandardMaterial({ color: 0x3f3f46, roughness: 0.4 }), + ); + mug.position.set(-0.85, -0.31, 0.42); + root.add(mug); + + const rack = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.95, 0.42), deskDark); + rack.position.set(1.05, 0.1, -0.05); + root.add(rack); + + const rackFace = new THREE.Mesh(new THREE.PlaneGeometry(0.3, 0.82), rackLed); + rackFace.position.set(1.05 + 0.191, 0.1, -0.05); + root.add(rackFace); + + for (let row = 0; row < 4; row++) { + const led = new THREE.Mesh( + new THREE.PlaneGeometry(0.22, 0.04), + new THREE.MeshStandardMaterial({ + color: 0x000000, + emissive: row % 2 === 0 ? 0x22c55e : 0xf59e0b, + emissiveIntensity: 0.65, + }), + ); + led.position.set(1.05 + 0.192, 0.35 - row * 0.18, -0.05); + root.add(led); + } + + const miniServer = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.15, 0.18), deskDark); + miniServer.position.set(1.05, -0.28, 0.45); + root.add(miniServer); +} + +/** AI Agent:赛博空间网格 + 机器人 + 霓虹光环 */ +function buildAiCyberScene(root: THREE.Group) { + const grid = new THREE.GridHelper(9, 28, 0x6366f1, 0x312e81); + grid.position.y = -1.05; + const gMat = grid.material; + if (Array.isArray(gMat)) { + gMat.forEach((m) => { + if ("opacity" in m) { + (m as THREE.Material & { opacity: number }).opacity = 0.45; + m.transparent = true; + } + }); + } else if (gMat && "opacity" in gMat) { + (gMat as THREE.Material & { opacity: number }).opacity = 0.45; + gMat.transparent = true; + } + root.add(grid); + + const bodyMat = new THREE.MeshStandardMaterial({ + color: 0x52525b, + metalness: 0.55, + roughness: 0.28, + flatShading: true, + }); + const glowCyan = new THREE.MeshStandardMaterial({ + color: 0x06b6d4, + emissive: 0x22d3ee, + emissiveIntensity: 0.85, + }); + const glowMagenta = new THREE.MeshStandardMaterial({ + color: 0xd946ef, + emissive: 0xc026d3, + emissiveIntensity: 0.55, + }); + + const robot = new THREE.Group(); + robot.position.y = -0.15; + + const torso = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.58, 0.32), bodyMat); + torso.position.y = 0.1; + robot.add(torso); + + const chestPanel = new THREE.Mesh(new THREE.PlaneGeometry(0.35, 0.25), glowCyan); + chestPanel.position.set(0, 0.12, 0.161); + robot.add(chestPanel); + + const head = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.36, 0.38), bodyMat); + head.position.y = 0.55; + robot.add(head); + + const visor = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.12, 0.02), glowCyan); + visor.position.set(0, 0.52, 0.19); + robot.add(visor); + + const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), glowCyan); + eyeL.position.set(-0.1, 0.56, 0.18); + const eyeR = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), glowCyan); + eyeR.position.set(0.1, 0.56, 0.18); + robot.add(eyeL, eyeR); + + const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.03, 0.28, 8), glowMagenta); + antenna.position.set(0, 0.82, 0); + robot.add(antenna); + + const armL = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.45, 0.12), bodyMat); + armL.position.set(-0.38, 0.05, 0); + const armR = armL.clone(); + armR.position.x = 0.38; + robot.add(armL, armR); + + root.add(robot); + + for (let i = 0; i < 3; i++) { + const ringMat = new THREE.MeshBasicMaterial({ + color: i === 1 ? 0xa855f7 : 0x22d3ee, + transparent: true, + opacity: 0.35 - i * 0.08, + }); + const ring = new THREE.Mesh(new THREE.TorusGeometry(0.95 + i * 0.22, 0.012, 8, 64), ringMat); + ring.rotation.x = Math.PI / 2; + ring.position.y = -0.85 + i * 0.15; + root.add(ring); + } + + const holo = new THREE.Mesh( + new THREE.OctahedronGeometry(0.18, 0), + new THREE.MeshBasicMaterial({ color: 0x67e8f9, wireframe: true, transparent: true, opacity: 0.5 }), + ); + holo.position.set(-0.55, 0.35, 0.35); + root.add(holo); + + return { robot, holo }; +} + +export function TopicPrepThreeScene({ variant }: Props) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + powerPreference: "high-performance", + }); + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100); + camera.position.set(0, 0.35, 5.2); + + const group = new THREE.Group(); + scene.add(group); + + let bobRefs: { robot?: THREE.Group; holo?: THREE.Mesh } | null = null; + + if (variant === "indie") { + scene.background = null; + const ambient = new THREE.AmbientLight(0xfff5e6, 0.45); + scene.add(ambient); + const warm = new THREE.PointLight(0xffb86b, 1.1, 12); + warm.position.set(0.6, 1.2, 1.2); + scene.add(warm); + const cool = new THREE.PointLight(0x4ade80, 0.35, 8); + cool.position.set(-0.3, 0.5, 0.5); + scene.add(cool); + const dir = new THREE.DirectionalLight(0xffffff, 0.35); + dir.position.set(-2, 4, 3); + scene.add(dir); + + buildIndieDeskScene(group); + group.rotation.y = 0.25; + } else { + scene.fog = new THREE.FogExp2(0x0a0a14, 0.055); + const ambient = new THREE.AmbientLight(0x4c1d95, 0.35); + scene.add(ambient); + const key = new THREE.PointLight(0x22d3ee, 1.2, 16); + key.position.set(1.5, 2, 2); + scene.add(key); + const rim = new THREE.PointLight(0xd946ef, 0.85, 14); + rim.position.set(-2, 0.5, -1); + scene.add(rim); + const dir = new THREE.DirectionalLight(0xa78bfa, 0.4); + dir.position.set(0, 3, 4); + scene.add(dir); + + bobRefs = buildAiCyberScene(group); + } + + let rx = 0.1; + let ry = variant === "indie" ? 0.2 : 0; + let vx = 0; + let vy = 0; + const sens = 0.0052; + const damping = 0.93; + const autoRy = variant === "indie" ? 0.0018 : 0.0024; + + let dragging = false; + let lastX = 0; + let lastY = 0; + + const onPointerDown = (e: PointerEvent) => { + dragging = true; + lastX = e.clientX; + lastY = e.clientY; + canvas.setPointerCapture(e.pointerId); + }; + const onPointerMove = (e: PointerEvent) => { + if (!dragging) return; + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + lastX = e.clientX; + lastY = e.clientY; + vy += dx * sens; + vx += dy * sens; + }; + const onPointerEnd = (e: PointerEvent) => { + dragging = false; + try { + canvas.releasePointerCapture(e.pointerId); + } catch { + /* already released */ + } + }; + + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", onPointerEnd); + canvas.addEventListener("pointercancel", onPointerEnd); + canvas.style.touchAction = "none"; + + const resize = () => { + const w = Math.max(280, container.clientWidth); + const h = Math.min(440, Math.max(236, Math.round(w * 0.5))); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(w, h, false); + }; + + const ro = new ResizeObserver(resize); + ro.observe(container); + resize(); + + let raf = 0; + const t0 = performance.now(); + const tick = () => { + raf = requestAnimationFrame(tick); + const t = (performance.now() - t0) * 0.001; + if (!dragging) vy += autoRy; + rx += vx; + ry += vy; + vx *= damping; + vy *= damping; + group.rotation.x = rx; + group.rotation.y = ry; + + if (bobRefs?.robot) { + bobRefs.robot.position.y = -0.15 + Math.sin(t * 1.8) * 0.035; + } + if (bobRefs?.holo) { + bobRefs.holo.rotation.y = t * 0.9; + bobRefs.holo.rotation.x = t * 0.45; + } + + renderer.render(scene, camera); + }; + tick(); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", onPointerEnd); + canvas.removeEventListener("pointercancel", onPointerEnd); + scene.fog = null; + disposeObject3D(group); + scene.remove(group); + renderer.dispose(); + }; + }, [variant]); + + const label = + variant === "indie" ? "独立开发:居家工作台与 homelab 预览" : "AI Agent:赛博空间与机器人预览"; + + const hint = + variant === "indie" + ? "拖动旋转 · 居家书桌、显示器与 homelab 机架(课程筹备视觉预告)" + : "拖动旋转 · 赛博网格空间与机器人(课程筹备视觉预告)"; + + return ( +
+

{hint}

+ +
+ ); +} diff --git a/app/topic/config.ts b/app/topic/config.ts index 584e37d..04c0d36 100644 --- a/app/topic/config.ts +++ b/app/topic/config.ts @@ -12,7 +12,7 @@ export type TopicConfig = { subtitle: string; desc: string; stats: { value: string; label: string }[]; - /** 使用的 curriculum 模块索引,空则用全部 */ + /** 云手机课纲的模块索引;不传则用全部;传 [] 表示本专题暂无课程(勿套用云手机章节) */ moduleIndices?: number[]; /** 外部链接,有则显示「前往了解」而非课程列表 */ externalUrl?: string; @@ -51,12 +51,12 @@ export const TOPIC_CONFIGS: Record = { icon: "🛠️", title: "独立开发", subtitle: "从想法到产品,独立开发者全流程指南", - desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。", + desc: "即将上线,敬请期待。", stats: [ { value: "—", label: "课程筹备中" }, { value: "—", label: "敬请期待" }, ], - externalUrl: "https://digital.hackrobot.cn/zh", + moduleIndices: [], payUrl: "/", }, aiagent: { @@ -64,12 +64,12 @@ export const TOPIC_CONFIGS: Record = { icon: "🤖", title: "AI Agent", subtitle: "AIGC 与智能体,Vibe Coding 跨界创造", - desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。", + desc: "即将上线,敬请期待。", stats: [ { value: "—", label: "课程筹备中" }, { value: "—", label: "敬请期待" }, ], - externalUrl: "https://digital.hackrobot.cn/zh", + moduleIndices: [], payUrl: "/", }, }; @@ -115,7 +115,10 @@ export function getTopicCurriculum(topicId: string) { if (topicId === "livestream") { return { modules: LIVESTREAM_CURRICULUM, hasContent: LIVESTREAM_CURRICULUM.length > 0 }; } - const indices = config.moduleIndices ?? CURRICULUM_CONFIG.modules.map((_, i) => i); + const indices = + config.moduleIndices !== undefined + ? config.moduleIndices + : CURRICULUM_CONFIG.modules.map((_, i) => i); const modules = indices .map((i) => ({ module: CURRICULUM_CONFIG.modules[i], originalIndex: i })) .filter((x) => x.module) diff --git a/app/vip/components/DashboardPage.tsx b/app/vip/components/DashboardPage.tsx index 9f837b3..b8bf30c 100644 --- a/app/vip/components/DashboardPage.tsx +++ b/app/vip/components/DashboardPage.tsx @@ -1,6 +1,8 @@ "use client"; +import { useState } from "react"; import { WelcomeBlock } from "./dashboard/WelcomeBlock"; +import { CommunityAccessModal } from "./dashboard/CommunityAccessModal"; import { UnlockedStats } from "./dashboard/UnlockedStats"; import { TopicEbookShowcase } from "./landing/TopicEbookShowcase"; @@ -31,6 +33,7 @@ function requiresEmailBinding(): boolean { export function DashboardPage({ userName, userEmail }: DashboardPageProps) { const bindingRequired = requiresEmailBinding(); + const [communityModalOpen, setCommunityModalOpen] = useState(false); return ( <> @@ -40,7 +43,11 @@ export function DashboardPage({ userName, userEmail }: DashboardPageProps) { bindingRequired={bindingRequired} /> - + setCommunityModalOpen(true)} /> + setCommunityModalOpen(false)} + /> ); } diff --git a/app/vip/components/LandingPage.tsx b/app/vip/components/LandingPage.tsx index d38d4e7..7b985e1 100644 --- a/app/vip/components/LandingPage.tsx +++ b/app/vip/components/LandingPage.tsx @@ -9,6 +9,7 @@ import { CommunityPreview } from "./landing/CommunityPreview"; import { FAQSection } from "./landing/FAQSection"; import { LandingCTA } from "./landing/LandingCTA"; import { LoginModal } from "./landing/LoginModal"; +import styles from "./vip.module.css"; type LandingPageProps = { onJoin: () => void; @@ -31,13 +32,26 @@ export function LandingPage({ return ( <> - - - setLoginModalOpen(true)} /> - - - - setLoginModalOpen(true)} /> +
+
+
+
+
+
+
+
+
+ + + setLoginModalOpen(true)} /> + +
+ +
+ + setLoginModalOpen(true)} /> +
+
setLoginModalOpen(false)} diff --git a/app/vip/components/VipHeader.tsx b/app/vip/components/VipHeader.tsx index dbbe56c..0b26e61 100644 --- a/app/vip/components/VipHeader.tsx +++ b/app/vip/components/VipHeader.tsx @@ -43,12 +43,7 @@ export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHead return (
- + 🌍 异度星球