This commit is contained in:
eric
2026-03-12 03:54:50 -05:00
parent 170a9939aa
commit 40230dd646
20 changed files with 645 additions and 95 deletions

View File

@@ -11,6 +11,7 @@ import {
} from "@/app/lib/payment"; } from "@/app/lib/payment";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll"; import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
import type { PayChannel, PayEnv } from "@/app/lib/payment"; import type { PayChannel, PayEnv } from "@/app/lib/payment";
import AuthModal from "@/app/components/AuthModal";
const genderOptions = ["男", "女"]; const genderOptions = ["男", "女"];
const singleOptions = ["单身", "恋爱中", "已婚"]; const singleOptions = ["单身", "恋爱中", "已婚"];
@@ -56,6 +57,8 @@ export default function JoinPage() {
const [payRedirecting, setPayRedirecting] = useState(false); const [payRedirecting, setPayRedirecting] = useState(false);
const [payEnv, setPayEnv] = useState<PayEnv | null>(null); const [payEnv, setPayEnv] = useState<PayEnv | null>(null);
const [payChannel, setPayChannel] = useState<PayChannel>("alipay"); const [payChannel, setPayChannel] = useState<PayChannel>("alipay");
const [authOpen, setAuthOpen] = useState(false);
const [pendingSubmit, setPendingSubmit] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") { if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
@@ -75,8 +78,7 @@ export default function JoinPage() {
const set = (key: keyof FormData, value: string) => const set = (key: keyof FormData, value: string) =>
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = async (e: FormEvent) => { const doSubmit = async () => {
e.preventDefault();
setError(null); setError(null);
setSubmitting(true); setSubmitting(true);
try { try {
@@ -84,6 +86,7 @@ export default function JoinPage() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, media: media?.url }), body: JSON.stringify({ ...form, media: media?.url }),
credentials: "include",
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok || !data?.ok) { if (!res.ok || !data?.ok) {
@@ -115,6 +118,26 @@ export default function JoinPage() {
} }
}; };
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const meRes = await fetch("/api/auth/me", { credentials: "include" });
const meData = await meRes.json();
if (!meData?.user) {
setPendingSubmit(true);
setAuthOpen(true);
return;
}
await doSubmit();
};
const handleAuthSuccess = () => {
setAuthOpen(false);
if (pendingSubmit) {
setPendingSubmit(false);
doSubmit();
}
};
if (submitted) { if (submitted) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4"> <div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
@@ -403,40 +426,6 @@ export default function JoinPage() {
)} )}
</div> </div>
<div className="mt-6 rounded-xl border border-slate-100 dark:border-gray-700 bg-slate-50/50 dark:bg-gray-800/50 px-4 py-4 sm:px-5">
<p className="mb-3 text-sm font-bold text-slate-700 dark:text-slate-300">💳 </p>
{payEnv === "wechat" ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
使 <span className="font-medium text-emerald-600"></span>
</p>
) : (
<div className="flex gap-3">
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-sky-500 has-[:checked]:bg-sky-50 dark:has-[:checked]:bg-sky-900/20">
<input
type="radio"
name="payChannel"
value="alipay"
checked={payChannel === "alipay"}
onChange={() => setPayChannel("alipay")}
className="h-4 w-4 border-slate-300 text-sky-500"
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
</label>
<label className="flex flex-1 cursor-pointer items-center gap-2 rounded-lg border-2 px-4 py-3 transition-colors has-[:checked]:border-emerald-500 has-[:checked]:bg-emerald-50 dark:has-[:checked]:bg-emerald-900/20">
<input
type="radio"
name="payChannel"
value="wxpay"
checked={payChannel === "wxpay"}
onChange={() => setPayChannel("wxpay")}
className="h-4 w-4 border-slate-300 text-emerald-500"
/>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
</label>
</div>
)}
</div>
<div className="mt-8 sm:mt-10"> <div className="mt-8 sm:mt-10">
{error && ( {error && (
<p className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-3 text-center text-sm text-red-600 dark:text-red-400"> <p className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-3 text-center text-sm text-red-600 dark:text-red-400">
@@ -465,6 +454,12 @@ export default function JoinPage() {
</Link> </Link>
</div> </div>
</div> </div>
<AuthModal
isOpen={authOpen}
onClose={() => { setAuthOpen(false); setPendingSubmit(false); }}
onSuccess={handleAuthSuccess}
/>
</div> </div>
); );
} }

View File

@@ -1,31 +1,122 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { Link } from "@/i18n/navigation"; import { Link } from "@/i18n/navigation";
/** /**
* 支付完成回跳页 * 支付完成回跳页
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页 * Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
* 若 meetup_needs_password_change 为 true则强制弹窗修改密码
*/ */
export default function JoinPaidPage() { export default function JoinPaidPage() {
const [needPasswordChange, setNeedPasswordChange] = useState<boolean | null>(null);
const [showChangeModal, setShowChangeModal] = useState(false);
const [newPassword, setNewPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [changeError, setChangeError] = useState<string | null>(null);
const [changing, setChanging] = useState(false);
useEffect(() => {
fetch("/api/meetup/need-password-change")
.then((r) => r.json())
.then((d) => {
setNeedPasswordChange(d?.need === true);
if (d?.need === true) setShowChangeModal(true);
})
.catch(() => setNeedPasswordChange(false));
}, []);
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setChangeError(null);
if (newPassword.length < 8) {
setChangeError("密码至少 8 位");
return;
}
if (newPassword !== passwordConfirm) {
setChangeError("两次密码不一致");
return;
}
setChanging(true);
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newPassword, passwordConfirm }),
credentials: "include",
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "修改失败");
}
setShowChangeModal(false);
} catch (err) {
setChangeError(err instanceof Error ? err.message : "修改失败");
} finally {
setChanging(false);
}
};
return ( return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4"> <>
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-gray-900 p-10 text-center shadow-lg border border-gray-100 dark:border-gray-800"> <div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl"> <div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-gray-900 p-10 text-center shadow-lg border border-gray-100 dark:border-gray-800">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
</div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100"></h2>
<p className="mt-3 text-slate-500 dark:text-slate-400">
<br />
</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
</Link>
</div> </div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100"></h2>
<p className="mt-3 text-slate-500 dark:text-slate-400">
<br />
</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
</Link>
</div> </div>
</div>
{showChangeModal && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white dark:bg-gray-900 p-6 shadow-xl border border-gray-100 dark:border-gray-800">
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-100"></h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
8
</p>
<form onSubmit={handleChangePassword} className="mt-4 space-y-3">
<input
type="password"
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setChangeError(null); }}
placeholder="新密码"
minLength={8}
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800"
/>
<input
type="password"
value={passwordConfirm}
onChange={(e) => { setPasswordConfirm(e.target.value); setChangeError(null); }}
placeholder="确认新密码"
minLength={8}
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800"
/>
{changeError && <p className="text-sm text-red-600 dark:text-red-400">{changeError}</p>}
<button
type="submit"
disabled={changing}
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white disabled:opacity-70"
>
{changing ? "提交中…" : "确认修改"}
</button>
</form>
</div>
</div>
)}
</>
); );
} }

