337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
"use client";
|
||
|
||
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;
|
||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||
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];
|
||
const parts = prefix.split("_");
|
||
return parts.length >= 2 ? parts.slice(0, -1).join("_") : "";
|
||
}
|
||
|
||
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(() => {
|
||
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 = "/";
|
||
} 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="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>
|
||
{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" | "confirm_fail"
|
||
>("pending");
|
||
const [confirmedUserId, setConfirmedUserId] = useState<string | null>(null);
|
||
const orderIdFromUrlOrCookie = getOrderIdFromPage();
|
||
|
||
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);
|
||
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="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>
|
||
<p className="mt-3 text-sm text-slate-500">
|
||
正在写入会员记录,请稍候。
|
||
<br />
|
||
支付完成后会继续进入邮箱绑定。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (status === "success") {
|
||
const anonymousUserId =
|
||
confirmedUserId ||
|
||
(typeof window !== "undefined" ? localStorage.getItem("userId") : null);
|
||
return <SuccessPanel anonymousUserId={anonymousUserId} />;
|
||
}
|
||
|
||
if (status === "confirm_fail") {
|
||
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>
|
||
<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-rose-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>
|
||
);
|
||
}
|