's'
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { usePayStatusPoll } from "@/app/lib/usePayStatusPoll";
|
||||
|
||||
const COOKIE_NAME = "nomadvip_pay_order";
|
||||
const DEFAULT_PASSWORD = "12345678";
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
@@ -11,6 +12,10 @@ function getCookie(name: string): string | null {
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function isAnonymousUserId(value: string | null | undefined): value is string {
|
||||
return !!value && /^user\d+$/.test(value);
|
||||
}
|
||||
|
||||
function parseUserIdFromOrderId(orderId: string): string {
|
||||
if (!orderId?.includes("_order_")) return "";
|
||||
const prefix = orderId.split("_order_")[0];
|
||||
@@ -18,72 +23,248 @@ function parseUserIdFromOrderId(orderId: string): string {
|
||||
return parts.length >= 2 ? parts.slice(0, -1).join("_") : "";
|
||||
}
|
||||
|
||||
function SuccessWithRedirect() {
|
||||
function getOrderIdFromPage(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromUrl =
|
||||
params.get("order_id") || params.get("out_trade_no") || params.get("orderId");
|
||||
if (fromUrl?.trim()) return fromUrl.trim();
|
||||
const raw = getCookie(COOKIE_NAME);
|
||||
if (!raw?.trim()) return null;
|
||||
return raw.split("|")[0]?.trim() || null;
|
||||
}
|
||||
|
||||
function persistAnonymousVip(userId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem("userId", userId);
|
||||
localStorage.setItem("memosAccount", userId);
|
||||
localStorage.setItem("paidType", "vip");
|
||||
if (localStorage.getItem("emailLinked") !== "1" && !localStorage.getItem("userEmail")) {
|
||||
localStorage.setItem("userName", userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmOrder(orderId: string, userId: string): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch("/api/pay/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ order_id: orderId, user_id: userId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.ok) return true;
|
||||
} catch (error) {
|
||||
console.error("pay/confirm request error:", error);
|
||||
}
|
||||
|
||||
if (attempt < 2) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function SuccessPanel({ anonymousUserId }: { anonymousUserId: string | null }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (!anonymousUserId || !isAnonymousUserId(anonymousUserId)) {
|
||||
const timer = window.setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [anonymousUserId]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!normalizedEmail || !anonymousUserId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/link-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: normalizedEmail,
|
||||
password: DEFAULT_PASSWORD,
|
||||
anonymousUserId,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok || !data?.ok || !data?.token || !data?.record?.id) {
|
||||
setError(data?.error || "绑定邮箱失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||
pbSaveAuth(data.token, data.record);
|
||||
|
||||
await fetch("/api/auth/sync-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: data.token,
|
||||
record: data.record,
|
||||
anonymousUserId,
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
localStorage.setItem("memosAccount", anonymousUserId);
|
||||
localStorage.setItem("userId", data.record.id);
|
||||
localStorage.setItem("userEmail", data.record.email || normalizedEmail);
|
||||
localStorage.setItem("userName", data.record.email || normalizedEmail);
|
||||
localStorage.setItem("emailLinked", "1");
|
||||
localStorage.setItem("paidType", "vip");
|
||||
|
||||
window.location.href = "/";
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
} catch (submitError) {
|
||||
console.error("link-email failed:", submitError);
|
||||
setError("绑定邮箱失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg" style={{ animationFillMode: "both" }}>
|
||||
<div
|
||||
className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg"
|
||||
style={{ animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 text-5xl">
|
||||
✅
|
||||
✓
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">支付成功</h2>
|
||||
<p className="mt-3 text-slate-500">感谢您的支持,VIP 权益已开通</p>
|
||||
<p className="mt-2 text-xs text-slate-400">2 秒后自动返回首页</p>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(e) => { e.preventDefault(); window.location.href = "/"; }}
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
← 立即返回首页
|
||||
</a>
|
||||
<p className="mt-3 text-slate-500">
|
||||
最后一步,绑定邮箱后再进入 VIP。后续可以在多个站点共用同一账号。
|
||||
</p>
|
||||
{isAnonymousUserId(anonymousUserId) ? (
|
||||
<form onSubmit={handleSubmit} className="mt-6 text-left">
|
||||
<p className="mb-2 text-sm text-slate-600">
|
||||
默认密码为 {DEFAULT_PASSWORD}。绑定后会把当前匿名会员迁移到统一邮箱账号。
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mb-3 w-full rounded-lg border border-slate-300 px-4 py-2 text-slate-800"
|
||||
/>
|
||||
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-sky-500 px-6 py-2 font-semibold text-white hover:bg-sky-600 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "处理中..." : "绑定邮箱并进入 VIP"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-2 text-xs text-slate-400">正在返回首页</p>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
返回首页
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaidPage() {
|
||||
const [status, setStatus] = useState<"pending" | "success" | "fail">("pending");
|
||||
const [fromUrl, setFromUrl] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
"pending" | "success" | "fail" | "confirm_fail"
|
||||
>("pending");
|
||||
const [confirmedUserId, setConfirmedUserId] = useState<string | null>(null);
|
||||
const orderIdFromUrlOrCookie = getOrderIdFromPage();
|
||||
|
||||
usePayStatusPoll((orderId) => {
|
||||
const uid = parseUserIdFromOrderId(orderId || "");
|
||||
const raw = getCookie(COOKIE_NAME);
|
||||
const oid = raw?.split("|")[0]?.trim() || orderId || "";
|
||||
const userId = uid || parseUserIdFromOrderId(oid);
|
||||
if (userId) {
|
||||
try {
|
||||
localStorage.setItem("paidType", "vip");
|
||||
localStorage.setItem("userName", userId);
|
||||
} catch {}
|
||||
}
|
||||
setStatus("success");
|
||||
}, () => { window.location.href = "/"; });
|
||||
usePayStatusPoll(
|
||||
async (orderId) => {
|
||||
const finalOrderId = (orderId || orderIdFromUrlOrCookie || "").trim();
|
||||
const candidateUserId =
|
||||
parseUserIdFromOrderId(finalOrderId) || localStorage.getItem("userId");
|
||||
|
||||
if (!finalOrderId || !candidateUserId) {
|
||||
setStatus("confirm_fail");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmOrder(finalOrderId, candidateUserId);
|
||||
if (confirmed) {
|
||||
persistAnonymousVip(candidateUserId);
|
||||
setConfirmedUserId(candidateUserId);
|
||||
}
|
||||
setStatus(confirmed ? "success" : "confirm_fail");
|
||||
},
|
||||
() => {
|
||||
window.location.href = "/";
|
||||
},
|
||||
orderIdFromUrlOrCookie
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paid = params.get("paid");
|
||||
const fail = params.get("fail");
|
||||
const from = params.get("from");
|
||||
if (paid === "1") setStatus("success");
|
||||
else if (fail === "1") setStatus("fail");
|
||||
if (from) setFromUrl(decodeURIComponent(from));
|
||||
if (params.get("fail") === "1") {
|
||||
setStatus("fail");
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.get("paid") !== "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = getOrderIdFromPage();
|
||||
const candidateUserId =
|
||||
(orderId ? parseUserIdFromOrderId(orderId) : "") || localStorage.getItem("userId");
|
||||
|
||||
if (!orderId || !candidateUserId) {
|
||||
setStatus("confirm_fail");
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmOrder(orderId, candidateUserId);
|
||||
if (confirmed) {
|
||||
persistAnonymousVip(candidateUserId);
|
||||
setConfirmedUserId(candidateUserId);
|
||||
}
|
||||
setStatus(confirmed ? "success" : "confirm_fail");
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="animate__animated animate__fadeInUp w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg" style={{ animationFillMode: "both" }}>
|
||||
<div
|
||||
className="animate__animated animate__fadeInUp w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg"
|
||||
style={{ animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 animate-spin items-center justify-center rounded-full border-4 border-sky-200 border-t-sky-500" />
|
||||
<h2 className="text-xl font-bold text-slate-800">支付结果确认中</h2>
|
||||
<h2 className="text-xl font-bold text-slate-800">正在确认支付结果</h2>
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
正在查询支付状态,请稍候…
|
||||
正在写入会员记录,请稍候。
|
||||
<br />
|
||||
(支付完成后将自动跳转,1 分钟内未支付将返回首页)
|
||||
支付完成后会继续进入邮箱绑定。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,25 +272,63 @@ export default function PaidPage() {
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
const anonymousUserId =
|
||||
confirmedUserId ||
|
||||
(typeof window !== "undefined" ? localStorage.getItem("userId") : null);
|
||||
return <SuccessPanel anonymousUserId={anonymousUserId} />;
|
||||
}
|
||||
|
||||
if (status === "confirm_fail") {
|
||||
return (
|
||||
<SuccessWithRedirect />
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div
|
||||
className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg"
|
||||
style={{ animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-amber-50 text-5xl">
|
||||
!
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">支付已确认,权益开通中</h2>
|
||||
<p className="mt-3 text-slate-500">
|
||||
订单已支付,但会员写入可能有延迟。请稍后刷新首页,或联系管理员处理。
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4">
|
||||
<div className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg" style={{ animationFillMode: "both" }}>
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-amber-50 text-5xl">
|
||||
⚠️
|
||||
<div
|
||||
className="animate__animated animate__zoomIn w-full max-w-md rounded-2xl bg-white p-10 text-center shadow-lg"
|
||||
style={{ animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-rose-50 text-5xl">
|
||||
!
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">支付未完成</h2>
|
||||
<p className="mt-3 text-slate-500">支付已取消或未成功,请重试</p>
|
||||
<p className="mt-3 text-slate-500">
|
||||
当前没有确认到成功支付记录。你可以返回首页重新发起支付。
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(e) => { e.preventDefault(); window.location.href = "/"; }}
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full border-2 border-slate-300 px-8 py-3 font-semibold text-slate-700 transition-colors hover:border-slate-400 hover:bg-slate-50"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-sky-500 px-8 py-3 font-semibold text-white transition-colors hover:bg-sky-600"
|
||||
>
|
||||
← 返回首页
|
||||
返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user