View File

@@ -0,0 +1,68 @@
/**
* 修改密码 - 用于 meetup 支付成功后强制修改默认密码
*/
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
const COOKIE_NAME = "pb_session";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
function getAuthFromCookie(request: NextRequest): { userId: string; token: string } | null {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (!cookie) return null;
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
if (payload?.userId && payload?.token) return { userId: payload.userId, token: payload.token };
return null;
} catch {
return null;
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const newPassword = String(body?.newPassword || "").trim();
const passwordConfirm = String(body?.passwordConfirm || "").trim();
if (newPassword.length < 8) {
return NextResponse.json({ ok: false, error: "密码至少 8 位" }, { status: 400 });
}
if (newPassword !== passwordConfirm) {
return NextResponse.json({ ok: false, error: "两次密码不一致" }, { status: 400 });
}
const auth = getAuthFromCookie(request);
if (!auth) {
return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 });
}
const pb = getPocketBaseConfig();
const res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${auth.token}`,
},
body: JSON.stringify({
password: newPassword,
passwordConfirm: passwordConfirm,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return NextResponse.json(
{ ok: false, error: err?.message || "修改失败" },
{ status: 400 }
);
}
const r = NextResponse.json({ ok: true });
r.cookies.set(COOKIE_NEEDS_PW, "", { path: "/", maxAge: 0 });
return r;
} catch (e) {
console.error("change-password error:", e);
return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 });
}
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
return res;
}

60
app/api/auth/me/route.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
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: AUTH_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: AUTH_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 });
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
const COOKIE_NAME = "pb_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
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: AUTH_COOKIE_DOMAIN,
path: "/",
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
httpOnly: true,
});
return res;
}

View File

@@ -1,7 +1,18 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config"; import { getPocketBaseConfig } from "@/config/services.config";
function getOrCreateUserId(): string { const COOKIE_NAME = "pb_session";
function getOrCreateUserId(request: NextRequest): string {
const cookie = request.cookies.get(COOKIE_NAME)?.value;
if (cookie) {
try {
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
if (payload?.userId) return payload.userId;
} catch {
/* ignore */
}
}
return `user${Date.now()}`; return `user${Date.now()}`;
} }
@@ -23,7 +34,7 @@ async function getAdminToken(): Promise<string | null> {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const userId = getOrCreateUserId(); const userId = getOrCreateUserId(request);
const body = await request.json(); const body = await request.json();
const formData = body && typeof body === "object" ? body : {}; const formData = body && typeof body === "object" ? body : {};

View File

@@ -0,0 +1,110 @@
/**
* 加入游牧中国:确保用户存在
* 新用户:用默认密码 123456789 创建,支付成功后需修改
* 老用户:验证密码后登录
*/
import { NextRequest, NextResponse } from "next/server";
import { getPocketBaseConfig } from "@/config/services.config";
const DEFAULT_PASSWORD = "123456789";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
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 POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const email = String(body?.email || "").trim();
const password = String(body?.password || "").trim();
if (!email) {
return NextResponse.json({ ok: false, error: "请输入邮箱" }, { status: 400 });
}
const pb = getPocketBaseConfig();
const tryLogin = async (pwd: string) => {
const res = await fetch(`${pb.url}/api/collections/users/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password: pwd }),
});
return res;
};
let loginRes = await tryLogin(password || DEFAULT_PASSWORD);
if (loginRes.ok) {
const data = await loginRes.json();
const res = NextResponse.json({
ok: true,
user_id: data.record?.id,
token: data.token,
record: data.record,
is_new: false,
});
return res;
}
const errData = await loginRes.json().catch(() => ({}));
const isNotFound = loginRes.status === 400 && (errData?.message?.includes("Invalid") || errData?.message?.includes("identity"));
if (!isNotFound && password) {
return NextResponse.json({ ok: false, error: "密码错误" }, { status: 400 });
}
const adminToken = await getAdminToken();
if (!adminToken) {
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
}
const createRes = await fetch(`${pb.url}/api/collections/users/records`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({
email,
password: DEFAULT_PASSWORD,
passwordConfirm: DEFAULT_PASSWORD,
}),
});
if (!createRes.ok) {
const createErr = await createRes.json().catch(() => ({}));
if (createRes.status === 400 && createErr?.data?.email?.message?.includes("already")) {
return NextResponse.json({ ok: false, error: "该邮箱已注册,请输入密码登录" }, { status: 400 });
}
return NextResponse.json({ ok: false, error: createErr?.message || "创建账号失败" }, { status: 500 });
}
const newUser = await createRes.json();
const finalLogin = await tryLogin(DEFAULT_PASSWORD);
if (!finalLogin.ok) {
return NextResponse.json({ ok: false, error: "登录失败" }, { status: 500 });
}
const authData = await finalLogin.json();
const res = NextResponse.json({
ok: true,
user_id: authData.record?.id,
token: authData.token,
record: authData.record,
is_new: true,
});
res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
return res;
} catch (e) {
console.error("ensure-user error:", e);
return NextResponse.json({ ok: false, error: "操作失败" }, { status: 500 });
}
}

