Files
2026-03-13 03:43:50 -05:00

337 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}