This commit is contained in:
eric
2026-03-13 03:43:50 -05:00
parent e5d080c202
commit 857380aa01
29 changed files with 3595 additions and 580 deletions

View File

@@ -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>