View File

@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
export async function GET(request: NextRequest) {
const need = request.cookies.get(COOKIE_NEEDS_PW)?.value === "1";
return NextResponse.json({ need });
}

View File

@@ -14,6 +14,7 @@ async function createPayOrder(
const payload = { const payload = {
user_id, user_id,
site_id: "meetup",
total_fee: Number(total_fee) || config.defaultAmount, total_fee: Number(total_fee) || config.defaultAmount,
type: type || config.defaultType, type: type || config.defaultType,
channel, channel,
@@ -23,7 +24,7 @@ async function createPayOrder(
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(`${config.apiUrl}/payh5`, { const res = await fetch(`${config.apiUrl}/cnomadcna/payh5`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -56,7 +57,7 @@ export async function POST(request: NextRequest) {
const user_id = String(body?.user_id || "").trim(); const user_id = String(body?.user_id || "").trim();
const return_url = String(body?.return_url || "").trim() || `${SITE_URL}/zh/join/paid`; const return_url = String(body?.return_url || "").trim() || `${SITE_URL}/zh/join/paid`;
const total_fee = Number(body?.total_fee) || 80; const total_fee = Number(body?.total_fee) || 100;
const type = String(body?.type || "meetup"); const type = String(body?.type || "meetup");
const channel = String(body?.channel || "alipay"); const channel = String(body?.channel || "alipay");
const device = String(body?.device || "pc"); const device = String(body?.device || "pc");

View File

@@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000); const timeout = setTimeout(() => controller.abort(), 25000);
const res = await fetch( const res = await fetch(
`${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`, `${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
{ cache: "no-store", signal: controller.signal } { cache: "no-store", signal: controller.signal }
).finally(() => clearTimeout(timeout)); ).finally(() => clearTimeout(timeout));
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));

View 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 });
}
}

View File

@@ -47,6 +47,15 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
result = await pbLogin(email, password); result = await pbLogin(email, password);
} }
pbSaveAuth(result.token, result.record); pbSaveAuth(result.token, result.record);
try {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: result.token, record: result.record }),
});
} catch {
/* ignore */
}
onSuccess(email); onSuccess(email);
onClose(); onClose();
setEmail(""); setEmail("");

View File

@@ -3,6 +3,14 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useLocale } from "@/i18n/navigation"; import { Link, useLocale } from "@/i18n/navigation";
import { useTranslation } from "@/i18n/navigation"; import { useTranslation } from "@/i18n/navigation";
import {
getPayEnv,
getRecommendedChannel,
mustUseWxPay,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"]; const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"]; const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
@@ -34,11 +42,49 @@ export default function HeroSection() {
const { t: tHero } = useTranslation("hero"); const { t: tHero } = useTranslation("hero");
const locale = useLocale(); const locale = useLocale();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleJoin = (e: React.FormEvent) => { const handleJoin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (email.trim()) { const em = email.trim();
alert(locale === "zh" ? `欢迎加入游牧中国!我们将发送确认邮件到 ${email}` : `Welcome! We'll send a confirmation email to ${email}`); if (!em) {
setError(locale === "zh" ? "请输入邮箱" : "Please enter your email");
return;
}
setError(null);
setLoading(true);
try {
const res = await fetch("/api/meetup/ensure-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: em, password: password || undefined }),
credentials: "include",
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed"));
}
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: data.token, record: data.record }),
});
const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid";
const env: PayEnv = typeof window !== "undefined" ? (navigator.userAgent.includes("MicroMessenger") ? "wechat" : "pc") : "pc";
const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
redirectToPay({
user_id: data.user_id,
return_url: returnUrl,
channel,
device: getDeviceFromEnv(env),
useJoinConfig: true,
});
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
} }
}; };
@@ -151,18 +197,29 @@ export default function HeroSection() {
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder={tHero("emailPlaceholder")} placeholder={tHero("emailPlaceholder")}
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3" className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
/> />
<input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }}
placeholder={locale === "zh" ? "密码(老用户必填)" : "Password (required for existing users)"}
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
/>
{error && (
<p className="mb-2 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button <button
type="submit" type="submit"
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] dark:hover:bg-[#ff5c5e] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98]" disabled={loading}
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] dark:hover:bg-[#ff5c5e] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98] disabled:opacity-70"
> >
{tHero("ctaJoin")} {loading ? (locale === "zh" ? "处理中…" : "Processing…") : tHero("ctaJoin")}
</button> </button>
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3"> <p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
{tHero("loginHint")} {locale === "zh" ? "新用户将自动注册,支付成功后需修改默认密码" : "New users auto-register; change default password after payment"}
</p> </p>
</form> </form>
</div> </div>

View File

@@ -22,11 +22,28 @@ export default function NavbarComponent() {
const navLinks = getNavLinks(); const navLinks = getNavLinks();
useEffect(() => { useEffect(() => {
setUserEmail(getStoredUserEmail()); let mounted = true;
(async () => {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (!mounted) return;
if (data?.user && data?.token) {
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.user.id, email: data.user.email });
setUserEmail(data.user.email);
return;
}
} catch {
/* ignore */
}
if (mounted) setUserEmail(getStoredUserEmail());
})();
return () => { mounted = false; };
}, []); }, []);
const handleLogout = useCallback(() => { const handleLogout = useCallback(async () => {
pbLogout(); await pbLogout();
setUserEmail(null); setUserEmail(null);
}, []); }, []);
@@ -104,14 +121,7 @@ export default function NavbarComponent() {
{t("logout")} {t("logout")}
</button> </button>
</div> </div>
) : ( ) : null}
<button
onClick={() => setAuthOpen(true)}
className="hidden md:block text-xs sm:text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-2 sm:px-3 py-2 rounded-full hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors whitespace-nowrap"
>
{t("loginRegister")}
</button>
)}
<button <button
type="button" type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })} onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
@@ -177,14 +187,7 @@ export default function NavbarComponent() {
{t("logout")} {t("logout")}
</button> </button>
</div> </div>
) : ( ) : null}
<button
onClick={() => { setMobileOpen(false); setAuthOpen(true); }}
className="w-full text-left px-4 py-3 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
>
{t("loginRegister")}
</button>
)}
</div> </div>
)} )}

