s'
This commit is contained in:
10
app/api/auth/logout/route.ts
Normal file
10
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "pb_session";
|
||||||
|
const COOKIE_DOMAIN = process.env.AUTH_COOKIE_DOMAIN || ".hackrobot.cn";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set(COOKIE_NAME, "", { domain: COOKIE_DOMAIN, path: "/", maxAge: 0 });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
60
app/api/auth/me/route.ts
Normal file
60
app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "pb_session";
|
||||||
|
const COOKIE_DOMAIN = process.env.AUTH_COOKIE_DOMAIN || ".hackrobot.cn";
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
if (!cookie) {
|
||||||
|
return NextResponse.json({ ok: true, user: null });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
|
||||||
|
const { token, userId, email } = payload;
|
||||||
|
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
|
||||||
|
|
||||||
|
const pb = getPocketBaseConfig();
|
||||||
|
const res = await fetch(`${pb.url}/api/users/refresh`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const r = NextResponse.json({ ok: true, user: null });
|
||||||
|
r.cookies.set(COOKIE_NAME, "", { domain: COOKIE_DOMAIN, path: "/", maxAge: 0 });
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const newToken = data.token;
|
||||||
|
const newRecord = data.record;
|
||||||
|
if (newToken && newRecord?.id) {
|
||||||
|
const newPayload = JSON.stringify({
|
||||||
|
token: newToken,
|
||||||
|
userId: newRecord.id,
|
||||||
|
email: newRecord.email ?? email,
|
||||||
|
});
|
||||||
|
const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
|
||||||
|
const r = NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: newRecord.id, email: newRecord.email ?? email },
|
||||||
|
token: newToken,
|
||||||
|
});
|
||||||
|
r.cookies.set(COOKIE_NAME, encoded, {
|
||||||
|
domain: COOKIE_DOMAIN,
|
||||||
|
path: "/",
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
httpOnly: true,
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: true, user: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/auth/sync-session/route.ts
Normal file
32
app/api/auth/sync-session/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "pb_session";
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
|
||||||
|
const COOKIE_DOMAIN = process.env.AUTH_COOKIE_DOMAIN || ".hackrobot.cn";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const token = body?.token;
|
||||||
|
const record = body?.record;
|
||||||
|
if (!token || !record?.id) {
|
||||||
|
return NextResponse.json({ ok: false, error: "缺少 token 或 record" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
token,
|
||||||
|
userId: record.id,
|
||||||
|
email: record.email ?? "",
|
||||||
|
});
|
||||||
|
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
|
||||||
|
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set(COOKIE_NAME, encoded, {
|
||||||
|
domain: COOKIE_DOMAIN,
|
||||||
|
path: "/",
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
httpOnly: true,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const return_url = String(body?.return_url || "").trim();
|
const return_url = String(body?.return_url || "").trim();
|
||||||
const total_fee = Number(body?.total_fee) || payConfig.defaultAmount || 8800;
|
const total_fee = Number(body?.total_fee) || payConfig.defaultAmount || 100;
|
||||||
const type = String(body?.type || "vip");
|
const type = String(body?.type || "vip");
|
||||||
const channel = String(body?.channel || "wxpay");
|
const channel = String(body?.channel || "wxpay");
|
||||||
const device = String(body?.device || "pc");
|
const device = String(body?.device || "pc");
|
||||||
|
|||||||
55
app/api/vip/check/route.ts
Normal file
55
app/api/vip/check/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "pb_session";
|
||||||
|
|
||||||
|
async function getAdminToken(): Promise<string | null> {
|
||||||
|
const email = process.env.POCKETBASE_EMAIL;
|
||||||
|
const password = process.env.POCKETBASE_PASSWORD;
|
||||||
|
if (!email || !password) return null;
|
||||||
|
const pb = getPocketBaseConfig();
|
||||||
|
const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ identity: email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
if (!cookie) {
|
||||||
|
return NextResponse.json({ ok: true, vip: false });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
|
||||||
|
const userId = payload?.userId;
|
||||||
|
if (!userId) return NextResponse.json({ ok: true, vip: false });
|
||||||
|
|
||||||
|
const token = await getAdminToken();
|
||||||
|
if (!token) return NextResponse.json({ ok: true, vip: false });
|
||||||
|
|
||||||
|
const pb = getPocketBaseConfig();
|
||||||
|
const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`;
|
||||||
|
const res = await fetch(
|
||||||
|
`${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return NextResponse.json({ ok: true, vip: false });
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const record = items[0];
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const isExpired = record?.expires_at && record.expires_at < now;
|
||||||
|
const vip = !!record && !isExpired;
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
vip,
|
||||||
|
expires_at: record?.expires_at ?? null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: true, vip: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/lib/pocketbase/auth.ts
Normal file
93
app/lib/pocketbase/auth.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* PocketBase 认证服务 - 邮箱登录、注册
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
import { PB_STORAGE_KEYS } from "./constants";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
token: string;
|
||||||
|
record: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPBUrl(): string {
|
||||||
|
return getPocketBaseConfig().url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pbLogin(
|
||||||
|
identity: string,
|
||||||
|
password: string
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const url = getPBUrl();
|
||||||
|
const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ identity, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err?.message || "登录失败");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data?.token) throw new Error("登录响应异常");
|
||||||
|
return { token: data.token, record: data.record };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pbRegister(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
passwordConfirm: string
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const url = getPBUrl();
|
||||||
|
const res = await fetch(`${url}/api/collections/users/records`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, passwordConfirm }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err?.message || "注册失败");
|
||||||
|
}
|
||||||
|
return pbLogin(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pbSaveAuth(token: string, record: AuthUser): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(PB_STORAGE_KEYS.token, token);
|
||||||
|
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登出 - 清除本地存储和根域 Cookie */
|
||||||
|
export async function pbLogout(): Promise<void> {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.removeItem(PB_STORAGE_KEYS.token);
|
||||||
|
localStorage.removeItem(PB_STORAGE_KEYS.user);
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredUserEmail(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
|
||||||
|
if (!raw) return null;
|
||||||
|
const user = JSON.parse(raw);
|
||||||
|
return user?.email ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(PB_STORAGE_KEYS.token);
|
||||||
|
}
|
||||||
1
app/lib/pocketbase/config.ts
Normal file
1
app/lib/pocketbase/config.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { getPocketBaseConfig } from "@/config/services.config";
|
||||||
5
app/lib/pocketbase/constants.ts
Normal file
5
app/lib/pocketbase/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** localStorage 存储 key */
|
||||||
|
export const PB_STORAGE_KEYS = {
|
||||||
|
token: "pb_token",
|
||||||
|
user: "pb_user",
|
||||||
|
} as const;
|
||||||
11
app/lib/pocketbase/index.ts
Normal file
11
app/lib/pocketbase/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
pbLogin,
|
||||||
|
pbRegister,
|
||||||
|
pbSaveAuth,
|
||||||
|
pbLogout,
|
||||||
|
getStoredUserEmail,
|
||||||
|
getStoredToken,
|
||||||
|
} from "./auth";
|
||||||
|
export type { AuthUser, AuthResult } from "./auth";
|
||||||
|
export { PB_STORAGE_KEYS } from "./constants";
|
||||||
|
export { getPocketBaseConfig } from "./config";
|
||||||
127
app/page.tsx
127
app/page.tsx
@@ -8,6 +8,7 @@ import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
|||||||
import { VipLayout } from "@/app/vip/components/VipLayout";
|
import { VipLayout } from "@/app/vip/components/VipLayout";
|
||||||
import { LandingPage } from "@/app/vip/components/LandingPage";
|
import { LandingPage } from "@/app/vip/components/LandingPage";
|
||||||
import { DashboardPage } from "@/app/vip/components/DashboardPage";
|
import { DashboardPage } from "@/app/vip/components/DashboardPage";
|
||||||
|
import { PayAuthModal } from "@/app/vip/components/landing/PayAuthModal";
|
||||||
import styles from "@/app/vip/components/vip.module.css";
|
import styles from "@/app/vip/components/vip.module.css";
|
||||||
|
|
||||||
const PB_URL =
|
const PB_URL =
|
||||||
@@ -168,7 +169,19 @@ export default function Home() {
|
|||||||
setUserName(name);
|
setUserName(name);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getOrCreateUserId = useCallback(() => {
|
const getOrCreateUserId = useCallback(async () => {
|
||||||
|
// 优先使用 PocketBase 用户 ID(已登录时)
|
||||||
|
try {
|
||||||
|
const meRes = await fetch("/api/auth/me");
|
||||||
|
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");
|
let userId = getStorage("userId");
|
||||||
if (userId?.startsWith("{") && userId.includes("data")) {
|
if (userId?.startsWith("{") && userId.includes("data")) {
|
||||||
try {
|
try {
|
||||||
@@ -215,6 +228,37 @@ export default function Home() {
|
|||||||
[savePaidType, saveUserName]
|
[savePaidType, saveUserName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 会员账号恢复:通过邮箱+密码验证,关联 users 后检查 site_vip / payments */
|
||||||
|
const checkVerifyByEmail = useCallback(
|
||||||
|
async (email: string, password: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const { pbLogin, pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||||
|
const result = await pbLogin(email, password);
|
||||||
|
pbSaveAuth(result.token, result.record);
|
||||||
|
await fetch("/api/auth/sync-session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: result.token, record: result.record }),
|
||||||
|
});
|
||||||
|
const userId = result.record.id;
|
||||||
|
const vipRes = await fetch("/api/vip/check");
|
||||||
|
const vipData = await vipRes.json();
|
||||||
|
if (vipData?.vip) {
|
||||||
|
setStorage("userId", userId);
|
||||||
|
savePaidType("vip");
|
||||||
|
saveUserName(email);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const found = await checkPaymentFromPB(userId);
|
||||||
|
if (found) return true;
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[checkPaymentFromPB, savePaidType, saveUserName]
|
||||||
|
);
|
||||||
|
|
||||||
const clearAllStorage = useCallback(() => {
|
const clearAllStorage = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -236,8 +280,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePay = useCallback(async () => {
|
const [authForPayOpen, setAuthForPayOpen] = useState(false);
|
||||||
const userId = getOrCreateUserId();
|
|
||||||
|
const doPay = useCallback(async () => {
|
||||||
|
const userId = await getOrCreateUserId();
|
||||||
const returnUrl = `${window.location.origin}/paid`;
|
const returnUrl = `${window.location.origin}/paid`;
|
||||||
const env = getPayEnv();
|
const env = getPayEnv();
|
||||||
const device = getDeviceFromEnv(env);
|
const device = getDeviceFromEnv(env);
|
||||||
@@ -259,6 +305,20 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [getOrCreateUserId]);
|
}, [getOrCreateUserId]);
|
||||||
|
|
||||||
|
const handlePay = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const meRes = await fetch("/api/auth/me");
|
||||||
|
const meData = await meRes.json();
|
||||||
|
if (meData?.user?.id) {
|
||||||
|
doPay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setAuthForPayOpen(true);
|
||||||
|
}, [doPay]);
|
||||||
|
|
||||||
const handleVerifySuccess = useCallback(() => {
|
const handleVerifySuccess = useCallback(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -297,7 +357,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
getOrCreateUserId();
|
await getOrCreateUserId();
|
||||||
|
|
||||||
const localType = getStorage("paidType");
|
const localType = getStorage("paidType");
|
||||||
const localUserName = getStorage("userName");
|
const localUserName = getStorage("userName");
|
||||||
@@ -308,6 +368,26 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSO: 优先检查根域 Cookie(跨站登录态)
|
||||||
|
try {
|
||||||
|
const meRes = await fetch("/api/auth/me");
|
||||||
|
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 });
|
||||||
|
const vipRes = await fetch("/api/vip/check");
|
||||||
|
const vipData = await vipRes.json();
|
||||||
|
if (vipData?.vip) {
|
||||||
|
savePaidType("vip");
|
||||||
|
saveUserName(meData.user.email || meData.user.id);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
const userId = getStorage("userId");
|
const userId = getStorage("userId");
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const found = await checkPaymentFromPB(userId);
|
const found = await checkPaymentFromPB(userId);
|
||||||
@@ -355,17 +435,34 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
const { pbLogout } = await import("@/app/lib/pocketbase");
|
||||||
|
await pbLogout();
|
||||||
|
clearAllStorage();
|
||||||
|
}, [clearAllStorage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VipLayout isLoggedIn={isLoggedIn} userName={userName}>
|
<>
|
||||||
{isLoggedIn ? (
|
<VipLayout isLoggedIn={isLoggedIn} userName={userName} onLogout={handleLogout}>
|
||||||
<DashboardPage userName={userName} />
|
{isLoggedIn ? (
|
||||||
) : (
|
<DashboardPage userName={userName} />
|
||||||
<LandingPage
|
) : (
|
||||||
onJoin={handlePay}
|
<LandingPage
|
||||||
onVerify={checkPaymentFromPB}
|
onJoin={handlePay}
|
||||||
onVerifySuccess={handleVerifySuccess}
|
onVerify={checkPaymentFromPB}
|
||||||
/>
|
onVerifyByEmail={checkVerifyByEmail}
|
||||||
)}
|
onVerifySuccess={handleVerifySuccess}
|
||||||
</VipLayout>
|
/>
|
||||||
|
)}
|
||||||
|
</VipLayout>
|
||||||
|
<PayAuthModal
|
||||||
|
open={authForPayOpen}
|
||||||
|
onClose={() => setAuthForPayOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setAuthForPayOpen(false);
|
||||||
|
doPay();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import { LoginModal } from "./landing/LoginModal";
|
|||||||
type LandingPageProps = {
|
type LandingPageProps = {
|
||||||
onJoin: () => void;
|
onJoin: () => void;
|
||||||
onVerify: (userId: string) => Promise<boolean>;
|
onVerify: (userId: string) => Promise<boolean>;
|
||||||
|
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
|
||||||
onVerifySuccess: () => void;
|
onVerifySuccess: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LandingPage({
|
export function LandingPage({
|
||||||
onJoin,
|
onJoin,
|
||||||
onVerify,
|
onVerify,
|
||||||
|
onVerifyByEmail,
|
||||||
onVerifySuccess,
|
onVerifySuccess,
|
||||||
}: LandingPageProps) {
|
}: LandingPageProps) {
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
@@ -36,6 +38,7 @@ export function LandingPage({
|
|||||||
open={loginModalOpen}
|
open={loginModalOpen}
|
||||||
onClose={() => setLoginModalOpen(false)}
|
onClose={() => setLoginModalOpen(false)}
|
||||||
onVerify={onVerify}
|
onVerify={onVerify}
|
||||||
|
onVerifyByEmail={onVerifyByEmail}
|
||||||
onSuccess={onVerifySuccess}
|
onSuccess={onVerifySuccess}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import styles from "./vip.module.css";
|
|||||||
type VipHeaderProps = {
|
type VipHeaderProps = {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
|
onLogout?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VipHeader({ isLoggedIn, userName }: VipHeaderProps) {
|
export function VipHeader({ isLoggedIn, userName, onLogout }: VipHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Link
|
<Link
|
||||||
@@ -23,7 +24,19 @@ export function VipHeader({ isLoggedIn, userName }: VipHeaderProps) {
|
|||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<span className={styles.userBadge}>{userName || "会员"}</span>
|
<div className={styles.userBadge} style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<span>{userName || "会员"}</span>
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className={styles.loginLink}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", padding: 0, fontSize: "inherit" }}
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/#login" className={styles.loginLink}>
|
<Link href="/#login" className={styles.loginLink}>
|
||||||
登录
|
登录
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ type VipLayoutProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
|
onLogout?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VipLayout({ children, isLoggedIn, userName }: VipLayoutProps) {
|
export function VipLayout({ children, isLoggedIn, userName, onLogout }: VipLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<VipHeader isLoggedIn={isLoggedIn} userName={userName} />
|
<VipHeader isLoggedIn={isLoggedIn} userName={userName} onLogout={onLogout} />
|
||||||
<main className={styles.main}>{children}</main>
|
<main className={styles.main}>{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
147
app/vip/components/landing/AuthForm.tsx
Normal file
147
app/vip/components/landing/AuthForm.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { pbLogin, pbRegister, pbSaveAuth } from "@/app/lib/pocketbase";
|
||||||
|
import styles from "../vip.module.css";
|
||||||
|
|
||||||
|
type AuthFormProps = {
|
||||||
|
onSuccess: () => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthForm({ onSuccess, autoFocus }: AuthFormProps) {
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (mode === "register") {
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
setError("两次密码不一致");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("密码至少 8 位");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await pbRegister(email, password, passwordConfirm);
|
||||||
|
} else {
|
||||||
|
result = await pbLogin(email, password);
|
||||||
|
}
|
||||||
|
pbSaveAuth(result.token, result.record);
|
||||||
|
await fetch("/api/auth/sync-session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: result.token, record: result.record }),
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "操作失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={mode === "register" ? "至少 8 位" : "••••••••"}
|
||||||
|
required
|
||||||
|
minLength={mode === "register" ? 8 : 1}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
marginBottom: mode === "register" ? "0.75rem" : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{mode === "register" && (
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
||||||
|
style={{ width: "100%", marginBottom: "0.75rem" }}
|
||||||
|
>
|
||||||
|
{loading ? "处理中…" : mode === "login" ? "登录" : "注册"}
|
||||||
|
</button>
|
||||||
|
<p style={{ fontSize: "0.85rem", color: "var(--muted-foreground)", textAlign: "center" }}>
|
||||||
|
{mode === "login" ? (
|
||||||
|
<>
|
||||||
|
还没有账号?{" "}
|
||||||
|
<button type="button" onClick={() => { setMode("register"); setError(null); }} className="text-[var(--primary)]">
|
||||||
|
立即注册
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
已有账号?{" "}
|
||||||
|
<button type="button" onClick={() => { setMode("login"); setError(null); }} className="text-[var(--primary)]">
|
||||||
|
去登录
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect, type FormEvent } from "react";
|
||||||
import styles from "../vip.module.css";
|
import styles from "../vip.module.css";
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
onVerify: (userId: string) => Promise<boolean>;
|
onVerify: (userId: string) => Promise<boolean>;
|
||||||
|
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LoginForm({ onVerify, onSuccess, autoFocus }: LoginFormProps) {
|
export function LoginForm({ onVerify, onVerifyByEmail, onSuccess, autoFocus }: LoginFormProps) {
|
||||||
|
const [mode, setMode] = useState<"user_id" | "email">("user_id");
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus) inputRef.current?.focus();
|
if (autoFocus) inputRef.current?.focus();
|
||||||
}, [autoFocus]);
|
}, [autoFocus, mode]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleUserIdSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = userId.trim();
|
const trimmed = userId.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -30,11 +34,8 @@ export function LoginForm({ onVerify, onSuccess, autoFocus }: LoginFormProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const ok = await onVerify(trimmed);
|
const ok = await onVerify(trimmed);
|
||||||
if (ok) {
|
if (ok) onSuccess();
|
||||||
onSuccess();
|
else setError("未找到该账号的会员记录,请确认账号正确");
|
||||||
} else {
|
|
||||||
setError("未找到该账号的会员记录,请确认账号正确");
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setError("验证失败,请稍后重试");
|
setError("验证失败,请稍后重试");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,39 +43,103 @@ export function LoginForm({ onVerify, onSuccess, autoFocus }: LoginFormProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEmailSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!onVerifyByEmail) return;
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
if (!trimmedEmail || !password) {
|
||||||
|
setError("请输入邮箱和密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const ok = await onVerifyByEmail(trimmedEmail, password);
|
||||||
|
if (ok) onSuccess();
|
||||||
|
else setError("未找到该邮箱关联的会员记录,请确认账号正确");
|
||||||
|
} catch {
|
||||||
|
setError("验证失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: "100%" as const,
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<>
|
||||||
<input
|
<div className="mb-3 flex gap-2">
|
||||||
ref={inputRef}
|
<button
|
||||||
type="text"
|
type="button"
|
||||||
value={userId}
|
onClick={() => { setMode("user_id"); setError(null); }}
|
||||||
onChange={(e) => setUserId(e.target.value)}
|
className={`flex-1 rounded-lg py-2 text-sm font-medium ${mode === "user_id" ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--muted)] text-[var(--muted-foreground)]"}`}
|
||||||
placeholder="例如:user1234567890"
|
>
|
||||||
disabled={loading}
|
user_id
|
||||||
style={{
|
</button>
|
||||||
width: "100%",
|
<button
|
||||||
padding: "0.75rem 1rem",
|
type="button"
|
||||||
fontSize: "1rem",
|
onClick={() => { setMode("email"); setError(null); }}
|
||||||
border: "1px solid var(--border)",
|
className={`flex-1 rounded-lg py-2 text-sm font-medium ${mode === "email" ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--muted)] text-[var(--muted-foreground)]"}`}
|
||||||
borderRadius: "8px",
|
>
|
||||||
background: "var(--background)",
|
邮箱
|
||||||
color: "var(--foreground)",
|
</button>
|
||||||
marginBottom: "0.75rem",
|
</div>
|
||||||
}}
|
{mode === "user_id" ? (
|
||||||
/>
|
<form onSubmit={handleUserIdSubmit}>
|
||||||
{error && (
|
<input
|
||||||
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
|
ref={inputRef}
|
||||||
{error}
|
type="text"
|
||||||
</p>
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
placeholder="例如:user1234567890"
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
{error && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
|
||||||
|
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>
|
||||||
|
{loading ? "验证中..." : "验证并登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : onVerifyByEmail ? (
|
||||||
|
<form onSubmit={handleEmailSubmit}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="密码"
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
{error && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
|
||||||
|
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>
|
||||||
|
{loading ? "验证中..." : "验证并登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleUserIdSubmit}>
|
||||||
|
<input ref={inputRef} type="text" value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="例如:user1234567890" disabled={loading} style={inputStyle} />
|
||||||
|
{error && <p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>{error}</p>}
|
||||||
|
<button type="submit" disabled={loading} className={`${styles.btnPrimary} ${styles.btnLarge}`} style={{ width: "100%" }}>{loading ? "验证中..." : "验证并登录"}</button>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
<button
|
</>
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className={`${styles.btnPrimary} ${styles.btnLarge}`}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{loading ? "验证中..." : "验证并登录"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import styles from "../vip.module.css";
|
import styles from "../vip.module.css";
|
||||||
import { LoginForm } from "./LoginForm";
|
import { LoginForm } from "./LoginForm";
|
||||||
|
import { AuthForm } from "./AuthForm";
|
||||||
|
|
||||||
type LoginModalProps = {
|
type LoginModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onVerify: (userId: string) => Promise<boolean>;
|
onVerify: (userId: string) => Promise<boolean>;
|
||||||
|
onVerifyByEmail?: (email: string, password: string) => Promise<boolean>;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LoginModal({ open, onClose, onVerify, onSuccess }: LoginModalProps) {
|
export function LoginModal({ open, onClose, onVerify, onVerifyByEmail, onSuccess }: LoginModalProps) {
|
||||||
|
const [tab, setTab] = useState<"email" | "legacy">("email");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
@@ -46,19 +50,17 @@ export function LoginModal({ open, onClose, onVerify, onSuccess }: LoginModalPro
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="login-modal-title"
|
aria-labelledby="login-modal-title"
|
||||||
>
|
>
|
||||||
{/* 背景遮罩 */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
{/* 弹窗内容 */}
|
|
||||||
<div
|
<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"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
|
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
|
||||||
已有账号登录
|
登录
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -71,10 +73,41 @@ export function LoginModal({ open, onClose, onVerify, onSuccess }: LoginModalPro
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
|
<div className="mb-4 flex gap-2 border-b border-[var(--border)]">
|
||||||
请输入您的会员账号(支付时使用的 user_id)以恢复权益
|
<button
|
||||||
</p>
|
type="button"
|
||||||
<LoginForm onVerify={onVerify} onSuccess={handleSuccess} autoFocus />
|
onClick={() => setTab("email")}
|
||||||
|
className={`pb-2 text-sm font-medium ${tab === "email" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-[var(--muted-foreground)]"}`}
|
||||||
|
>
|
||||||
|
邮箱登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab("legacy")}
|
||||||
|
className={`pb-2 text-sm font-medium ${tab === "legacy" ? "border-b-2 border-[var(--primary)] text-[var(--primary)]" : "text-[var(--muted-foreground)]"}`}
|
||||||
|
>
|
||||||
|
会员账号恢复
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tab === "email" ? (
|
||||||
|
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
|
||||||
|
使用邮箱登录,可与 digital / meetup 等站点共享账号
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
|
||||||
|
支持 user_id 或关联 users 的邮箱验证
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{tab === "email" ? (
|
||||||
|
<AuthForm onSuccess={handleSuccess} autoFocus />
|
||||||
|
) : (
|
||||||
|
<LoginForm
|
||||||
|
onVerify={onVerify}
|
||||||
|
onVerifyByEmail={onVerifyByEmail}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
66
app/vip/components/landing/PayAuthModal.tsx
Normal file
66
app/vip/components/landing/PayAuthModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import styles from "../vip.module.css";
|
||||||
|
import { AuthForm } from "./AuthForm";
|
||||||
|
|
||||||
|
type PayAuthModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 支付前强制补充邮箱:注册/登录以关联 users 记录 */
|
||||||
|
export function PayAuthModal({ open, onClose, onSuccess }: PayAuthModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) document.body.style.overflow = "hidden";
|
||||||
|
else document.body.style.overflow = "";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
onClose();
|
||||||
|
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">
|
||||||
|
<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"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 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)]"
|
||||||
|
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>
|
||||||
|
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
|
||||||
|
补充邮箱用于关联会员权益,支付后可在各站点通用
|
||||||
|
</p>
|
||||||
|
<AuthForm onSuccess={handleSuccess} autoFocus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
/** 站点标识,用于 VIP 按站点隔离 */
|
||||||
|
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "vip";
|
||||||
|
|
||||||
export interface PocketBaseConfig {
|
export interface PocketBaseConfig {
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
@@ -13,7 +16,7 @@ export interface PaymentConfig {
|
|||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
provider: "zpay" | "xorpay";
|
provider: "zpay" | "xorpay";
|
||||||
zpaySubmitUrl: string;
|
zpaySubmitUrl: string;
|
||||||
/** 默认金额(分),88 元 = 8800 */
|
/** 默认金额(分),测试用1元 */
|
||||||
defaultAmount?: number;
|
defaultAmount?: number;
|
||||||
/** nomadvip 专用接口路径 */
|
/** nomadvip 专用接口路径 */
|
||||||
nomadvipPayPath: string;
|
nomadvipPayPath: string;
|
||||||
@@ -56,7 +59,7 @@ export function getPaymentConfig(): PaymentConfig {
|
|||||||
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
|
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
|
||||||
zpaySubmitUrl:
|
zpaySubmitUrl:
|
||||||
process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
|
process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
|
||||||
defaultAmount: 8800,
|
defaultAmount: 100,
|
||||||
nomadvipPayPath: "/nomadvip/payh5",
|
nomadvipPayPath: "/nomadvip/payh5",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user