's'
This commit is contained in:
716
app/HomeClient.tsx
Normal file
716
app/HomeClient.tsx
Normal file
@@ -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<string | null>(
|
||||
() => readVipBootstrapState().paidType
|
||||
);
|
||||
const [userName, setUserName] = useState<string | null>(
|
||||
() => readVipBootstrapState().userName
|
||||
);
|
||||
const [userEmail, setUserEmail] = useState<string | null>(
|
||||
() => readVipBootstrapState().userEmail
|
||||
);
|
||||
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(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<boolean> => {
|
||||
try {
|
||||
const normalizedUserId = userId.trim();
|
||||
const res = await fetch("/api/vip/check-by-user", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: normalizedUserId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.vip) {
|
||||
setStorage("userId", normalizedUserId);
|
||||
if (/^user\d+$/.test(normalizedUserId)) {
|
||||
setStorage("memosAccount", normalizedUserId);
|
||||
setAnonymousUserCookie(normalizedUserId);
|
||||
}
|
||||
savePaidType("vip");
|
||||
if (data?.alreadyLinked && data?.email) {
|
||||
const linkedEmail = String(data.email).trim().toLowerCase();
|
||||
setStorage("emailLinked", "1");
|
||||
setStorage("userEmail", linkedEmail);
|
||||
setStorage("userName", linkedEmail);
|
||||
setUserEmail(linkedEmail);
|
||||
saveUserName(linkedEmail);
|
||||
await recoverLinkedVipSession(
|
||||
linkedEmail,
|
||||
DEFAULT_EMAIL_PASSWORD,
|
||||
normalizedUserId
|
||||
).catch(() => {});
|
||||
} else {
|
||||
const linked = getStorage("emailLinked") === "1";
|
||||
const hasEmail = getStorage("userEmail") || getStorage("userName")?.includes("@");
|
||||
if (!linked && !hasEmail) saveUserName(normalizedUserId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
savePaidType(null);
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error("验证失败:", err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[recoverLinkedVipSession, savePaidType, saveUserName]
|
||||
);
|
||||
|
||||
const checkPaymentFromPB = checkVerifyByUserId;
|
||||
|
||||
const tryRecoverByAnonymousUserId = useCallback(
|
||||
async (anonymousUserId: string): Promise<boolean> => {
|
||||
if (!anonymousUserId || !/^user\d+$/.test(anonymousUserId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstHit = await checkPaymentFromPB(anonymousUserId);
|
||||
if (firstHit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isWechat = getDeviceFromEnv(getPayEnv()) === "wechat";
|
||||
if (!isWechat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// WeChat can close page immediately after pay success; notify write may lag.
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const recovered = await checkPaymentFromPB(anonymousUserId);
|
||||
if (recovered) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[checkPaymentFromPB]
|
||||
);
|
||||
|
||||
/** 会员账号恢复:用用户输入的邮箱+密码调后端查库验证 VIP,可选 user_id 用于自动关联 */
|
||||
const checkVerifyByEmail = useCallback(
|
||||
async (
|
||||
email: string,
|
||||
password: string,
|
||||
user_id?: string
|
||||
): Promise<{ ok: boolean; error?: string }> => {
|
||||
return recoverLinkedVipSession(email, password, user_id);
|
||||
},
|
||||
[recoverLinkedVipSession]
|
||||
);
|
||||
|
||||
const clearAllStorage = useCallback(() => {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clearAnonymousUserCookie();
|
||||
clearPendingOrder();
|
||||
setPaidType(null);
|
||||
setUserName(null);
|
||||
const path = window.location.pathname || "/";
|
||||
window.location.replace(path);
|
||||
} catch (error) {
|
||||
console.error("清空存储失败:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePay = useCallback(() => {
|
||||
triggerPay(setPayPendingOrderId);
|
||||
}, []);
|
||||
|
||||
const handleVerifySuccess = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const env = getPayEnv();
|
||||
const device = getDeviceFromEnv(env);
|
||||
const isH5 = device === "h5";
|
||||
usePayStatusPoll(
|
||||
async (orderId) => {
|
||||
let uid = getStorage("userId");
|
||||
if (!uid && orderId?.includes("_order_")) {
|
||||
const prefix = orderId.split("_order_")[0];
|
||||
const parts = prefix.split("_");
|
||||
if (parts.length >= 2) uid = parts.slice(0, -1).join("_");
|
||||
}
|
||||
if (uid) {
|
||||
let confirmed = false;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const confirmRes = await fetch("/api/pay/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ order_id: orderId, user_id: uid }),
|
||||
});
|
||||
const confirmData = await confirmRes.json().catch(() => ({}));
|
||||
if (confirmData?.ok) {
|
||||
confirmed = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("pay/confirm request error:", e);
|
||||
}
|
||||
if (attempt < 2) await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
if (confirmed) {
|
||||
saveUserName(uid);
|
||||
savePaidType("vip");
|
||||
}
|
||||
/* confirm 失败时不设置 paidType,避免显示 VIP 但 PB/Memos 无记录 */
|
||||
}
|
||||
clearPendingOrder();
|
||||
if (isH5) setPayPendingOrderId(null);
|
||||
window.location.reload();
|
||||
},
|
||||
() => {
|
||||
clearPendingOrder();
|
||||
if (isH5) setPayPendingOrderId(null);
|
||||
},
|
||||
isH5 ? payPendingOrderId ?? undefined : undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get("type")?.trim() === "clean") {
|
||||
clearAllStorage();
|
||||
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 ? (
|
||||
<HomeBootOverlay visible subtitle="正在同步会员状态…" />
|
||||
) : null}
|
||||
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
|
||||
{isLoggedIn ? (
|
||||
<DashboardPage userName={userName} userEmail={userEmail} />
|
||||
) : showGuestLanding ? (
|
||||
<>
|
||||
<LandingPage
|
||||
onJoin={handlePay}
|
||||
onVerify={checkPaymentFromPB}
|
||||
onVerifyByEmail={checkVerifyByEmail}
|
||||
onVerifySuccess={handleVerifySuccess}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="min-h-[50vh]" aria-hidden />
|
||||
)}
|
||||
</VipLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
app/HomeClientLoader.tsx
Normal file
17
app/HomeClientLoader.tsx
Normal file
@@ -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: () => (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<HomeBootOverlay visible subtitle="加载中…" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export function HomeClientLoader() {
|
||||
return <HomeClient />;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -24,23 +24,25 @@ export function ChatFloatButton() {
|
||||
|
||||
if (paid) {
|
||||
return (
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${baseClass} ${paidClass}`}
|
||||
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
|
||||
aria-label="即时问答"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
<span className="hidden">
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${baseClass} ${paidClass}`}
|
||||
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
|
||||
aria-label="即时问答"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
@@ -54,6 +56,6 @@ export function ChatFloatButton() {
|
||||
</svg>
|
||||
</button>
|
||||
<InstantQaVipModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,28 +30,32 @@ export function Footer() {
|
||||
课程首页
|
||||
</Link>
|
||||
{paid ? (
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
<span className="hidden">
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
<span className="hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-sm text-[var(--muted-foreground)]">
|
||||
|
||||
@@ -28,28 +28,32 @@ export function Header() {
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
|
||||
<ThemeToggle />
|
||||
{paid ? (
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
<span className="hidden">
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQaModalOpen(true)}
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
<span className="hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQaModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||
首页
|
||||
|
||||
47
app/components/HomeBootOverlay.tsx
Normal file
47
app/components/HomeBootOverlay.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex flex-col items-center justify-center gap-6 bg-[var(--background)]/92 backdrop-blur-md"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="relative flex h-20 w-20 items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 animate-spin rounded-full border-2 border-amber-500/20 border-t-amber-500"
|
||||
style={{ animationDuration: "1.05s" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-2 animate-spin rounded-full border-2 border-transparent border-b-amber-400/50"
|
||||
style={{ animationDuration: "0.75s", animationDirection: "reverse" }}
|
||||
/>
|
||||
<span
|
||||
className="animate__animated animate__pulse animate__infinite text-3xl drop-shadow-sm"
|
||||
aria-hidden
|
||||
>
|
||||
🌍
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 px-6 text-center">
|
||||
<p className="text-base font-semibold tracking-wide text-[var(--foreground)]">
|
||||
异度星球
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{line}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
657
app/page.tsx
657
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<string | null>(null);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
const [payPendingOrderId, setPayPendingOrderId] = useState<string | null>(null);
|
||||
|
||||
const savePaidType = useCallback((type: string | null) => {
|
||||
if (type) setStorage("paidType", type);
|
||||
else removeStorage("paidType");
|
||||
setPaidType(type);
|
||||
}, []);
|
||||
|
||||
const saveUserName = useCallback((name: string) => {
|
||||
setStorage("userName", name);
|
||||
setUserName(name);
|
||||
}, []);
|
||||
|
||||
const getStoredAnonymousUserId = useCallback((): string | null => {
|
||||
const memosAccount = getStorage("memosAccount");
|
||||
if (memosAccount && /^user\d+$/.test(memosAccount)) {
|
||||
return memosAccount;
|
||||
}
|
||||
|
||||
const userId = getStorage("userId");
|
||||
if (userId && /^user\d+$/.test(userId)) {
|
||||
return userId;
|
||||
}
|
||||
|
||||
const cookieUserId = getCookie(ANONYMOUS_USER_COOKIE);
|
||||
if (cookieUserId && /^user\d+$/.test(cookieUserId)) {
|
||||
setStorage("memosAccount", cookieUserId);
|
||||
return cookieUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const applyRecoveredEmailSession = useCallback(
|
||||
async (
|
||||
data: {
|
||||
token?: string;
|
||||
record?: { id?: string; email?: string };
|
||||
anonymousUserId?: string | null;
|
||||
memosAccount?: string | null;
|
||||
},
|
||||
normalizedEmail: string,
|
||||
fallbackAnonymousUserId?: string
|
||||
) => {
|
||||
if (!data?.token || !data?.record?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||
pbSaveAuth(data.token, { id: data.record.id, email: data.record.email ?? normalizedEmail });
|
||||
|
||||
const anonymousUserId =
|
||||
(typeof data?.anonymousUserId === "string" &&
|
||||
/^user\d+$/.test(data.anonymousUserId)
|
||||
? data.anonymousUserId
|
||||
: typeof data?.memosAccount === "string" &&
|
||||
/^user\d+$/.test(data.memosAccount)
|
||||
? data.memosAccount
|
||||
: /^user\d+$/.test(fallbackAnonymousUserId?.trim() || "")
|
||||
? fallbackAnonymousUserId?.trim()
|
||||
: "") || "";
|
||||
|
||||
await fetch("/api/auth/sync-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: data.token,
|
||||
record: data.record,
|
||||
anonymousUserId: anonymousUserId || undefined,
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
setStorage("userId", data.record.id);
|
||||
setStorage("userEmail", normalizedEmail);
|
||||
setStorage("userName", normalizedEmail);
|
||||
setStorage("emailLinked", "1");
|
||||
if (anonymousUserId) {
|
||||
setStorage("memosAccount", anonymousUserId);
|
||||
setAnonymousUserCookie(anonymousUserId);
|
||||
}
|
||||
|
||||
savePaidType("vip");
|
||||
setUserEmail(normalizedEmail);
|
||||
saveUserName(normalizedEmail);
|
||||
setStorage(EMAIL_VERIFY_TS_KEY, String(Date.now()));
|
||||
return true;
|
||||
},
|
||||
[savePaidType, saveUserName]
|
||||
);
|
||||
|
||||
const recoverLinkedVipSession = useCallback(
|
||||
async (
|
||||
email: string,
|
||||
password: string,
|
||||
user_id?: string
|
||||
): Promise<{ ok: boolean; error?: string }> => {
|
||||
try {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const res = await fetch("/api/vip/check-by-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: normalizedEmail,
|
||||
password,
|
||||
...(user_id?.trim() && { user_id: user_id.trim() }),
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data?.vip || !data?.token || !data?.record) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
data?.error ||
|
||||
"未找到关联的会员记录,请确认已支付并绑定邮箱",
|
||||
};
|
||||
}
|
||||
|
||||
const ok = await applyRecoveredEmailSession(
|
||||
data,
|
||||
normalizedEmail,
|
||||
user_id
|
||||
);
|
||||
return ok ? { ok: true } : { ok: false, error: "登录恢复失败" };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "验证失败";
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
},
|
||||
[applyRecoveredEmailSession]
|
||||
);
|
||||
|
||||
const getOrCreateUserId = useCallback(async () => {
|
||||
const anonymousUserId = getStoredAnonymousUserId();
|
||||
if (anonymousUserId) {
|
||||
setStorage("userId", anonymousUserId);
|
||||
setAnonymousUserCookie(anonymousUserId);
|
||||
return anonymousUserId;
|
||||
}
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||
const meData = await meRes.json();
|
||||
if (meData?.user?.id) {
|
||||
const pbUserId = meData.user.id;
|
||||
setStorage("userId", pbUserId);
|
||||
return pbUserId;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
let userId = getStorage("userId");
|
||||
if (userId?.startsWith("{") && userId.includes("data")) {
|
||||
try {
|
||||
const parsed = JSON.parse(userId) as { data?: string };
|
||||
if (parsed?.data) {
|
||||
userId = parsed.data;
|
||||
setStorage("userId", userId);
|
||||
}
|
||||
} catch {
|
||||
userId = null;
|
||||
}
|
||||
}
|
||||
if (!userId) {
|
||||
userId = `user${Date.now()}`;
|
||||
setStorage("userId", userId);
|
||||
}
|
||||
return userId;
|
||||
}, [getStoredAnonymousUserId]);
|
||||
|
||||
/** user_id 验证:调用后端 API(优先 payjsapi,回退 PocketBase) */
|
||||
const checkVerifyByUserId = useCallback(
|
||||
async (userId: string): Promise<boolean> => {
|
||||
try {
|
||||
const normalizedUserId = userId.trim();
|
||||
const res = await fetch("/api/vip/check-by-user", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: normalizedUserId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.vip) {
|
||||
setStorage("userId", normalizedUserId);
|
||||
if (/^user\d+$/.test(normalizedUserId)) {
|
||||
setStorage("memosAccount", normalizedUserId);
|
||||
setAnonymousUserCookie(normalizedUserId);
|
||||
}
|
||||
savePaidType("vip");
|
||||
if (data?.alreadyLinked && data?.email) {
|
||||
const linkedEmail = String(data.email).trim().toLowerCase();
|
||||
setStorage("emailLinked", "1");
|
||||
setStorage("userEmail", linkedEmail);
|
||||
setStorage("userName", linkedEmail);
|
||||
setUserEmail(linkedEmail);
|
||||
saveUserName(linkedEmail);
|
||||
await recoverLinkedVipSession(
|
||||
linkedEmail,
|
||||
DEFAULT_EMAIL_PASSWORD,
|
||||
normalizedUserId
|
||||
).catch(() => {});
|
||||
} else {
|
||||
const linked = getStorage("emailLinked") === "1";
|
||||
const hasEmail = getStorage("userEmail") || getStorage("userName")?.includes("@");
|
||||
if (!linked && !hasEmail) saveUserName(normalizedUserId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
savePaidType(null);
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error("验证失败:", err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[recoverLinkedVipSession, savePaidType, saveUserName]
|
||||
);
|
||||
|
||||
const checkPaymentFromPB = checkVerifyByUserId;
|
||||
|
||||
const tryRecoverByAnonymousUserId = useCallback(
|
||||
async (anonymousUserId: string): Promise<boolean> => {
|
||||
if (!anonymousUserId || !/^user\d+$/.test(anonymousUserId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstHit = await checkPaymentFromPB(anonymousUserId);
|
||||
if (firstHit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isWechat = getDeviceFromEnv(getPayEnv()) === "wechat";
|
||||
if (!isWechat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// WeChat can close page immediately after pay success; notify write may lag.
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const recovered = await checkPaymentFromPB(anonymousUserId);
|
||||
if (recovered) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[checkPaymentFromPB]
|
||||
);
|
||||
|
||||
/** 会员账号恢复:用用户输入的邮箱+密码调后端查库验证 VIP,可选 user_id 用于自动关联 */
|
||||
const checkVerifyByEmail = useCallback(
|
||||
async (
|
||||
email: string,
|
||||
password: string,
|
||||
user_id?: string
|
||||
): Promise<{ ok: boolean; error?: string }> => {
|
||||
return recoverLinkedVipSession(email, password, user_id);
|
||||
},
|
||||
[recoverLinkedVipSession]
|
||||
);
|
||||
|
||||
const clearAllStorage = useCallback(() => {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clearAnonymousUserCookie();
|
||||
clearPendingOrder();
|
||||
setPaidType(null);
|
||||
setUserName(null);
|
||||
const path = window.location.pathname || "/";
|
||||
window.location.replace(path);
|
||||
} catch (error) {
|
||||
console.error("清空存储失败:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePay = useCallback(() => {
|
||||
triggerPay(setPayPendingOrderId);
|
||||
}, []);
|
||||
|
||||
const handleVerifySuccess = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const env = getPayEnv();
|
||||
const device = getDeviceFromEnv(env);
|
||||
const isH5 = device === "h5";
|
||||
usePayStatusPoll(
|
||||
async (orderId) => {
|
||||
let uid = getStorage("userId");
|
||||
if (!uid && orderId?.includes("_order_")) {
|
||||
const prefix = orderId.split("_order_")[0];
|
||||
const parts = prefix.split("_");
|
||||
if (parts.length >= 2) uid = parts.slice(0, -1).join("_");
|
||||
}
|
||||
if (uid) {
|
||||
let confirmed = false;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const confirmRes = await fetch("/api/pay/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ order_id: orderId, user_id: uid }),
|
||||
});
|
||||
const confirmData = await confirmRes.json().catch(() => ({}));
|
||||
if (confirmData?.ok) {
|
||||
confirmed = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("pay/confirm request error:", e);
|
||||
}
|
||||
if (attempt < 2) await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
if (confirmed) {
|
||||
saveUserName(uid);
|
||||
savePaidType("vip");
|
||||
}
|
||||
/* confirm 失败时不设置 paidType,避免显示 VIP 但 PB/Memos 无记录 */
|
||||
}
|
||||
clearPendingOrder();
|
||||
if (isH5) setPayPendingOrderId(null);
|
||||
window.location.reload();
|
||||
},
|
||||
() => {
|
||||
clearPendingOrder();
|
||||
if (isH5) setPayPendingOrderId(null);
|
||||
},
|
||||
isH5 ? payPendingOrderId ?? undefined : undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get("type")?.trim() === "clean") {
|
||||
clearAllStorage();
|
||||
return;
|
||||
}
|
||||
/* paid=1 来自 URL 时不再无条件设置,需通过 confirm 或 PB 校验 */
|
||||
|
||||
const init = async () => {
|
||||
const pendingOrderId = getPendingOrderId();
|
||||
if (pendingOrderId) {
|
||||
setPayPendingOrderId(pendingOrderId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 本地已有 VIP 标识时优先使用,立即展示;后台悄悄校验,若 user 被删或 VIP 被撤则更新本地状态
|
||||
const localType = getStorage("paidType");
|
||||
const localUserName = getStorage("userName");
|
||||
const localUserEmail = getStorage("userEmail");
|
||||
const localUserId = getStorage("userId");
|
||||
if (localType === "vip" && (localUserName || localUserEmail) && localUserId) {
|
||||
// 立即展示 VIP 状态,不阻塞首屏
|
||||
setPaidType(localType);
|
||||
setUserName(localUserEmail || localUserName || "");
|
||||
setUserEmail(localUserEmail || (localUserName?.includes("@") ? localUserName : null));
|
||||
if (!localUserEmail && !localUserName?.includes("@")) {
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||
const meData = await meRes.json();
|
||||
if (meData?.user?.email) setUserEmail(meData.user.email);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 后台悄悄校验:user 被删或 VIP 被撤则清除本地存储并更新状态
|
||||
const verifyInBackground = async () => {
|
||||
const isAnonymous = /^user\d+$/.test(localUserId);
|
||||
let verified = false;
|
||||
try {
|
||||
if (isAnonymous) {
|
||||
verified = await checkPaymentFromPB(localUserId);
|
||||
} else {
|
||||
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||
const vipData = await vipRes.json();
|
||||
verified = !!vipData?.vip;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (!verified) {
|
||||
removeStorage("paidType");
|
||||
removeStorage("userName");
|
||||
removeStorage("userEmail");
|
||||
savePaidType(null);
|
||||
setUserName(null);
|
||||
setUserEmail(null);
|
||||
}
|
||||
};
|
||||
void verifyInBackground();
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验逻辑:有本地存储(登录资料+VIP)时优先使用并校验;无身份/VIP 时点击按钮直接进入支付,不校验
|
||||
const anonymousCandidate = getStoredAnonymousUserId();
|
||||
const hadStoredUserId = !!anonymousCandidate;
|
||||
const emailVerifiedAt = Number(getStorage(EMAIL_VERIFY_TS_KEY) || "0");
|
||||
const skipAnonymousRecover =
|
||||
!!(getStorage("emailLinked") === "1" && Date.now() - emailVerifiedAt < EMAIL_VERIFY_COOLDOWN_MS);
|
||||
if (anonymousCandidate && !skipAnonymousRecover) {
|
||||
const recovered = await tryRecoverByAnonymousUserId(anonymousCandidate);
|
||||
if (recovered) {
|
||||
setStorage("userId", anonymousCandidate);
|
||||
setStorage("memosAccount", anonymousCandidate);
|
||||
setAnonymousUserCookie(anonymousCandidate);
|
||||
const name = getStorage("userEmail") || getStorage("userName") || anonymousCandidate;
|
||||
setUserName(name);
|
||||
setUserEmail(name.includes("@") ? name : getStorage("userEmail"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await getOrCreateUserId();
|
||||
|
||||
const afterLocalType = getStorage("paidType");
|
||||
const afterLocalUserName = getStorage("userName");
|
||||
const afterLocalUserEmail = getStorage("userEmail");
|
||||
if (afterLocalType && (afterLocalUserName || afterLocalUserEmail)) {
|
||||
setPaidType(afterLocalType);
|
||||
setUserName(afterLocalUserEmail || afterLocalUserName || "");
|
||||
setUserEmail(afterLocalUserEmail || (afterLocalUserName?.includes("@") ? afterLocalUserName : null));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查本站 Cookie 登录态
|
||||
try {
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||
const meData = await meRes.json();
|
||||
if (meData?.user && meData?.token) {
|
||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||
pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email });
|
||||
setUserEmail(meData.user.email || null);
|
||||
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||
const vipData = await vipRes.json();
|
||||
if (vipData?.vip) {
|
||||
// 匿名 id 不覆盖;仅当当前无匿名 id 时才写入(可能是纯邮箱登录)
|
||||
const cur = getStorage("userId");
|
||||
if (cur && /^user\d+$/.test(cur)) {
|
||||
setStorage("memosAccount", cur);
|
||||
} else if (
|
||||
typeof meData.user.anonymousUserId === "string" &&
|
||||
/^user\d+$/.test(meData.user.anonymousUserId)
|
||||
) {
|
||||
setStorage("memosAccount", meData.user.anonymousUserId);
|
||||
}
|
||||
setStorage("userId", meData.user.id);
|
||||
setStorage("emailLinked", "1");
|
||||
if (meData.user.email) {
|
||||
setStorage("userEmail", meData.user.email);
|
||||
}
|
||||
savePaidType("vip");
|
||||
saveUserName(meData.user.email || meData.user.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// 仅当有本地存储的 userId 时才校验(无身份/VIP 时跳过,点击按钮直接进入支付流程)
|
||||
if (hadStoredUserId) {
|
||||
const userIdForCheck = getStoredAnonymousUserId() || getStorage("userId");
|
||||
if (userIdForCheck) {
|
||||
const found = await checkPaymentFromPB(userIdForCheck);
|
||||
if (found) {
|
||||
const name = getStorage("userEmail") || getStorage("userName");
|
||||
if (name) setUserName(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, [
|
||||
clearAllStorage,
|
||||
getOrCreateUserId,
|
||||
getStoredAnonymousUserId,
|
||||
checkPaymentFromPB,
|
||||
savePaidType,
|
||||
saveUserName,
|
||||
tryRecoverByAnonymousUserId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isH5) return;
|
||||
|
||||
const syncPendingOrder = () => {
|
||||
const pendingOrderId = getPendingOrderId();
|
||||
if (!pendingOrderId) return;
|
||||
setPayPendingOrderId((current) =>
|
||||
current === pendingOrderId ? current : pendingOrderId
|
||||
);
|
||||
};
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
syncPendingOrder();
|
||||
}
|
||||
};
|
||||
|
||||
syncPendingOrder();
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
window.addEventListener("pageshow", syncPendingOrder);
|
||||
window.addEventListener("focus", syncPendingOrder);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
window.removeEventListener("pageshow", syncPendingOrder);
|
||||
window.removeEventListener("focus", syncPendingOrder);
|
||||
};
|
||||
}, [isH5]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const localType = getStorage("paidType");
|
||||
const localName = getStorage("userEmail") || getStorage("userName");
|
||||
if (localType && localName && !paidType) {
|
||||
setPaidType(localType);
|
||||
setUserName(localName);
|
||||
}
|
||||
};
|
||||
const onVisible = () => syncFromStorage();
|
||||
const onPageShow = (e: PageTransitionEvent) => {
|
||||
if (e.persisted) syncFromStorage();
|
||||
};
|
||||
if (typeof document !== "undefined" && document.addEventListener) {
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
};
|
||||
}
|
||||
}, [paidType]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
const { pbLogout } = await import("@/app/lib/pocketbase");
|
||||
await pbLogout();
|
||||
setUserEmail(null);
|
||||
clearAllStorage();
|
||||
}, [clearAllStorage]);
|
||||
|
||||
const isLoggedIn = paidType === "vip";
|
||||
|
||||
return (
|
||||
<>
|
||||
<VipLayout isLoggedIn={isLoggedIn} userName={userName} userEmail={userEmail} onLogout={handleLogout}>
|
||||
{isLoggedIn ? (
|
||||
<DashboardPage userName={userName} userEmail={userEmail} />
|
||||
) : (
|
||||
<>
|
||||
<LandingPage
|
||||
onJoin={handlePay}
|
||||
onVerify={checkPaymentFromPB}
|
||||
onVerifyByEmail={checkVerifyByEmail}
|
||||
onVerifySuccess={handleVerifySuccess}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</VipLayout>
|
||||
</>
|
||||
);
|
||||
export default function Page() {
|
||||
return <HomeClientLoader />;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
前往数字游民指南了解更多 →
|
||||
了解更多 →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -38,28 +38,32 @@ export function TopicFooter({ topicId }: TopicFooterProps) {
|
||||
模块列表
|
||||
</Link>
|
||||
{paid ? (
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
<span className="hidden">
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
<span className="hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-sm text-[var(--muted-foreground)]">
|
||||
|
||||
@@ -48,28 +48,32 @@ export function TopicHeader({ topicId }: TopicHeaderProps) {
|
||||
首页
|
||||
</Link>
|
||||
{paid ? (
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
<span className="hidden">
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQaModalOpen(true)}
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
<span className="hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQaModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{config?.externalUrl ? (
|
||||
<Link
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { getTopicConfig, getTopicCurriculum } from "../config";
|
||||
import { getLastWatched, getProgress } from "@/app/cloudphone/lib/learning";
|
||||
import { ParticleBackground } from "@/app/cloudphone/components/ParticleBackground";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
import type { TopicPrepVariant } from "./TopicPrepThreeScene";
|
||||
|
||||
const TopicPrepThreeScene = dynamic(
|
||||
() => import("./TopicPrepThreeScene").then((m) => m.TopicPrepThreeScene),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="mx-auto mt-2 min-h-[260px] w-full max-w-2xl animate-pulse rounded-2xl border border-[var(--border)] bg-[var(--muted)]/20"
|
||||
aria-hidden
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
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 (
|
||||
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
|
||||
@@ -74,6 +93,14 @@ export function TopicHero({ topicId }: TopicHeroProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showPrepThree ? (
|
||||
<div
|
||||
className="animate__animated animate__fadeInUp mx-auto mb-10 w-full max-w-2xl sm:mb-12"
|
||||
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
|
||||
>
|
||||
<TopicPrepThreeScene variant={prepVariant} />
|
||||
</div>
|
||||
) : null}
|
||||
{curriculum?.hasContent && (
|
||||
<div
|
||||
className="animate__animated animate__fadeInDown flex flex-wrap justify-center gap-3 sm:gap-4"
|
||||
|
||||
405
app/topic/components/TopicPrepThreeScene.tsx
Normal file
405
app/topic/components/TopicPrepThreeScene.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
export type TopicPrepVariant = "indie" | "aiagent";
|
||||
|
||||
type Props = {
|
||||
variant: TopicPrepVariant;
|
||||
};
|
||||
|
||||
function disposeObject3D(root: THREE.Object3D) {
|
||||
const materials = new Set<THREE.Material>();
|
||||
const geometries = new Set<THREE.BufferGeometry>();
|
||||
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<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
||||
<div ref={containerRef} className="mx-auto mt-2 w-full max-w-2xl">
|
||||
<p className="mb-2 text-center text-xs text-[var(--muted-foreground)]">{hint}</p>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="mx-auto block w-full cursor-grab touch-none rounded-2xl border border-[var(--border)] bg-[var(--muted)]/25 active:cursor-grabbing dark:bg-[var(--muted)]/15"
|
||||
aria-label={label}
|
||||
role="img"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, TopicConfig> = {
|
||||
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<string, TopicConfig> = {
|
||||
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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<UnlockedStats />
|
||||
<TopicEbookShowcase />
|
||||
<TopicEbookShowcase onCommunityCardClick={() => setCommunityModalOpen(true)} />
|
||||
<CommunityAccessModal
|
||||
open={communityModalOpen}
|
||||
onClose={() => setCommunityModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<LandingHero />
|
||||
<AssetStats />
|
||||
<TopicEbookShowcase onLockedClick={() => setLoginModalOpen(true)} />
|
||||
<CompareSection />
|
||||
<CommunityPreview />
|
||||
<FAQSection />
|
||||
<LandingCTA onJoin={onJoin} onLogin={() => setLoginModalOpen(true)} />
|
||||
<div className={styles.landingScene}>
|
||||
<div className={styles.landingSceneBg} aria-hidden>
|
||||
<div className={styles.landingOrb} />
|
||||
<div className={styles.landingOrb} />
|
||||
<div className={styles.landingOrb} />
|
||||
<div className={styles.landingMesh} />
|
||||
<div className={styles.landingNoise} />
|
||||
</div>
|
||||
<div className={styles.landingSceneInner}>
|
||||
<LandingHero />
|
||||
<AssetStats />
|
||||
<TopicEbookShowcase onLockedClick={() => setLoginModalOpen(true)} />
|
||||
<CompareSection />
|
||||
<div className="hidden" aria-hidden>
|
||||
<CommunityPreview />
|
||||
</div>
|
||||
<FAQSection />
|
||||
<LandingCTA onJoin={onJoin} onLogin={() => setLoginModalOpen(true)} />
|
||||
</div>
|
||||
</div>
|
||||
<LoginModal
|
||||
open={loginModalOpen}
|
||||
onClose={() => setLoginModalOpen(false)}
|
||||
|
||||
@@ -43,12 +43,7 @@ export function VipHeader({ isLoggedIn, userName, userEmail, onLogout }: VipHead
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Link
|
||||
href="https://digital.hackrobot.cn/zh"
|
||||
className={styles.logo}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Link href="/" className={styles.logo}>
|
||||
🌍 异度星球
|
||||
</Link>
|
||||
<nav className={styles.nav}>
|
||||
|
||||
133
app/vip/components/dashboard/CommunityAccessModal.tsx
Normal file
133
app/vip/components/dashboard/CommunityAccessModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copyable } from "../Copyable";
|
||||
import styles from "../vip.module.css";
|
||||
|
||||
const COMMUNITY_URL = "https://qun.hackrobot.cn";
|
||||
const DEFAULT_PASSWORD = "12345678";
|
||||
|
||||
function getStorage(key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
function isAnonymousUserId(value: string | null): value is string {
|
||||
return !!value && /^user\d+$/.test(value);
|
||||
}
|
||||
|
||||
type CommunityAccessModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** 与 WelcomeBlock 同步的社群登录账号解析,用于弹窗展示 */
|
||||
export function CommunityAccessModal({ open, onClose }: CommunityAccessModalProps) {
|
||||
const userId = typeof window !== "undefined" ? getStorage("userId") : null;
|
||||
const [backendMemosAccount, setBackendMemosAccount] = useState<string | null>(null);
|
||||
|
||||
const memosAccount =
|
||||
getStorage("memosAccount") ||
|
||||
backendMemosAccount ||
|
||||
(isAnonymousUserId(userId) ? userId : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !userId) return;
|
||||
fetch(`/api/vip/linked-email?user_id=${encodeURIComponent(userId)}`, {
|
||||
cache: "no-store",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const linkedAnonymousUserId =
|
||||
typeof data?.anonymousUserId === "string" && /^user\d+$/.test(data.anonymousUserId)
|
||||
? data.anonymousUserId
|
||||
: null;
|
||||
if (linkedAnonymousUserId && typeof window !== "undefined") {
|
||||
setBackendMemosAccount(linkedAnonymousUserId);
|
||||
localStorage.setItem("memosAccount", linkedAnonymousUserId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [open, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.communityModalBackdrop}
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={styles.communityModalPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="community-modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={styles.communityModalGlow} aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.communityModalClose}
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className={styles.communityModalIconWrap}>
|
||||
<span className={styles.communityModalIcon}>👥</span>
|
||||
</div>
|
||||
<h2 id="community-modal-title" className={styles.communityModalTitle}>
|
||||
私密社群 · 登录信息
|
||||
</h2>
|
||||
<p className={styles.communityModalDesc}>
|
||||
使用以下信息访问社群站点,可随时点击复制
|
||||
</p>
|
||||
|
||||
<div className={styles.communityModalRows}>
|
||||
<div className={styles.communityModalRow}>
|
||||
<span className={styles.communityModalLabel}>网址</span>
|
||||
<div className={styles.communityModalValueWrap}>
|
||||
<Copyable text={COMMUNITY_URL} className={styles.communityModalCopyable}>
|
||||
<span className={styles.communityModalMono}>qun.hackrobot.cn</span>
|
||||
</Copyable>
|
||||
<a
|
||||
href={COMMUNITY_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.communityModalOpenLink}
|
||||
>
|
||||
打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.communityModalRow}>
|
||||
<span className={styles.communityModalLabel}>账号</span>
|
||||
<Copyable text={memosAccount || ""} className={styles.communityModalCopyable}>
|
||||
<span className={styles.communityModalMono}>{memosAccount || "—"}</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
<div className={styles.communityModalRow}>
|
||||
<span className={styles.communityModalLabel}>密码</span>
|
||||
<Copyable text={DEFAULT_PASSWORD} className={styles.communityModalCopyable}>
|
||||
<span className={styles.communityModalMono}>{DEFAULT_PASSWORD}</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className={styles.communityModalDone} onClick={onClose}>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -287,38 +287,12 @@ export function WelcomeBlock({
|
||||
>
|
||||
欢迎回来,<Copyable text={displayName}>{displayName}</Copyable>
|
||||
</h1>
|
||||
<p
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
fontSize: "0.95rem",
|
||||
color: "var(--muted-foreground)",
|
||||
margin: "0.25rem 0 0",
|
||||
animationDelay: "0.15s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
以下是当前会员账号和登录信息
|
||||
</p>
|
||||
<div className={styles.loginCard}>
|
||||
<div className={styles.loginCardTitle}>会员登录信息</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>网址</span>
|
||||
<Copyable text="https://qun.hackrobot.cn">
|
||||
qun.hackrobot.cn
|
||||
</Copyable>
|
||||
<a
|
||||
href="https://qun.hackrobot.cn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.loginCardLink}
|
||||
>
|
||||
打开
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>账号</span>
|
||||
<Copyable text={memosAccount || ""}>
|
||||
<span className={styles.loginCardValue}>{memosAccount || "-"}</span>
|
||||
<span className={styles.loginCardLabel}>邮箱</span>
|
||||
<Copyable text={displayEmail || ""}>
|
||||
<span className={styles.loginCardValue}>{displayEmail || "-"}</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
@@ -327,12 +301,6 @@ export function WelcomeBlock({
|
||||
<span className={styles.loginCardValue}>{DEFAULT_PASSWORD}</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
<div className={styles.loginCardRow}>
|
||||
<span className={styles.loginCardLabel}>邮箱</span>
|
||||
<Copyable text={displayEmail || ""}>
|
||||
<span className={styles.loginCardValue}>{displayEmail || "-"}</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function AssetStats() {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={styles.card}
|
||||
className={`${styles.card} ${styles.landingStatCard}`}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
animationDelay: `${0.25 + i * 0.05}s`,
|
||||
|
||||
@@ -4,54 +4,75 @@ import { useState } from "react";
|
||||
import { FAQ_ITEMS } from "@/app/data/vip.mock";
|
||||
import styles from "../vip.module.css";
|
||||
|
||||
function ChevronDown() {
|
||||
return (
|
||||
<svg className={styles.faqChevron} width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FAQSection() {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>常见问题</h2>
|
||||
<div
|
||||
className="animate__animated animate__fadeInUp"
|
||||
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
|
||||
>
|
||||
{FAQ_ITEMS.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.card}
|
||||
style={{
|
||||
marginBottom: "0.75rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontWeight: 500,
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{item.q}
|
||||
<span style={{ fontSize: "1.2rem", transition: "transform 0.2s", transform: openIndex === i ? "rotate(180deg)" : "none" }}>
|
||||
▼
|
||||
</span>
|
||||
</div>
|
||||
{openIndex === i && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "var(--muted-foreground)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
<section className={`${styles.section} ${styles.faqSection}`} aria-labelledby="faq-heading">
|
||||
<div className={`${styles.faqInner} animate__animated animate__fadeInUp`} style={{ animationDelay: "0.15s", animationFillMode: "both" }}>
|
||||
<p className={styles.faqEyebrow}>
|
||||
<span className={styles.faqEyebrowDot} aria-hidden />
|
||||
FAQ
|
||||
</p>
|
||||
<h2 id="faq-heading" className={styles.faqHeadline}>
|
||||
常见问题
|
||||
</h2>
|
||||
<p className={styles.faqSub}>
|
||||
购买前可快速了解会员权益与流程;点击问题展开详情。
|
||||
</p>
|
||||
<div className={styles.faqList} role="list">
|
||||
{FAQ_ITEMS.map((item, i) => {
|
||||
const open = openIndex === i;
|
||||
const panelId = `faq-panel-${i}`;
|
||||
const triggerId = `faq-trigger-${i}`;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.faqItem} ${open ? styles.faqItemOpen : ""}`}
|
||||
role="listitem"
|
||||
>
|
||||
{item.a}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
id={triggerId}
|
||||
className={styles.faqTrigger}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpenIndex(open ? null : i)}
|
||||
>
|
||||
<span className={styles.faqIndex} aria-hidden>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className={styles.faqQuestionText}>{item.q}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div
|
||||
id={panelId}
|
||||
role="region"
|
||||
aria-labelledby={triggerId}
|
||||
className={`${styles.faqPanel} ${open ? styles.faqPanelOpen : ""}`}
|
||||
>
|
||||
<div className={styles.faqPanelInner}>
|
||||
<p className={styles.faqAnswer}>{item.a}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,9 @@ type LandingCTAProps = {
|
||||
onLogin: () => void;
|
||||
};
|
||||
|
||||
/** 设为 true 可重新展示「适合你 / 请止步」区块(下方 JSX 与 vip.module.css 内样式均保留) */
|
||||
const SHOW_CTA_AUDIENCE_BOUNDARY = false;
|
||||
|
||||
export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
|
||||
const [restoreDate, setRestoreDate] = useState<string>("");
|
||||
useEffect(() => {
|
||||
@@ -16,50 +19,68 @@ export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className={styles.section} style={{ paddingBottom: "2rem" }}>
|
||||
<div
|
||||
className={`${styles.card} animate__animated animate__zoomIn`}
|
||||
style={{
|
||||
maxWidth: "560px",
|
||||
margin: "0 auto",
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
animationDelay: "0.2s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: "1.35rem", margin: "0 0 0.5rem", color: "var(--foreground)" }}>
|
||||
限时特惠 · {restoreDate || "—"}恢复原价
|
||||
</h2>
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--muted-foreground)", margin: "0 0 1rem" }}>
|
||||
阶梯定价 · 数字交付 · 不支持退款
|
||||
</p>
|
||||
<p style={{ fontSize: "0.8rem", color: "var(--muted-foreground)", marginBottom: "1.5rem" }}>
|
||||
🚫 不适合躺赚、现金流紧张
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "1rem",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onJoin}
|
||||
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
||||
<section className={`${styles.section} ${styles.ctaSection}`} aria-labelledby="cta-heading">
|
||||
<div className={`${styles.ctaInner} animate__animated animate__zoomIn`} style={{ animationDelay: "0.15s", animationFillMode: "both" }}>
|
||||
<div className={styles.ctaGlow} aria-hidden />
|
||||
<div className={styles.ctaCard}>
|
||||
<h2 id="cta-heading" className={styles.ctaPromoLine}>
|
||||
<span className={styles.ctaBadgeInline}>
|
||||
<span className={styles.ctaBadgePulse} aria-hidden />
|
||||
限时特惠
|
||||
</span>
|
||||
<span className={styles.ctaPromoDivider} aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className={styles.ctaPromoMain}>
|
||||
<span className={styles.ctaHighlight}>{restoreDate || "—"}</span>
|
||||
<span className={styles.ctaPromoSuffix}>恢复原价</span>
|
||||
</span>
|
||||
</h2>
|
||||
<div className={styles.ctaChips}>
|
||||
<span className={styles.ctaChip}>阶梯定价</span>
|
||||
<span className={styles.ctaChip}>数字交付</span>
|
||||
<span className={styles.ctaChip}>持续更新</span>
|
||||
<span className={styles.ctaChip}>不支持退款</span>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.ctaBoundary} ${SHOW_CTA_AUDIENCE_BOUNDARY ? "" : styles.ctaBoundaryHidden}`}
|
||||
role="note"
|
||||
aria-hidden={!SHOW_CTA_AUDIENCE_BOUNDARY}
|
||||
>
|
||||
🔒 立即加入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogin}
|
||||
className={styles.btnSecondary}
|
||||
>
|
||||
已有账号登录
|
||||
</button>
|
||||
<div className={styles.ctaBoundarySheen} aria-hidden />
|
||||
<div className={styles.ctaBoundaryInner}>
|
||||
<p className={styles.ctaBoundaryPos}>
|
||||
<span className={styles.ctaBoundaryMark} aria-hidden>
|
||||
✦
|
||||
</span>
|
||||
<span>
|
||||
<strong className={styles.ctaBoundaryStrong}>适合你:</strong>
|
||||
愿意投入行动、认真做增长与出海的同行者
|
||||
</span>
|
||||
</p>
|
||||
<p className={styles.ctaBoundaryNeg}>
|
||||
<span className={styles.ctaBoundaryMarkNeg} aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span>
|
||||
<strong className={styles.ctaBoundaryStrongNeg}>请止步:</strong>
|
||||
指望躺赚,或当下现金流极度紧张的朋友
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.ctaActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onJoin}
|
||||
className={`${styles.btnPrimary} ${styles.btnLarge} ${styles.ctaBtnPrimary}`}
|
||||
>
|
||||
🔒 立即加入
|
||||
</button>
|
||||
<button type="button" onClick={onLogin} className={styles.ctaBtnSecondary}>
|
||||
已有账号登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,50 +4,23 @@ import styles from "../vip.module.css";
|
||||
|
||||
export function LandingHero() {
|
||||
return (
|
||||
<section className={`${styles.section} ${styles.heroSection}`} style={{ textAlign: "center" }}>
|
||||
<section className={`${styles.section} ${styles.heroSection} ${styles.heroSection3d}`} style={{ textAlign: "center" }}>
|
||||
<p
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--muted-foreground)",
|
||||
background: "var(--card)",
|
||||
padding: "0.35rem 0.85rem",
|
||||
borderRadius: "9999px",
|
||||
border: "1px solid var(--border)",
|
||||
marginBottom: "1rem",
|
||||
animationDelay: "0.1s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
className={`${styles.heroEyebrow} animate__animated animate__fadeInDown`}
|
||||
style={{ animationDelay: "0.1s", animationFillMode: "both" }}
|
||||
>
|
||||
异度星球会员中心
|
||||
</p>
|
||||
<h1
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
fontSize: "clamp(2rem, 6vw, 3rem)",
|
||||
fontWeight: 700,
|
||||
margin: "0 0 0.5rem",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.2,
|
||||
animationDelay: "0.2s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
className={`${styles.heroTitle} animate__animated animate__fadeInDown`}
|
||||
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
|
||||
>
|
||||
全模块解锁 · 持续更新 · 抱团出海
|
||||
<span className={styles.heroTitleAccent}>全模块解锁</span>
|
||||
<span className={styles.heroTitleRest}> · 持续更新 · 抱团出海</span>
|
||||
</h1>
|
||||
<p
|
||||
className="animate__animated animate__fadeInDown"
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
color: "var(--muted-foreground)",
|
||||
maxWidth: "540px",
|
||||
margin: "0 auto",
|
||||
lineHeight: 1.6,
|
||||
textAlign: "center",
|
||||
animationDelay: "0.3s",
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
className={`${styles.heroLead} animate__animated animate__fadeInDown`}
|
||||
style={{ animationDelay: "0.3s", animationFillMode: "both" }}
|
||||
>
|
||||
一次付费,解锁电子书、视频教程、私密社群、即时问答,每周更新
|
||||
</p>
|
||||
|
||||
@@ -12,6 +12,8 @@ type LoginFormProps = {
|
||||
) => Promise<boolean | { ok: boolean; error?: string }>;
|
||||
onSuccess: () => void;
|
||||
autoFocus?: boolean;
|
||||
/** 验证请求进行中(用于全屏 loading 等) */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
};
|
||||
|
||||
export function LoginForm({
|
||||
@@ -19,6 +21,7 @@ export function LoginForm({
|
||||
onVerifyByEmail,
|
||||
onSuccess,
|
||||
autoFocus,
|
||||
onBusyChange,
|
||||
}: LoginFormProps) {
|
||||
const [mode, setMode] = useState<"user_id" | "email">("user_id");
|
||||
const [userId, setUserId] = useState("");
|
||||
@@ -41,6 +44,10 @@ export function LoginForm({
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
}, [autoFocus, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange?.(loading);
|
||||
}, [loading, onBusyChange]);
|
||||
|
||||
const handleUserIdSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = userId.trim();
|
||||
@@ -51,14 +58,19 @@ export function LoginForm({
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
let succeeded = false;
|
||||
try {
|
||||
const ok = await onVerify(trimmed);
|
||||
if (ok) onSuccess();
|
||||
else setError("未找到该账号的会员记录,请确认账号正确");
|
||||
if (ok) {
|
||||
succeeded = true;
|
||||
onSuccess();
|
||||
} else {
|
||||
setError("未找到该账号的会员记录,请确认账号正确");
|
||||
}
|
||||
} catch {
|
||||
setError("验证失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!succeeded) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,6 +91,7 @@ export function LoginForm({
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
let succeeded = false;
|
||||
try {
|
||||
const candidateUserId = getCandidateUserId();
|
||||
const result = await onVerifyByEmail(
|
||||
@@ -88,12 +101,16 @@ export function LoginForm({
|
||||
);
|
||||
const ok = typeof result === "object" ? result.ok : result;
|
||||
const errMsg = typeof result === "object" ? result.error : undefined;
|
||||
if (ok) onSuccess();
|
||||
else setError(errMsg || "邮箱或密码错误,或未找到关联的会员记录");
|
||||
if (ok) {
|
||||
succeeded = true;
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(errMsg || "邮箱或密码错误,或未找到关联的会员记录");
|
||||
}
|
||||
} catch {
|
||||
setError("验证失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!succeeded) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HomeBootOverlay } from "@/app/components/HomeBootOverlay";
|
||||
import styles from "../vip.module.css";
|
||||
import { LoginForm } from "./LoginForm";
|
||||
|
||||
@@ -17,11 +18,14 @@ type LoginModalProps = {
|
||||
};
|
||||
|
||||
export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess }: LoginModalProps) {
|
||||
const [verifyOverlay, setVerifyOverlay] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
setVerifyOverlay(false);
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
@@ -31,56 +35,63 @@ export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape" && !verifyOverlay) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, onClose]);
|
||||
}, [open, onClose, verifyOverlay]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
onClose();
|
||||
// 勿先 onClose:父级会把 open 设为 false,弹窗卸载会清掉全屏 loading,应在 reload 前保持验证中态
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="login-modal-title"
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl animate__animated animate__fadeIn animate__faster"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="login-modal-title"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
|
||||
登录
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<LoginForm
|
||||
onVerify={onVerify}
|
||||
onVerifyByEmail={onVerifyByEmail}
|
||||
onSuccess={handleSuccess}
|
||||
autoFocus
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={verifyOverlay ? undefined : onClose}
|
||||
/>
|
||||
<div
|
||||
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl animate__animated animate__fadeIn animate__faster"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
|
||||
登录
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={verifyOverlay}
|
||||
className="rounded-lg p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)] disabled:pointer-events-none disabled:opacity-40"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<LoginForm
|
||||
onVerify={onVerify}
|
||||
onVerifyByEmail={onVerifyByEmail}
|
||||
onSuccess={handleSuccess}
|
||||
onBusyChange={setVerifyOverlay}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{verifyOverlay ? (
|
||||
<HomeBootOverlay visible subtitle="正在验证并解锁会员…" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import { NetdiskDownloadsSection } from "@/app/components/NetdiskDownloadsSectio
|
||||
|
||||
type TopicEbookShowcaseProps = {
|
||||
onLockedClick?: () => void;
|
||||
/** VIP 首页:点击「私密社群」打开社群登录信息弹窗 */
|
||||
onCommunityCardClick?: () => void;
|
||||
};
|
||||
|
||||
export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
|
||||
export function TopicEbookShowcase({
|
||||
onLockedClick,
|
||||
onCommunityCardClick,
|
||||
}: TopicEbookShowcaseProps) {
|
||||
return (
|
||||
<section id="topics" className={`${styles.section} ${styles.topicsSection}`}>
|
||||
{/* 模块展示 */}
|
||||
@@ -21,6 +26,7 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
|
||||
{TOPICS_DISPLAY.map((t) => {
|
||||
const { id, href } = t;
|
||||
const isLocked = "locked" in t && t.locked && onLockedClick;
|
||||
const isCommunityModal = id === "community" && onCommunityCardClick;
|
||||
const content = (
|
||||
<span className={styles.topicCardInner}>
|
||||
{isLocked && (
|
||||
@@ -44,6 +50,19 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (isCommunityModal) {
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={styles.topicCard}
|
||||
onClick={onCommunityCardClick}
|
||||
style={{ cursor: "pointer", border: "none", font: "inherit" }}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -118,6 +118,233 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 首页 · 沉浸式背景(透视网格 / 光晕 · 轻 3D 感,无 WebGL 依赖) ========== */
|
||||
.landingScene {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
width: calc(100% + 3rem);
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.landingScene {
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.landingSceneBg {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: min(118vh, 1400px);
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.landingSceneInner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.landingOrb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(52px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.landingOrb:nth-child(1) {
|
||||
width: min(42vw, 380px);
|
||||
height: min(42vw, 380px);
|
||||
top: -4%;
|
||||
left: -10%;
|
||||
background: radial-gradient(circle, rgba(245, 158, 11, 0.38) 0%, transparent 68%);
|
||||
animation: landingOrbA 22s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landingOrb:nth-child(2) {
|
||||
width: min(48vw, 420px);
|
||||
height: min(48vw, 420px);
|
||||
top: 10%;
|
||||
right: -16%;
|
||||
background: radial-gradient(circle, rgba(245, 158, 11, 0.14) 0%, transparent 72%);
|
||||
animation: landingOrbB 26s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.landingOrb:nth-child(3) {
|
||||
width: min(34vw, 300px);
|
||||
height: min(34vw, 300px);
|
||||
top: 36%;
|
||||
left: 14%;
|
||||
background: radial-gradient(circle, rgba(251, 191, 36, 0.22) 0%, transparent 72%);
|
||||
animation: landingOrbC 19s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.dark .landingOrb:nth-child(2) {
|
||||
background: radial-gradient(circle, rgba(129, 140, 248, 0.22) 0%, transparent 72%);
|
||||
}
|
||||
|
||||
@keyframes landingOrbA {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(5%, 5%) scale(1.06); }
|
||||
}
|
||||
|
||||
@keyframes landingOrbB {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-4%, 6%) scale(1.04); }
|
||||
}
|
||||
|
||||
@keyframes landingOrbC {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(4%, -3%); }
|
||||
}
|
||||
|
||||
.landingMesh {
|
||||
position: absolute;
|
||||
left: -20%;
|
||||
width: 140%;
|
||||
top: 0;
|
||||
height: min(64vh, 580px);
|
||||
pointer-events: none;
|
||||
perspective: 520px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.landingMesh::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(245, 158, 11, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(245, 158, 11, 0.1) 1px, transparent 1px);
|
||||
background-size: 52px 52px;
|
||||
transform-origin: 50% 0%;
|
||||
transform: rotateX(74deg) translateY(-5%) scale(1.06);
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.62) 0%, transparent 92%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.62) 0%, transparent 92%);
|
||||
animation: landingMeshScroll 28s linear infinite;
|
||||
}
|
||||
|
||||
.dark .landingMesh::before {
|
||||
background-image:
|
||||
linear-gradient(rgba(245, 158, 11, 0.15) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(245, 158, 11, 0.15) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
@keyframes landingMeshScroll {
|
||||
from {
|
||||
background-position: 0 0, 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0 52px, 52px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.landingNoise {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.dark .landingNoise {
|
||||
opacity: 0.08;
|
||||
mix-blend-mode: soft-light;
|
||||
}
|
||||
|
||||
.heroSection3d {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heroEyebrow {
|
||||
display: inline-block;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.4rem 0.95rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.22);
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(12px) saturate(1.2);
|
||||
box-shadow:
|
||||
0 10px 36px rgba(0, 0, 0, 0.07),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.55) inset;
|
||||
}
|
||||
|
||||
.dark .heroEyebrow {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.heroTitleAccent {
|
||||
background: linear-gradient(120deg, var(--foreground) 0%, var(--accent) 42%, #fbbf24 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.heroTitleRest {
|
||||
color: var(--foreground);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.heroLead {
|
||||
font-size: clamp(1rem, 2.8vw, 1.125rem);
|
||||
color: var(--muted-foreground);
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.65;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landingStatCard {
|
||||
position: relative;
|
||||
backdrop-filter: blur(12px) saturate(1.15);
|
||||
background: rgba(255, 255, 255, 0.52);
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
transition: transform 0.35s ease, box-shadow 0.35s ease, border-color 0.2s;
|
||||
}
|
||||
|
||||
.dark .landingStatCard {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(245, 158, 11, 0.24);
|
||||
}
|
||||
|
||||
.landingStatCard:hover {
|
||||
transform: translateY(-3px) perspective(880px) rotateX(5deg);
|
||||
box-shadow:
|
||||
0 18px 44px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 1px rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
.dark .landingStatCard:hover {
|
||||
box-shadow:
|
||||
0 22px 52px rgba(0, 0, 0, 0.48),
|
||||
0 0 0 1px rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
|
||||
.statsSection {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
@@ -426,3 +653,731 @@
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 私密社群 · 登录信息弹窗 */
|
||||
.communityModalBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.communityModalPanel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
background: var(--card);
|
||||
box-shadow:
|
||||
0 24px 48px rgba(0, 0, 0, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04) inset;
|
||||
}
|
||||
|
||||
.communityModalGlow {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 16px;
|
||||
background: radial-gradient(
|
||||
ellipse 80% 60% at 50% -20%,
|
||||
rgba(245, 158, 11, 0.18),
|
||||
transparent 55%
|
||||
);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.communityModalClose {
|
||||
position: absolute;
|
||||
top: 0.65rem;
|
||||
right: 0.65rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.communityModalClose:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.communityModalIconWrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.communityModalIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
border-radius: 14px;
|
||||
font-size: 1.75rem;
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(245, 158, 11, 0.06));
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.communityModalTitle {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--foreground);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.communityModalDesc {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.communityModalRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.communityModalRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.communityModalLabel {
|
||||
flex-shrink: 0;
|
||||
width: 2.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.communityModalValueWrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.communityModalCopyable {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
|
||||
.communityModalMono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
text-align: right;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.communityModalOpenLink {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.communityModalOpenLink:hover {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.communityModalDone {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--accent-foreground);
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.35);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.communityModalDone:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
/* ========== 首页 · 常见问题 ========== */
|
||||
.faqSection {
|
||||
padding: 2rem 0 2.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.faqSection {
|
||||
padding: 2.5rem 0 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.faqInner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faqEyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.faqEyebrowDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.faqHeadline {
|
||||
font-size: clamp(1.35rem, 3.5vw, 1.75rem);
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
margin: 0 0 0.35rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.faqSub {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 1.5rem;
|
||||
line-height: 1.55;
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.faqList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.faqItem {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.faqItem:hover {
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
|
||||
.faqItemOpen {
|
||||
border-color: rgba(245, 158, 11, 0.45);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.dark .faqItemOpen {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.faqTrigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--foreground);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.faqTrigger:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.faqTrigger:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.faqIndex {
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.faqQuestionText {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.faqChevron {
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--muted-foreground);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.faqItemOpen .faqChevron {
|
||||
transform: rotate(180deg);
|
||||
color: var(--accent);
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.faqPanel {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.faqPanelOpen {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.faqPanelInner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.faqAnswer {
|
||||
padding: 0 1.1rem 1.1rem 3.65rem;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.65;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.faqAnswer {
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 首页 · 立即加入 CTA ========== */
|
||||
.ctaSection {
|
||||
padding: 0 0 2.75rem;
|
||||
}
|
||||
|
||||
.ctaInner {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ctaGlow {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: -20% -10% auto;
|
||||
height: 70%;
|
||||
background: radial-gradient(
|
||||
ellipse 70% 80% at 50% 0%,
|
||||
rgba(245, 158, 11, 0.22),
|
||||
transparent 65%
|
||||
);
|
||||
opacity: 0.85;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.ctaCard {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
padding: 1.75rem 1.5rem 1.65rem;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(16px) saturate(1.25);
|
||||
border: 1px solid rgba(245, 158, 11, 0.24);
|
||||
box-shadow:
|
||||
0 20px 50px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.45) inset;
|
||||
}
|
||||
|
||||
.dark .ctaCard {
|
||||
background: rgba(20, 20, 26, 0.78);
|
||||
box-shadow:
|
||||
0 24px 56px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06) inset;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.ctaCard {
|
||||
padding: 2rem 2.25rem 1.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 与主标题同一行的限时标签 */
|
||||
.ctaPromoLine {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: clamp(0.8125rem, 3.4vw, 1.125rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ctaBadgeInline {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.28rem 0.65rem;
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.ctaPromoDivider {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.55;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ctaPromoMain {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25em;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctaPromoSuffix {
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ctaBadgePulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: ctaPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaPulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.65; transform: scale(0.92); }
|
||||
}
|
||||
|
||||
.ctaHighlight {
|
||||
background: linear-gradient(120deg, var(--accent) 0%, #fbbf24 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.ctaChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.85rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.ctaChip {
|
||||
font-size: clamp(0.5625rem, 2.1vw, 0.6875rem);
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
/* 与 SHOW_CTA_AUDIENCE_BOUNDARY 配合:隐藏时不删 DOM,仅不占布局 */
|
||||
.ctaBoundaryHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctaBoundary {
|
||||
position: relative;
|
||||
margin: 0 auto 1.4rem;
|
||||
max-width: 26rem;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(245, 158, 11, 0.22);
|
||||
background:
|
||||
linear-gradient(125deg, rgba(245, 158, 11, 0.1) 0%, transparent 45%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.75) 0%, rgba(248, 248, 250, 0.94) 100%);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0, 0, 0, 0.06),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.5) inset;
|
||||
}
|
||||
|
||||
.dark .ctaBoundary {
|
||||
background:
|
||||
linear-gradient(125deg, rgba(245, 158, 11, 0.14) 0%, transparent 48%),
|
||||
linear-gradient(180deg, rgba(32, 32, 40, 0.92) 0%, rgba(16, 16, 20, 0.96) 100%);
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
box-shadow:
|
||||
0 16px 48px rgba(0, 0, 0, 0.38),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
}
|
||||
|
||||
.ctaBoundarySheen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -50%;
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.28) 48%,
|
||||
transparent 72%
|
||||
);
|
||||
animation: ctaBoundaryShine 8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark .ctaBoundarySheen {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(251, 191, 36, 0.1) 48%,
|
||||
transparent 72%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes ctaBoundaryShine {
|
||||
0%, 100% {
|
||||
transform: translateX(-20%);
|
||||
opacity: 0;
|
||||
}
|
||||
40% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
55% {
|
||||
transform: translateX(380%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ctaBoundaryInner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0.85rem 1rem 0.95rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.ctaBoundaryPos {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: clamp(0.75rem, 2.4vw, 0.875rem);
|
||||
line-height: 1.52;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.ctaBoundaryNeg {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: clamp(0.7rem, 2.15vw, 0.8125rem);
|
||||
line-height: 1.52;
|
||||
color: var(--muted-foreground);
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed rgba(245, 158, 11, 0.28);
|
||||
}
|
||||
|
||||
.ctaBoundaryMark {
|
||||
flex-shrink: 0;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||
}
|
||||
|
||||
.ctaBoundaryMarkNeg {
|
||||
flex-shrink: 0;
|
||||
width: 1.4rem;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4rem;
|
||||
color: rgba(220, 80, 80, 0.88);
|
||||
}
|
||||
|
||||
.dark .ctaBoundaryMarkNeg {
|
||||
color: rgba(252, 165, 165, 0.88);
|
||||
}
|
||||
|
||||
.ctaBoundaryStrong {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ctaBoundaryStrongNeg {
|
||||
font-weight: 600;
|
||||
color: rgba(220, 80, 80, 0.95);
|
||||
}
|
||||
|
||||
.dark .ctaBoundaryStrongNeg {
|
||||
color: rgba(254, 202, 202, 0.92);
|
||||
}
|
||||
|
||||
.ctaActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.ctaActions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ctaBtnPrimary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 280px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.35);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.ctaBtnPrimary {
|
||||
margin: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ctaBtnPrimary::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.18) 50%,
|
||||
transparent 60%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
|
||||
.ctaBtnPrimary:hover::after {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.ctaBtnSecondary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 280px;
|
||||
margin: 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 1.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.ctaBtnSecondary {
|
||||
margin: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ctaBtnSecondary:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user