's'
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -39,3 +39,10 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
.next
|
||||||
|
.next_stale*
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|||||||
@@ -52,21 +52,48 @@ export default function JoinPage() {
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [isVipSubmit, setIsVipSubmit] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
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>("wxpay");
|
||||||
const [authOpen, setAuthOpen] = useState(false);
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
const [pendingSubmit, setPendingSubmit] = useState(false);
|
const [pendingSubmit, setPendingSubmit] = useState(false);
|
||||||
|
const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false);
|
||||||
|
const [checkingStatus, setCheckingStatus] = useState(true);
|
||||||
|
|
||||||
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") {
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
|
setCheckingStatus(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const checkExisting = async () => {
|
||||||
|
const meRes = await fetch("/api/auth/me", { credentials: "include" });
|
||||||
|
const meData = await meRes.json();
|
||||||
|
if (!meData?.user) {
|
||||||
|
setCheckingStatus(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusRes = await fetch("/api/join/status", { credentials: "include", cache: "no-store" });
|
||||||
|
const statusData = await statusRes.json();
|
||||||
|
if (statusData?.hasRecord && statusData?.isComplete) {
|
||||||
|
setIsAlreadyRecorded(true);
|
||||||
|
setSubmitted(true);
|
||||||
|
}
|
||||||
|
setCheckingStatus(false);
|
||||||
|
};
|
||||||
|
checkExisting();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
usePayStatusPoll(() => setSubmitted(true));
|
// 轮询到支付成功时跳转到 /join/paid,避免在 /join 重复显示成功页
|
||||||
|
usePayStatusPoll(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const paidPath = path.replace(/\/join\/?$/, "") + "/join/paid";
|
||||||
|
window.location.replace(paidPath);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -97,6 +124,15 @@ export default function JoinPage() {
|
|||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 再次确认 VIP,防止 handleAuthSuccess 等路径绕过
|
||||||
|
const vipRes2 = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||||
|
const vipData2 = await vipRes2.json();
|
||||||
|
if (vipData2?.vip) {
|
||||||
|
setIsVipSubmit(true);
|
||||||
|
setSubmitted(true);
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setPayRedirecting(true);
|
setPayRedirecting(true);
|
||||||
const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
|
const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
|
||||||
@@ -127,6 +163,11 @@ export default function JoinPage() {
|
|||||||
setAuthOpen(true);
|
setAuthOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||||
|
const vipData = await vipRes.json();
|
||||||
|
if (vipData?.vip) {
|
||||||
|
setIsVipSubmit(true);
|
||||||
|
}
|
||||||
await doSubmit();
|
await doSubmit();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,11 +186,11 @@ export default function JoinPage() {
|
|||||||
<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="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>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">申请已提交</h2>
|
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||||
|
{isAlreadyRecorded ? "资料已录入" : isVipSubmit ? "提交成功" : "支付成功"}
|
||||||
|
</h2>
|
||||||
<p className="mt-3 text-slate-500 dark:text-slate-400">
|
<p className="mt-3 text-slate-500 dark:text-slate-400">
|
||||||
我们会尽快审核你的信息
|
{isAlreadyRecorded ? "您的报名信息已录入,无需重复提交" : isVipSubmit ? "感谢您的参与" : "感谢您的支持"}
|
||||||
<br />
|
|
||||||
审核通过后将通过微信邀请你入群
|
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
@@ -162,6 +203,14 @@ export default function JoinPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (checkingStatus) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
|
||||||
|
<div className="text-slate-500 dark:text-slate-400">加载中…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f0f4f8] dark:bg-gray-950">
|
<div className="min-h-screen bg-[#f0f4f8] dark:bg-gray-950">
|
||||||
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">
|
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">
|
||||||
|
|||||||
@@ -3,29 +3,127 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
const DEFAULT_PASSWORD = "12345678";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 支付完成回跳页
|
* 支付完成回跳页
|
||||||
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
|
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
|
||||||
* 若 meetup_needs_password_change 为 true,则强制弹窗修改密码
|
* 新用户支付成功后弹窗显示默认密码 12345678,并提供修改密码选项
|
||||||
*/
|
*/
|
||||||
export default function JoinPaidPage() {
|
export default function JoinPaidPage() {
|
||||||
const [needPasswordChange, setNeedPasswordChange] = useState<boolean | null>(null);
|
const [needPasswordChange, setNeedPasswordChange] = useState<boolean | null>(null);
|
||||||
const [showChangeModal, setShowChangeModal] = useState(false);
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||||
const [changeError, setChangeError] = useState<string | null>(null);
|
const [changeError, setChangeError] = useState<string | null>(null);
|
||||||
const [changing, setChanging] = useState(false);
|
const [changing, setChanging] = useState(false);
|
||||||
|
const [showChangeForm, setShowChangeForm] = useState(false);
|
||||||
|
const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending");
|
||||||
|
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||||
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string }> => {
|
||||||
|
const r = await fetch("/api/meetup/complete-order", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ order_id: orderId }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d?.ok) return { ok: false, error: d?.error || `HTTP ${r.status}` };
|
||||||
|
|
||||||
|
if (d?.token && d?.record) {
|
||||||
|
await fetch("/api/auth/sync-session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: d.token, record: d.record }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||||
|
pbSaveAuth(d.token, d.record);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||||
|
setTimeout(() => window.dispatchEvent(new CustomEvent("auth:updated")), 500);
|
||||||
|
if (d.is_new) {
|
||||||
|
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
|
||||||
|
if (!cancelled) {
|
||||||
|
setNeedPasswordChange(true);
|
||||||
|
setShowPasswordModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order="));
|
||||||
|
const rawValue = cookie?.split("=")[1]?.trim();
|
||||||
|
const decoded = rawValue ? decodeURIComponent(rawValue) : "";
|
||||||
|
const orderId = decoded.split("|")[0]?.trim() || "";
|
||||||
|
if (!orderId) {
|
||||||
|
window.location.replace(`/${window.location.pathname.split("/")[1] || "zh"}/join`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxClientRetries = 5;
|
||||||
|
const retryDelays = [2000, 3000, 4000, 5000, 6000];
|
||||||
|
let lastError = "";
|
||||||
|
for (let i = 0; i <= maxClientRetries; i++) {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const result = await tryComplete(orderId);
|
||||||
|
if (result.ok) {
|
||||||
|
document.cookie = "join_pay_order=; path=/; max-age=0";
|
||||||
|
if (!cancelled) setCompleteStatus("success");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastError = result.error || "unknown";
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e instanceof Error ? e.message : "network error";
|
||||||
|
}
|
||||||
|
if (i < maxClientRetries) {
|
||||||
|
await new Promise((r) => setTimeout(r, retryDelays[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setCompleteStatus("error");
|
||||||
|
setErrorDetail(lastError);
|
||||||
|
}
|
||||||
|
|
||||||
fetch("/api/meetup/need-password-change")
|
fetch("/api/meetup/need-password-change")
|
||||||
.then((r) => r.json())
|
.then((res) => res.json())
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
|
if (cancelled) return;
|
||||||
setNeedPasswordChange(d?.need === true);
|
setNeedPasswordChange(d?.need === true);
|
||||||
if (d?.need === true) setShowChangeModal(true);
|
if (d?.need === true) setShowPasswordModal(true);
|
||||||
})
|
})
|
||||||
.catch(() => setNeedPasswordChange(false));
|
.catch(() => { if (!cancelled) setNeedPasswordChange(false); });
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const COUNTDOWN_SECONDS = 5;
|
||||||
|
useEffect(() => {
|
||||||
|
if (completeStatus !== "success" || showPasswordModal) return;
|
||||||
|
setCountdown(COUNTDOWN_SECONDS);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev == null || prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
const locale = window.location.pathname.split("/")[1] || "zh";
|
||||||
|
window.location.replace(`/${locale}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [completeStatus, showPasswordModal]);
|
||||||
|
|
||||||
const handleChangePassword = async (e: React.FormEvent) => {
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setChangeError(null);
|
setChangeError(null);
|
||||||
@@ -49,7 +147,7 @@ export default function JoinPaidPage() {
|
|||||||
if (!res.ok || !data?.ok) {
|
if (!res.ok || !data?.ok) {
|
||||||
throw new Error(data?.error || "修改失败");
|
throw new Error(data?.error || "修改失败");
|
||||||
}
|
}
|
||||||
setShowChangeModal(false);
|
setShowPasswordModal(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setChangeError(err instanceof Error ? err.message : "修改失败");
|
setChangeError(err instanceof Error ? err.message : "修改失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,6 +155,14 @@ export default function JoinPaidPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyPassword = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(DEFAULT_PASSWORD);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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">
|
||||||
@@ -64,12 +170,30 @@ export default function JoinPaidPage() {
|
|||||||
<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="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>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">申请已提交</h2>
|
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||||
|
{completeStatus === "pending" ? "处理中…" : completeStatus === "success" ? "支付成功" : "处理异常"}
|
||||||
|
</h2>
|
||||||
<p className="mt-3 text-slate-500 dark:text-slate-400">
|
<p className="mt-3 text-slate-500 dark:text-slate-400">
|
||||||
我们会尽快审核你的信息
|
{completeStatus === "success"
|
||||||
<br />
|
? countdown != null
|
||||||
审核通过后将通过微信邀请你入群
|
? `${countdown} 秒后返回首页`
|
||||||
|
: "感谢您的支持"
|
||||||
|
: completeStatus === "error"
|
||||||
|
? "请稍后刷新页面重试"
|
||||||
|
: "正在确认支付状态…"}
|
||||||
</p>
|
</p>
|
||||||
|
{completeStatus === "error" && errorDetail && (
|
||||||
|
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">{errorDetail}</p>
|
||||||
|
)}
|
||||||
|
{completeStatus === "error" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 rounded-full border border-[#ff4d4f] px-6 py-2.5 text-sm font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10 transition-colors"
|
||||||
|
>
|
||||||
|
刷新重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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]"
|
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]"
|
||||||
@@ -79,19 +203,40 @@ export default function JoinPaidPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showChangeModal && (
|
{showPasswordModal && (
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4">
|
<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">
|
<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>
|
<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">
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
为保障账号安全,请立即设置新密码(至少 8 位)
|
请妥善保存以下默认密码,用于登录本网站(游牧中国)
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-4 flex items-center gap-2 rounded-lg bg-slate-100 dark:bg-slate-800 px-4 py-3">
|
||||||
|
<code className="flex-1 font-mono text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{DEFAULT_PASSWORD}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyPassword}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-sm font-medium text-[#ff4d4f] hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!showChangeForm ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowChangeForm(true)}
|
||||||
|
className="mt-4 w-full rounded-lg border border-[#ff4d4f] py-2.5 text-sm font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10 transition-colors"
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<form onSubmit={handleChangePassword} className="mt-4 space-y-3">
|
<form onSubmit={handleChangePassword} className="mt-4 space-y-3">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => { setNewPassword(e.target.value); setChangeError(null); }}
|
onChange={(e) => { setNewPassword(e.target.value); setChangeError(null); }}
|
||||||
placeholder="新密码"
|
placeholder="新密码(至少 8 位)"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
required
|
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"
|
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"
|
||||||
@@ -106,14 +251,31 @@ export default function JoinPaidPage() {
|
|||||||
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"
|
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>}
|
{changeError && <p className="text-sm text-red-600 dark:text-red-400">{changeError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowChangeForm(false)}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 py-2.5 text-sm font-medium text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={changing}
|
disabled={changing}
|
||||||
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white disabled:opacity-70"
|
className="flex-1 rounded-lg bg-[#ff4d4f] py-2.5 font-semibold text-white disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{changing ? "提交中…" : "确认修改"}
|
{changing ? "提交中…" : "确认修改"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPasswordModal(false)}
|
||||||
|
className="mt-3 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
稍后修改
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getPocketBaseConfig } from "@/config/services.config";
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
import { getAdminToken } from "@/app/lib/pocketbase/admin";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
const COOKIE_NAME = "pb_session";
|
||||||
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
|
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
|
||||||
@@ -38,24 +39,35 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pb = getPocketBaseConfig();
|
const pb = getPocketBaseConfig();
|
||||||
const res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
|
const patchBody = { password: newPassword, passwordConfirm };
|
||||||
|
|
||||||
|
let res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${auth.token}`,
|
Authorization: `Bearer ${auth.token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(patchBody),
|
||||||
password: newPassword,
|
|
||||||
passwordConfirm: passwordConfirm,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const adminToken = await getAdminToken();
|
||||||
|
if (adminToken) {
|
||||||
|
res = await fetch(`${pb.url}/api/collections/users/records/${auth.userId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(patchBody),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
return NextResponse.json(
|
const msg = err?.message || (err?.data && typeof err.data === "object" && Object.values(err.data)[0] && (Object.values(err.data)[0] as { message?: string })?.message) || "修改失败";
|
||||||
{ ok: false, error: err?.message || "修改失败" },
|
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = NextResponse.json({ ok: true });
|
const r = NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getAuthCookieDomain } from "@/config/domain.config";
|
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const domain = getAuthCookieDomain(request);
|
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(COOKIE_NAME, "", {
|
res.cookies.set(COOKIE_NAME, "", getCookieOptions(true, getHostFromRequest(request)) as Record<string, string | number | boolean>);
|
||||||
...(domain && { domain }),
|
|
||||||
path: "/",
|
|
||||||
maxAge: 0,
|
|
||||||
});
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getPocketBaseConfig } from "@/config/services.config";
|
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
|
||||||
import { getAuthCookieDomain } from "@/config/domain.config";
|
import { getAdminToken } from "@/app/lib/pocketbase/admin";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
const COOKIE_NAME = "pb_session";
|
||||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
async function checkVip(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const token = await getAdminToken();
|
||||||
|
if (!token) return 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}` }, cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const record = items[0];
|
||||||
|
if (!record) return false;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const isExpired = record?.expires_at && record.expires_at < now;
|
||||||
|
return !isExpired;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
const domain = getAuthCookieDomain(request);
|
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
return NextResponse.json({ ok: true, user: null });
|
return NextResponse.json({ ok: true, user: null });
|
||||||
}
|
}
|
||||||
@@ -17,12 +39,14 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
|
if (!token || !userId) return NextResponse.json({ ok: true, user: null });
|
||||||
|
|
||||||
const pb = getPocketBaseConfig();
|
const pb = getPocketBaseConfig();
|
||||||
const res = await fetch(`${pb.url}/api/users/refresh`, {
|
const res = await fetch(`${pb.url}/api/collections/${pb.usersCollection}/auth-refresh`, {
|
||||||
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const r = NextResponse.json({ ok: true, user: null });
|
const r = NextResponse.json({ ok: true, user: null });
|
||||||
r.cookies.set(COOKIE_NAME, "", { ...(domain && { domain }), path: "/", maxAge: 0 });
|
r.cookies.set(COOKIE_NAME, "", { path: "/", maxAge: 0 });
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -35,13 +59,13 @@ export async function GET(request: NextRequest) {
|
|||||||
email: newRecord.email ?? email,
|
email: newRecord.email ?? email,
|
||||||
});
|
});
|
||||||
const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
|
const encoded = Buffer.from(newPayload, "utf-8").toString("base64url");
|
||||||
|
const vip = await checkVip(newRecord.id);
|
||||||
const r = NextResponse.json({
|
const r = NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
user: { id: newRecord.id, email: newRecord.email ?? email },
|
user: { id: newRecord.id, email: newRecord.email ?? email, vip },
|
||||||
token: newToken,
|
token: newToken,
|
||||||
});
|
});
|
||||||
r.cookies.set(COOKIE_NAME, encoded, {
|
r.cookies.set(COOKIE_NAME, encoded, {
|
||||||
...(domain && { domain }),
|
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: COOKIE_MAX_AGE,
|
maxAge: COOKIE_MAX_AGE,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
@@ -50,9 +74,11 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
const uid = data.record?.id ?? userId;
|
||||||
|
const vip = uid ? await checkVip(uid) : false;
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
user: { id: data.record?.id ?? userId, email: data.record?.email ?? email },
|
user: { id: uid, email: data.record?.email ?? email, vip },
|
||||||
token: data.token,
|
token: data.token,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getAuthCookieDomain } from "@/config/domain.config";
|
import { COOKIE_NAME, getCookieOptions, getHostFromRequest } from "@/app/lib/auth-cookie";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
|
||||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
@@ -19,15 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
|
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
|
||||||
|
|
||||||
const domain = getAuthCookieDomain(request);
|
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(COOKIE_NAME, encoded, {
|
res.cookies.set(COOKIE_NAME, encoded, getCookieOptions(false, getHostFromRequest(request)) as Record<string, string | number | boolean>);
|
||||||
...(domain && { domain }),
|
|
||||||
path: "/",
|
|
||||||
maxAge: COOKIE_MAX_AGE,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: true,
|
|
||||||
});
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,26 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getPocketBaseConfig } from "@/config/services.config";
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
import { getAdminToken } from "@/app/lib/pocketbase/admin";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
import { COOKIE_NAME } from "@/app/lib/auth-cookie";
|
||||||
|
|
||||||
function getOrCreateUserId(request: NextRequest): string {
|
function getUserIdFromCookie(request: NextRequest): string | null {
|
||||||
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
if (cookie) {
|
if (!cookie) return null;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
|
const payload = JSON.parse(Buffer.from(cookie, "base64url").toString("utf-8"));
|
||||||
if (payload?.userId) return payload.userId;
|
return payload?.userId ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `user${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = getOrCreateUserId(request);
|
const userId = getUserIdFromCookie(request);
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ ok: false, error: "请先登录" }, { status: 401 });
|
||||||
|
}
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const formData = body && typeof body === "object" ? body : {};
|
const formData = body && typeof body === "object" ? body : {};
|
||||||
|
|
||||||
@@ -73,27 +59,55 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const pb = getPocketBaseConfig();
|
const pb = getPocketBaseConfig();
|
||||||
const collection = pb.joinCollection;
|
const collection = pb.joinCollection;
|
||||||
|
const base = pb.url.replace(/\/$/, "");
|
||||||
|
|
||||||
const doCreate = async (authToken?: string) => {
|
const doUpsert = async (authToken: string) => {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${authToken}`,
|
||||||
};
|
};
|
||||||
if (authToken) headers["Authorization"] = authToken;
|
|
||||||
|
|
||||||
return fetch(`${pb.url}/api/collections/${collection}/records`, {
|
const filter = `user_id="${userId}"`;
|
||||||
|
const listRes = await fetch(
|
||||||
|
`${base}/api/collections/${collection}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${authToken}` }, cache: "no-store" }
|
||||||
|
);
|
||||||
|
const listData = await listRes.json().catch(() => ({}));
|
||||||
|
const existing = (listData?.items ?? [])[0];
|
||||||
|
|
||||||
|
if (existing?.id) {
|
||||||
|
const patchBody: Record<string, string | number> = {
|
||||||
|
nickname: record.nickname,
|
||||||
|
occupation: record.occupation,
|
||||||
|
reason: record.reason,
|
||||||
|
single: record.single,
|
||||||
|
wechatId: record.wechatId,
|
||||||
|
gender: record.gender,
|
||||||
|
education: record.education,
|
||||||
|
gradYear: record.gradYear,
|
||||||
|
phone: record.phone,
|
||||||
|
};
|
||||||
|
if ("media" in record) patchBody.media = record.media as string;
|
||||||
|
return fetch(`${base}/api/collections/${collection}/records/${existing.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(patchBody),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fetch(`${base}/api/collections/${collection}/records`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(record),
|
body: JSON.stringify(record),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = await doCreate();
|
|
||||||
|
|
||||||
if (res.status === 403) {
|
|
||||||
const token = await getAdminToken();
|
const token = await getAdminToken();
|
||||||
if (token) res = await doCreate(token);
|
if (!token) {
|
||||||
|
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const res = await doUpsert(token);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
console.error("PocketBase create error:", res.status, errText);
|
console.error("PocketBase create error:", res.status, errText);
|
||||||
|
|||||||
62
app/api/join/status/route.ts
Normal file
62
app/api/join/status/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
import { getAdminToken } from "@/app/lib/pocketbase/admin";
|
||||||
|
|
||||||
|
import { COOKIE_NAME } from "@/app/lib/auth-cookie";
|
||||||
|
|
||||||
|
function getUserIdFromCookie(request: NextRequest): 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"));
|
||||||
|
return payload?.userId ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_FIELDS = ["nickname", "occupation", "reason", "single", "wechatId", "gender", "phone"];
|
||||||
|
|
||||||
|
function isRecordComplete(record: Record<string, unknown>): boolean {
|
||||||
|
return REQUIRED_FIELDS.every((key) => {
|
||||||
|
const v = record[key];
|
||||||
|
return v != null && String(v).trim() !== "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = getUserIdFromCookie(request);
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAdminToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pb = getPocketBaseConfig();
|
||||||
|
const base = pb.url.replace(/\/$/, "");
|
||||||
|
const filter = `user_id="${userId}"`;
|
||||||
|
const res = await fetch(
|
||||||
|
`${base}/api/collections/${pb.joinCollection}/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const record = (data?.items ?? [])[0];
|
||||||
|
if (!record) {
|
||||||
|
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isComplete = isRecordComplete(record as Record<string, unknown>);
|
||||||
|
return NextResponse.json({ ok: true, hasRecord: true, isComplete });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: true, hasRecord: false, isComplete: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/api/meetup/_paymentApi.ts
Normal file
81
app/api/meetup/_paymentApi.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { DEFAULT_PAYMENT_API_URL } from "@/config/domain.config";
|
||||||
|
import { getPaymentConfig } from "@/config/services.config";
|
||||||
|
|
||||||
|
function isPrivateHost(hostname: string): boolean {
|
||||||
|
if (!hostname) return false;
|
||||||
|
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const match = hostname.match(/^172\.(\d{1,2})\./);
|
||||||
|
if (!match) return false;
|
||||||
|
const secondOctet = Number(match[1]);
|
||||||
|
return secondOctet >= 16 && secondOctet <= 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url: string | undefined): string {
|
||||||
|
return String(url || "").trim().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestHostname(request: NextRequest): string {
|
||||||
|
const forwardedHost = request.headers.get("x-forwarded-host");
|
||||||
|
const host = forwardedHost || request.headers.get("host") || "";
|
||||||
|
return host.split(",")[0].trim().replace(/:\d+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeetupApiCandidates(request: NextRequest): string[] {
|
||||||
|
const configuredApiUrl = normalizeBaseUrl(getPaymentConfig().apiUrl);
|
||||||
|
const localApiUrl = normalizeBaseUrl(DEFAULT_PAYMENT_API_URL);
|
||||||
|
const hostname = getRequestHostname(request);
|
||||||
|
const shouldPreferLocal =
|
||||||
|
process.env.NODE_ENV === "development" || isPrivateHost(hostname);
|
||||||
|
|
||||||
|
const urls = shouldPreferLocal
|
||||||
|
? [localApiUrl, configuredApiUrl]
|
||||||
|
: [configuredApiUrl, localApiUrl];
|
||||||
|
|
||||||
|
return urls.filter((url, index) => url && urls.indexOf(url) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postMeetupApi(
|
||||||
|
request: NextRequest,
|
||||||
|
path: string,
|
||||||
|
body: unknown
|
||||||
|
): Promise<{ status: number; data: unknown }> {
|
||||||
|
const apiCandidates = getMeetupApiCandidates(request);
|
||||||
|
if (apiCandidates.length === 0) {
|
||||||
|
throw new Error("PAYMENT_API_URL is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastResponse: { status: number; data: unknown } | null = null;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (const apiUrl of apiCandidates) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (res.ok || (res.status !== 404 && res.status < 500)) {
|
||||||
|
return { status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastResponse = { status: res.status, data };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResponse) {
|
||||||
|
return lastResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("Meetup API request failed");
|
||||||
|
}
|
||||||
18
app/api/meetup/check-user/route.ts
Normal file
18
app/api/meetup/check-user/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { postMeetupApi } from "../_paymentApi";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const email = String(body?.email || "").trim();
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Please enter your email" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, data } = await postMeetupApi(request, "/api/meetup/check-user", { email });
|
||||||
|
return NextResponse.json(data, { status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("check-user error:", error);
|
||||||
|
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
203
app/api/meetup/complete-order/route.ts
Normal file
203
app/api/meetup/complete-order/route.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getPocketBaseConfig, getPaymentConfig, SITE_ID } from "@/config/services.config";
|
||||||
|
import { getAdminToken } from "@/app/lib/pocketbase/admin";
|
||||||
|
|
||||||
|
const DEFAULT_PASSWORD = "12345678";
|
||||||
|
|
||||||
|
function parseOrderId(orderId: string): { userId: string; payType: string } {
|
||||||
|
if (!orderId.includes("_order_")) return { userId: "", payType: "unknown" };
|
||||||
|
const prefix = orderId.split("_order_")[0];
|
||||||
|
if (prefix.includes("_")) {
|
||||||
|
const lastUnderscore = prefix.lastIndexOf("_");
|
||||||
|
return {
|
||||||
|
userId: prefix.slice(0, lastUnderscore),
|
||||||
|
payType: prefix.slice(lastUnderscore + 1).toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { userId: prefix, payType: "unknown" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePendingEmail(userId: string): string | null {
|
||||||
|
if (!userId.startsWith("pending_")) return null;
|
||||||
|
try {
|
||||||
|
return Buffer.from(userId.slice(8), "hex").toString("utf-8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryZpayPaid(orderId: string): Promise<boolean> {
|
||||||
|
const config = getPaymentConfig();
|
||||||
|
if (!config.apiUrl) {
|
||||||
|
console.error("queryZpayPaid: PAYMENT_API_URL not configured");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
const url = `${config.apiUrl}/cnomadcna/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`;
|
||||||
|
const res = await fetch(url, { cache: "no-store", signal: controller.signal })
|
||||||
|
.finally(() => clearTimeout(timeout));
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!data?.paid) {
|
||||||
|
console.warn(`queryZpayPaid: not paid yet, order_id=${orderId}, response=`, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
return !!data?.paid;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`queryZpayPaid: fetch error, order_id=${orderId}`, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSiteVip(
|
||||||
|
base: string,
|
||||||
|
adminToken: string,
|
||||||
|
userId: string,
|
||||||
|
orderId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const filter = `user_id = "${userId}" && site_id = "${SITE_ID}"`;
|
||||||
|
const checkRes = await fetch(
|
||||||
|
`${base}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (checkRes.ok) {
|
||||||
|
const checkData = await checkRes.json();
|
||||||
|
const existing = (checkData?.items ?? [])[0];
|
||||||
|
if (existing) {
|
||||||
|
await fetch(`${base}/api/collections/site_vip/records/${existing.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
|
||||||
|
body: JSON.stringify({ order_id: orderId, expires_at: "" }),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const createRes = await fetch(`${base}/api/collections/site_vip/records`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
|
||||||
|
body: JSON.stringify({ user_id: userId, site_id: SITE_ID, order_id: orderId, expires_at: "" }),
|
||||||
|
});
|
||||||
|
return createRes.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("ensureSiteVip error:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUserByEmail(base: string, adminToken: string, email: string): Promise<string | null> {
|
||||||
|
const createRes = await fetch(`${base}/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 data = await createRes.json();
|
||||||
|
return data?.id ?? null;
|
||||||
|
}
|
||||||
|
if (createRes.status === 400) {
|
||||||
|
const filter = `email="${email}"`;
|
||||||
|
const getRes = await fetch(
|
||||||
|
`${base}/api/collections/users/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (getRes.ok) {
|
||||||
|
const getData = await getRes.json();
|
||||||
|
return (getData?.items ?? [])[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const orderId = String(body?.order_id || "").trim();
|
||||||
|
if (!orderId) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Missing order_id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let { userId, payType } = parseOrderId(orderId);
|
||||||
|
if (!userId || payType !== "meetup") {
|
||||||
|
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminToken = await getAdminToken();
|
||||||
|
if (!adminToken) {
|
||||||
|
console.error("complete-order: getAdminToken failed, check POCKETBASE_EMAIL/PASSWORD");
|
||||||
|
return NextResponse.json({ ok: false, error: "服务配置错误" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pb = getPocketBaseConfig();
|
||||||
|
const base = pb.url.replace(/\/$/, "");
|
||||||
|
|
||||||
|
// 1. 先查 site_vip 是否已由异步通知创建
|
||||||
|
const filter = `order_id = "${orderId}"`;
|
||||||
|
const vipRes = await fetch(
|
||||||
|
`${base}/api/collections/site_vip/records?filter=${encodeURIComponent(filter)}&perPage=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${adminToken}` }, cache: "no-store" }
|
||||||
|
);
|
||||||
|
let found = false;
|
||||||
|
if (vipRes.ok) {
|
||||||
|
const vipData = await vipRes.json();
|
||||||
|
found = (vipData?.items ?? []).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 没找到 → 查 ZPAY 确认是否已支付,已支付则直接创建 site_vip
|
||||||
|
if (!found) {
|
||||||
|
const zpayPaid = await queryZpayPaid(orderId);
|
||||||
|
if (!zpayPaid) {
|
||||||
|
console.error(`complete-order: ZPAY 未确认支付 order_id=${orderId}`);
|
||||||
|
return NextResponse.json({ ok: false, error: "订单不存在或未支付成功" }, { status: 400 });
|
||||||
|
}
|
||||||
|
console.log(`complete-order: ZPAY 已确认支付,主动创建 site_vip order_id=${orderId}`);
|
||||||
|
|
||||||
|
// pending_ 新用户:先创建用户
|
||||||
|
let realUserId = userId;
|
||||||
|
if (userId.startsWith("pending_")) {
|
||||||
|
const email = decodePendingEmail(userId);
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const newId = await createUserByEmail(base, adminToken, email);
|
||||||
|
if (!newId) {
|
||||||
|
return NextResponse.json({ ok: false, error: "创建用户失败" }, { status: 500 });
|
||||||
|
}
|
||||||
|
realUserId = newId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await ensureSiteVip(base, adminToken, realUserId, orderId);
|
||||||
|
if (!created) {
|
||||||
|
return NextResponse.json({ ok: false, error: "VIP 开通失败" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. pending_ 新用户:登录并返回 token
|
||||||
|
if (userId.startsWith("pending_")) {
|
||||||
|
const email = decodePendingEmail(userId);
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json({ ok: false, error: "订单格式无效" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const loginRes = await fetch(`${base}/api/collections/users/auth-with-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ identity: email, password: DEFAULT_PASSWORD }),
|
||||||
|
});
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
return NextResponse.json({ ok: false, error: "登录失败,请稍后重试" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const authData = await loginRes.json();
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
token: authData.token,
|
||||||
|
record: authData.record,
|
||||||
|
is_new: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, token: null, record: null, is_new: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("complete-order error:", error);
|
||||||
|
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,110 +1,30 @@
|
|||||||
/**
|
|
||||||
* 加入游牧中国:确保用户存在
|
|
||||||
* 新用户:用默认密码 123456789 创建,支付成功后需修改
|
|
||||||
* 老用户:验证密码后登录
|
|
||||||
*/
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getPocketBaseConfig } from "@/config/services.config";
|
import { postMeetupApi } from "../_paymentApi";
|
||||||
|
|
||||||
const DEFAULT_PASSWORD = "123456789";
|
|
||||||
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const email = String(body?.email || "").trim();
|
const email = String(body?.email || "").trim();
|
||||||
const password = String(body?.password || "").trim();
|
const password = String(body?.password || "").trim();
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return NextResponse.json({ ok: false, error: "请输入邮箱" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "Please enter your email" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pb = getPocketBaseConfig();
|
const { status, data } = await postMeetupApi(request, "/api/meetup/ensure-user", {
|
||||||
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,
|
email,
|
||||||
password: DEFAULT_PASSWORD,
|
password: password || undefined,
|
||||||
passwordConfirm: DEFAULT_PASSWORD,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!createRes.ok) {
|
const payload = typeof data === "object" && data !== null ? data as Record<string, unknown> : null;
|
||||||
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 nextRes = NextResponse.json(data, { status });
|
||||||
const finalLogin = await tryLogin(DEFAULT_PASSWORD);
|
if (status >= 200 && status < 300 && payload?.is_new) {
|
||||||
if (!finalLogin.ok) {
|
nextRes.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
|
||||||
return NextResponse.json({ ok: false, error: "登录失败" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
const authData = await finalLogin.json();
|
return nextRes;
|
||||||
|
} catch (error) {
|
||||||
const res = NextResponse.json({
|
console.error("ensure-user error:", error);
|
||||||
ok: true,
|
return NextResponse.json({ ok: false, error: "Operation failed" }, { status: 500 });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/api/meetup/set-needs-password-change/route.ts
Normal file
9
app/api/meetup/set-needs-password-change/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const COOKIE_NEEDS_PW = "meetup_needs_password_change";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set(COOKIE_NEEDS_PW, "1", { path: "/", maxAge: 60 * 60 * 24 });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ export async function POST(request: NextRequest) {
|
|||||||
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(String(v))}" />`)
|
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(String(v))}" />`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const orderId = String((params as Record<string, unknown>)?.out_trade_no || "").trim();
|
const orderId = String((params as Record<string, unknown>)?.out_trade_no || "").trim();
|
||||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>跳转支付...</title></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(payUrl)}" method="POST">${inputs}</form><script>document.getElementById("f").submit();</script></body></html>`;
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{margin:0;background:#f0f4f8;opacity:0;}</style></head><body><form id="f" action="${escapeAttr(payUrl)}" method="POST">${inputs}</form><script>document.getElementById("f").submit();</script></body></html>`;
|
||||||
const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
const res = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||||||
if (orderId) {
|
if (orderId) {
|
||||||
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
|
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
|
import { getPocketBaseConfig, SITE_ID } from "@/config/services.config";
|
||||||
|
import { getAdminToken } from "@/app/lib/pocketbase/admin";
|
||||||
|
|
||||||
const COOKIE_NAME = "pb_session";
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useCallback, useEffect } 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 JoinModal from "./JoinModal";
|
import JoinModal from "./JoinModal";
|
||||||
|
import {
|
||||||
|
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"];
|
||||||
@@ -30,15 +37,144 @@ const features = [
|
|||||||
{ icon: "💬", key: "feature5" as const },
|
{ icon: "💬", key: "feature5" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function isPrivateDevHost(hostname: string): boolean {
|
||||||
|
if (!hostname) return false;
|
||||||
|
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const match = hostname.match(/^172\.(\d{1,2})\./);
|
||||||
|
if (!match) return false;
|
||||||
|
const secondOctet = Number(match[1]);
|
||||||
|
return secondOctet >= 16 && secondOctet <= 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPendingUserId(email: string): string {
|
||||||
|
return (
|
||||||
|
"pending_" +
|
||||||
|
Array.from(new TextEncoder().encode(email))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HeroSection() {
|
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 [joinModalOpen, setJoinModalOpen] = useState(false);
|
const [joinModalOpen, setJoinModalOpen] = useState(false);
|
||||||
|
const [joinModalVipOnly, setJoinModalVipOnly] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||||
|
const data = await res.json();
|
||||||
|
setIsLoggedIn(!!data?.user);
|
||||||
|
} catch {
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAuth();
|
||||||
|
const onAuth = () => checkAuth();
|
||||||
|
window.addEventListener("auth:updated", onAuth);
|
||||||
|
return () => window.removeEventListener("auth:updated", onAuth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCheckAndProceed = useCallback(
|
||||||
|
async (em: string) => {
|
||||||
|
if (checking) return;
|
||||||
|
const trimmed = em.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError(locale === "zh" ? "请输入邮箱" : "Please enter your email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setChecking(true);
|
||||||
|
try {
|
||||||
|
const checkRes = await fetch("/api/meetup/check-user", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: trimmed }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const checkData = await checkRes.json();
|
||||||
|
if (!checkRes.ok || !checkData?.ok) {
|
||||||
|
throw new Error(checkData?.error || checkData?.detail || (locale === "zh" ? "操作失败" : "Operation failed"));
|
||||||
|
}
|
||||||
|
if (checkData.exists) {
|
||||||
|
if (checkData.vip) {
|
||||||
|
setJoinModalOpen(true);
|
||||||
|
setJoinModalVipOnly(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJoinModalVipOnly(false);
|
||||||
|
setJoinModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 新用户:支付成功后才创建,用 pending_hex(email) 作为 user_id
|
||||||
|
const shouldPrecreateUser =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
isPrivateDevHost(window.location.hostname);
|
||||||
|
let user_id = buildPendingUserId(trimmed);
|
||||||
|
if (shouldPrecreateUser) {
|
||||||
|
const ensureRes = await fetch("/api/meetup/ensure-user", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: trimmed }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const ensureData = await ensureRes.json();
|
||||||
|
if (!ensureRes.ok || !ensureData?.ok || !ensureData?.user_id) {
|
||||||
|
throw new Error(
|
||||||
|
ensureData?.error ||
|
||||||
|
ensureData?.detail ||
|
||||||
|
(locale === "zh" ? "创建用户失败" : "Failed to create user")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
user_id = String(ensureData.user_id).trim();
|
||||||
|
if (ensureData?.token && ensureData?.record) {
|
||||||
|
await fetch("/api/auth/sync-session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: ensureData.token, record: ensureData.record }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
return_url: returnUrl,
|
||||||
|
channel,
|
||||||
|
device: getDeviceFromEnv(env),
|
||||||
|
useJoinConfig: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "操作失败");
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[checking, locale]
|
||||||
|
);
|
||||||
|
|
||||||
const handleJoinClick = (e: React.FormEvent) => {
|
const handleJoinClick = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setJoinModalOpen(true);
|
handleCheckAndProceed(email);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,29 +282,39 @@ export default function HeroSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isLoggedIn ? (
|
||||||
<form onSubmit={handleJoinClick} className="p-4 sm:p-5">
|
<form onSubmit={handleJoinClick} className="p-4 sm:p-5">
|
||||||
<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"
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 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={checking}
|
||||||
|
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")}
|
{checking ? (locale === "zh" ? "处理中…" : "Processing…") : tHero("ctaJoin")}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
|
|
||||||
{locale === "zh" ? "新用户将自动注册,支付成功后需修改默认密码" : "New users auto-register; change default password after payment"}
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 sm:p-5 text-center">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{locale === "zh" ? "您已登录,可前往社区参与互动" : "You are logged in"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<JoinModal
|
<JoinModal
|
||||||
isOpen={joinModalOpen}
|
isOpen={joinModalOpen}
|
||||||
onClose={() => setJoinModalOpen(false)}
|
onClose={() => { setJoinModalOpen(false); setJoinModalVipOnly(false); }}
|
||||||
initialEmail={email.trim()}
|
initialEmail={email.trim()}
|
||||||
|
vipOnly={joinModalVipOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ interface JoinModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
|
/** 仅登录(已有 VIP 用户验证密码后直接登录,不跳转支付) */
|
||||||
|
vipOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinModalProps) {
|
export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly = false }: JoinModalProps) {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const [email, setEmail] = useState(initialEmail);
|
const [email, setEmail] = useState(initialEmail);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -34,6 +36,7 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo
|
|||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (loading) return;
|
||||||
const em = email.trim();
|
const em = email.trim();
|
||||||
if (!em) {
|
if (!em) {
|
||||||
setError(locale === "zh" ? "请输入邮箱" : "Please enter your email");
|
setError(locale === "zh" ? "请输入邮箱" : "Please enter your email");
|
||||||
@@ -52,12 +55,21 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo
|
|||||||
if (!res.ok || !data?.ok) {
|
if (!res.ok || !data?.ok) {
|
||||||
throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed"));
|
throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed"));
|
||||||
}
|
}
|
||||||
|
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||||
|
pbSaveAuth(data.token, data.record);
|
||||||
await fetch("/api/auth/sync-session", {
|
await fetch("/api/auth/sync-session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ token: data.token, record: data.record }),
|
body: JSON.stringify({ token: data.token, record: data.record }),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||||
|
}
|
||||||
|
if (vipOnly) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const returnUrl = typeof window !== "undefined" ? `${window.location.origin}/${locale}/join/paid` : "/zh/join/paid";
|
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 env: PayEnv = typeof window !== "undefined" ? (navigator.userAgent.includes("MicroMessenger") ? "wechat" : "pc") : "pc";
|
||||||
const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
|
const channel: PayChannel = mustUseWxPay(env) ? "wxpay" : getRecommendedChannel(env);
|
||||||
@@ -96,11 +108,8 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
|
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
|
||||||
{locale === "zh" ? "加入游牧中国" : "Join Nomad China"}
|
{vipOnly ? (locale === "zh" ? "验证密码登录" : "Verify password to login") : (locale === "zh" ? "加入游牧中国" : "Join Nomad China")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{locale === "zh" ? "新用户将自动注册,老用户请输入密码" : "New users auto-register; existing users enter password"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -118,13 +127,13 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{locale === "zh" ? "密码(老用户必填)" : "Password (required for existing users)"}
|
{locale === "zh" ? "密码" : "Password"}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => { setPassword(e.target.value); setError(null); }}
|
onChange={(e) => { setPassword(e.target.value); setError(null); }}
|
||||||
placeholder={locale === "zh" ? "新用户可留空" : "Leave empty for new users"}
|
placeholder={locale === "zh" ? "请输入密码" : "Enter your password"}
|
||||||
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,13 +145,9 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "" }: JoinMo
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
|
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{loading ? (locale === "zh" ? "处理中…" : "Processing…") : (locale === "zh" ? "加入游牧中国 →" : "Join Nomad China →")}
|
{loading ? (locale === "zh" ? "处理中…" : "Processing…") : vipOnly ? (locale === "zh" ? "登录" : "Login") : (locale === "zh" ? "加入游牧中国 →" : "Join Nomad China →")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
|
|
||||||
{locale === "zh" ? "新用户将自动注册,支付成功后需修改默认密码" : "New users auto-register; change default password after payment"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getNavLinks } from "@/config";
|
|||||||
import AuthModal from "./AuthModal";
|
import AuthModal from "./AuthModal";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
import ThemeToggle from "./ThemeToggle";
|
||||||
import SiteMenu from "./SiteMenu";
|
import SiteMenu from "./SiteMenu";
|
||||||
|
import UserAvatar from "./UserAvatar";
|
||||||
|
|
||||||
export default function NavbarComponent() {
|
export default function NavbarComponent() {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
@@ -18,33 +19,42 @@ export default function NavbarComponent() {
|
|||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [authOpen, setAuthOpen] = useState(false);
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
|
const [userVip, setUserVip] = useState(false);
|
||||||
|
|
||||||
const navLinks = getNavLinks();
|
const navLinks = getNavLinks();
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchAuth = useCallback(async () => {
|
||||||
let mounted = true;
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
const res = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!mounted) return;
|
|
||||||
if (data?.user && data?.token) {
|
if (data?.user && data?.token) {
|
||||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||||
pbSaveAuth(data.token, { id: data.user.id, email: data.user.email });
|
pbSaveAuth(data.token, { id: data.user.id, email: data.user.email });
|
||||||
setUserEmail(data.user.email);
|
setUserEmail(data.user.email);
|
||||||
|
setUserVip(!!data.user.vip);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
if (mounted) setUserEmail(getStoredUserEmail());
|
setUserEmail(getStoredUserEmail());
|
||||||
})();
|
setUserVip(false);
|
||||||
return () => { mounted = false; };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAuth();
|
||||||
|
}, [fetchAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onAuthUpdated = () => fetchAuth();
|
||||||
|
window.addEventListener("auth:updated", onAuthUpdated);
|
||||||
|
return () => window.removeEventListener("auth:updated", onAuthUpdated);
|
||||||
|
}, [fetchAuth]);
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
await pbLogout();
|
await pbLogout();
|
||||||
setUserEmail(null);
|
setUserEmail(null);
|
||||||
|
setUserVip(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isActive = useCallback((href: string) => {
|
const isActive = useCallback((href: string) => {
|
||||||
@@ -114,12 +124,7 @@ export default function NavbarComponent() {
|
|||||||
<span className="hidden sm:block max-w-[120px] truncate text-sm text-gray-600 dark:text-gray-400">
|
<span className="hidden sm:block max-w-[120px] truncate text-sm text-gray-600 dark:text-gray-400">
|
||||||
{userEmail}
|
{userEmail}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<UserAvatar email={userEmail} isVip={userVip} onLogout={() => { setUserEmail(null); setUserVip(false); }} />
|
||||||
onClick={handleLogout}
|
|
||||||
className="rounded-full border border-gray-200 dark:border-gray-700 px-2 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{t("logout")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
@@ -182,10 +187,8 @@ export default function NavbarComponent() {
|
|||||||
</button>
|
</button>
|
||||||
{userEmail ? (
|
{userEmail ? (
|
||||||
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 py-2.5 rounded-lg bg-gray-50 dark:bg-gray-800">
|
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 py-2.5 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||||
|
<UserAvatar email={userEmail} isVip={userVip} onLogout={() => { handleLogout(); setMobileOpen(false); }} />
|
||||||
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
|
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
|
||||||
<button onClick={() => { handleLogout(); setMobileOpen(false); }} className="text-sm font-medium text-[#ff4d4f]">
|
|
||||||
{t("logout")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
66
app/components/UserAvatar.tsx
Normal file
66
app/components/UserAvatar.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useTranslation } from "@/i18n/navigation";
|
||||||
|
import { pbLogout } from "@/app/lib/pocketbase";
|
||||||
|
|
||||||
|
function getAvatarLetter(email: string): string {
|
||||||
|
const local = email.split("@")[0];
|
||||||
|
if (!local) return "?";
|
||||||
|
return local.slice(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
email: string;
|
||||||
|
isVip?: boolean;
|
||||||
|
onLogout?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 圆形头像 + VIP 标识 + 点击展开退出按钮 */
|
||||||
|
export default function UserAvatar({ email, isVip = false, onLogout }: UserAvatarProps) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await pbLogout();
|
||||||
|
setOpen(false);
|
||||||
|
onLogout?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#ff4d4f] text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-gray-900 hover:bg-[#ff7a45]"
|
||||||
|
aria-label={t("logout")}
|
||||||
|
>
|
||||||
|
{getAvatarLetter(email)}
|
||||||
|
</button>
|
||||||
|
{isVip && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white shadow-sm">
|
||||||
|
VIP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{t("logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss" source(none);
|
||||||
|
@source "./";
|
||||||
|
@source not "./favicon.ico";
|
||||||
|
@source "../config";
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
|||||||
33
app/lib/auth-cookie.ts
Normal file
33
app/lib/auth-cookie.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 认证 Cookie 配置
|
||||||
|
* 生产环境设置 AUTH_COOKIE_DOMAIN 实现跨子域 SSO(如 .hackrobot.cn)
|
||||||
|
* 本地开发不设置 domain,避免 IP 访问时 cookie 失效
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COOKIE_NAME = "pb_session";
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
export { COOKIE_NAME, COOKIE_MAX_AGE };
|
||||||
|
|
||||||
|
export function getHostFromRequest(request: { headers: { get: (k: string) => string | null } }): string {
|
||||||
|
const host = request.headers.get("host") || request.headers.get("x-forwarded-host") || "";
|
||||||
|
return host.split(",")[0].trim().replace(/:\d+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCookieOptions(clear = false, requestHost?: string) {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const domain = process.env.AUTH_COOKIE_DOMAIN?.trim();
|
||||||
|
const opts: Record<string, unknown> = {
|
||||||
|
path: "/",
|
||||||
|
maxAge: clear ? 0 : COOKIE_MAX_AGE,
|
||||||
|
secure: isProd,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
httpOnly: true,
|
||||||
|
};
|
||||||
|
if (domain && isProd) {
|
||||||
|
opts.domain = domain;
|
||||||
|
} else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) {
|
||||||
|
opts.domain = requestHost;
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
@@ -2,4 +2,4 @@ const isDev = process.env.NODE_ENV === "development";
|
|||||||
|
|
||||||
export const SITE_URL =
|
export const SITE_URL =
|
||||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||||
(isDev ? "http://localhost:3000" : "https://nomadcna.cn");
|
(isDev ? "http://localhost:3001" : "https://nomadcna.cn");
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export function getPayEnv(): PayEnv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getRecommendedChannel(env: PayEnv): PayChannel {
|
export function getRecommendedChannel(env: PayEnv): PayChannel {
|
||||||
if (env === "wechat") return "wxpay";
|
return "wxpay"; // 默认微信支付(PC/H5/微信内均优先)
|
||||||
return "alipay";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mustUseWxPay(env: PayEnv): boolean {
|
export function mustUseWxPay(env: PayEnv): boolean {
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export function usePayStatusPoll(onPaid: () => void): boolean {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data?.ok && data?.paid) {
|
if (data?.ok && data?.paid) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
clearCookie(COOKIE_NAME);
|
|
||||||
setPolling(false);
|
setPolling(false);
|
||||||
onPaidRef.current();
|
onPaidRef.current();
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/lib/pocketbase/admin.ts
Normal file
44
app/lib/pocketbase/admin.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { getPocketBaseConfig } from "@/config/services.config";
|
||||||
|
|
||||||
|
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
const TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase v0.23+ 使用 _superusers 集合替代旧 /api/admins 端点。
|
||||||
|
* 此函数依次尝试新旧两个端点,兼容所有版本。
|
||||||
|
*/
|
||||||
|
export async function getAdminToken(): Promise<string | null> {
|
||||||
|
if (cachedToken && Date.now() < cachedToken.expiresAt) {
|
||||||
|
return cachedToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = process.env.POCKETBASE_EMAIL;
|
||||||
|
const password = process.env.POCKETBASE_PASSWORD;
|
||||||
|
if (!email || !password) return null;
|
||||||
|
|
||||||
|
const pb = getPocketBaseConfig();
|
||||||
|
const base = pb.url.replace(/\/$/, "");
|
||||||
|
const body = JSON.stringify({ identity: email, password });
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
`${base}/api/collections/_superusers/auth-with-password`,
|
||||||
|
`${base}/api/admins/auth-with-password`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of endpoints) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: "POST", headers, body });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const data = await res.json();
|
||||||
|
const token = data?.token;
|
||||||
|
if (token) {
|
||||||
|
cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS };
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -73,10 +73,11 @@ export async function pbLogout(): Promise<void> {
|
|||||||
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 {
|
try {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:updated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoredUserEmail(): string | null {
|
export function getStoredUserEmail(): string | null {
|
||||||
|
|||||||
@@ -8,20 +8,9 @@ const isDev = process.env.NODE_ENV === "development";
|
|||||||
export const ROOT_DOMAIN =
|
export const ROOT_DOMAIN =
|
||||||
process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn";
|
process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn";
|
||||||
|
|
||||||
export const AUTH_COOKIE_DOMAIN =
|
/** 支付 API 默认地址(本地可用 PAYMENT_API_URL 覆盖) */
|
||||||
process.env.AUTH_COOKIE_DOMAIN || `.${ROOT_DOMAIN}`;
|
|
||||||
|
|
||||||
/** 根据请求 host 返回 Cookie domain。localhost 时不设 domain,生产环境用根域实现跨站 SSO */
|
|
||||||
export function getAuthCookieDomain(request: { headers: { get: (name: string) => string | null } }): string | undefined {
|
|
||||||
const envDomain = process.env.AUTH_COOKIE_DOMAIN;
|
|
||||||
if (envDomain) return envDomain;
|
|
||||||
const host = request.headers.get("host") ?? "";
|
|
||||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return undefined;
|
|
||||||
return AUTH_COOKIE_DOMAIN;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_PAYMENT_API_URL = isDev
|
export const DEFAULT_PAYMENT_API_URL = isDev
|
||||||
? "http://127.0.0.1:8700"
|
? "http://127.0.0.1:8007"
|
||||||
: `https://api.${ROOT_DOMAIN}`;
|
: `https://api.${ROOT_DOMAIN}`;
|
||||||
|
|
||||||
export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`;
|
export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`;
|
||||||
|
|||||||
BIN
dev-server.log
Normal file
BIN
dev-server.log
Normal file
Binary file not shown.
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
|
|||||||
"172.27.128.1:3000",
|
"172.27.128.1:3000",
|
||||||
"192.168.41.222",
|
"192.168.41.222",
|
||||||
"192.168.41.222:3000",
|
"192.168.41.222:3000",
|
||||||
|
"192.168.41.222:3001",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
"http://172.27.128.1:3000",
|
"http://172.27.128.1:3000",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -H 0.0.0.0",
|
"dev": "next dev -p 3001 -H 0.0.0.0",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
|
|||||||
Reference in New Issue
Block a user