View File

@@ -85,7 +85,7 @@ export default memo(function SiteMenu({
{ {
titleKey: "other", titleKey: "other",
items: [ items: [
{ labelKey: "digitalNomadGuide", href: "https://digital.hackrobot.cn/", icon: "📖" }, { labelKey: "digitalNomadGuide", href: process.env.NEXT_PUBLIC_DIGITAL_URL || "https://digital.hackrobot.cn/", icon: "📖" },
{ labelKey: "remoteJobs", href: "#", icon: "💼" }, { labelKey: "remoteJobs", href: "#", icon: "💼" },
{ labelKey: "coworking", href: "#", icon: "🏢" }, { labelKey: "coworking", href: "#", icon: "🏢" },
], ],

View File

@@ -67,10 +67,16 @@ export function pbSaveAuth(token: string, record: AuthUser): void {
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record)); localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
} }
export function pbLogout(): void { /** 登出 - 清除本地存储和根域 Cookie */
export async function pbLogout(): Promise<void> {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
localStorage.removeItem(PB_STORAGE_KEYS.token); localStorage.removeItem(PB_STORAGE_KEYS.token);
localStorage.removeItem(PB_STORAGE_KEYS.user); localStorage.removeItem(PB_STORAGE_KEYS.user);
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
} }
export function getStoredUserEmail(): string | null { export function getStoredUserEmail(): string | null {

23
config/domain.config.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* 域名与根域配置 - 特色标识
* 更换域名(如 *.hackrobot.cn -> *.nomadro.com只需设置 ROOT_DOMAIN 或各独立变量
*/
const isDev = process.env.NODE_ENV === "development";
export const ROOT_DOMAIN =
process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn";
export const AUTH_COOKIE_DOMAIN =
process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`;
export const DEFAULT_PAYMENT_API_URL = isDev
? "http://127.0.0.1:8700"
: `https://api.${ROOT_DOMAIN}`;
export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`;
export const DEFAULT_MINIO_HOST = `minioweb.${ROOT_DOMAIN}`;
/** digital 专题站默认地址(用于跨站链接) */
export const DEFAULT_DIGITAL_URL = `https://digital.${ROOT_DOMAIN}`;

View File

@@ -8,6 +8,7 @@ export {
getPaymentConfig, getPaymentConfig,
getJoinPaymentConfig, getJoinPaymentConfig,
getMinioConfig, getMinioConfig,
SITE_ID,
} from "./services.config"; } from "./services.config";
export type { export type {
PocketBaseConfig, PocketBaseConfig,

View File

@@ -1,7 +1,14 @@
/** /**
* 服务配置 - PocketBase、支付、MinIO * 服务配置 - PocketBase、支付、MinIO
* 域名更换:设置 ROOT_DOMAIN如 nomadro.com可推导默认 API 地址
*/ */
import {
DEFAULT_PAYMENT_API_URL,
DEFAULT_POCKETBASE_URL,
DEFAULT_MINIO_HOST,
} from "./domain.config";
export interface PocketBaseConfig { export interface PocketBaseConfig {
enabled: boolean; enabled: boolean;
url: string; url: string;
@@ -33,13 +40,19 @@ export interface MinioConfig {
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 || "meetup";
/** meetup 加入游牧中国 价格测试用1元 */
export const MEETUP_JOIN_AMOUNT = 100;
export function getPocketBaseConfig(): PocketBaseConfig { export function getPocketBaseConfig(): PocketBaseConfig {
return { return {
enabled: true, enabled: true,
url: url:
process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.NEXT_PUBLIC_POCKETBASE_URL ||
process.env.POCKETBASE_URL || process.env.POCKETBASE_URL ||
"https://pocketbase.hackrobot.cn", DEFAULT_POCKETBASE_URL,
usersCollection: "users", usersCollection: "users",
joinCollection: process.env.POCKETBASE_JOIN_COLLECTION || "solan", joinCollection: process.env.POCKETBASE_JOIN_COLLECTION || "solan",
}; };
@@ -48,11 +61,9 @@ export function getPocketBaseConfig(): PocketBaseConfig {
export function getPaymentConfig(): PaymentConfig { export function getPaymentConfig(): PaymentConfig {
return { return {
enabled: true, enabled: true,
apiUrl: apiUrl: process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL,
process.env.PAYMENT_API_URL ||
(isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"),
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay", provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 80, defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 100,
defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup", defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup",
zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php", zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
}; };
@@ -60,10 +71,9 @@ export function getPaymentConfig(): PaymentConfig {
/** 获取「加入」场景的支付配置 */ /** 获取「加入」场景的支付配置 */
export function getJoinPaymentConfig(): { amount: number; type: string } { export function getJoinPaymentConfig(): { amount: number; type: string } {
const payment = getPaymentConfig();
return { return {
amount: Number(process.env.PAYMENT_JOIN_AMOUNT) || payment.defaultAmount, amount: Number(process.env.PAYMENT_JOIN_AMOUNT) || MEETUP_JOIN_AMOUNT,
type: process.env.PAYMENT_JOIN_TYPE || payment.defaultType, type: process.env.PAYMENT_JOIN_TYPE || "meetup",
}; };
} }
@@ -71,7 +81,7 @@ export function getMinioConfig(themeOverride?: {
bucket?: string; bucket?: string;
uploadPrefix?: string; uploadPrefix?: string;
}): MinioConfig { }): MinioConfig {
const endPoint = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn"; const endPoint = process.env.MINIO_ENDPOINT || DEFAULT_MINIO_HOST;
const port = parseInt(process.env.MINIO_PORT || "9035", 10); const port = parseInt(process.env.MINIO_PORT || "9035", 10);
const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot"; const bucket = themeOverride?.bucket || process.env.MINIO_BUCKET || "hackrobot";
const publicUrl = const publicUrl =