's'
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
68
app/api/auth/change-password/route.ts
Normal file
68
app/api/auth/change-password/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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";
|
||||||
|
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
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";
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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";
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 : {};
|
||||||
|
|
||||||
|
|||||||
110
app/api/meetup/ensure-user/route.ts
Normal file
110
app/api/meetup/ensure-user/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/api/meetup/need-password-change/route.ts
Normal file
8
app/api/meetup/need-password-change/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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(() => ({}));
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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: "🏢" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
23
config/domain.config.ts
Normal 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}`;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user