Files
gitlab-instance-0a899031_di…/app/lib/payment/usePayStatusPoll.ts
2026-03-13 05:51:05 -05:00

180 lines
5.1 KiB
TypeScript
Raw 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.
import { useEffect, useRef, useState } from "react";
const COOKIE_NAME = "join_pay_order";
const STORAGE_NAME = "join_pay_order";
const POLL_INTERVAL_MS = 1200;
const MAX_POLLS = 60;
const MAX_AGE_MS = 15 * 60 * 1000;
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 clearCookie(name: string): void {
if (typeof document === "undefined") return;
document.cookie = `${name}=; path=/; max-age=0`;
}
function getStorage(name: string): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(name);
}
function removeStorage(name: string): void {
if (typeof window === "undefined") return;
window.localStorage.removeItem(name);
}
function clearPendingOrder(): void {
clearCookie(COOKIE_NAME);
removeStorage(STORAGE_NAME);
}
function persistPendingOrder(raw: string): void {
if (typeof document !== "undefined") {
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(raw)}; path=/; max-age=900`;
}
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(STORAGE_NAME, raw);
} catch {}
}
}
function parsePendingOrder(raw: string | null): [string | null, boolean] {
if (!raw?.trim()) return [null, false];
const parts = raw.split("|");
const orderId = parts[0]?.trim() || raw.trim();
const ts = parts[1] ? parseInt(parts[1], 10) : NaN;
if (!orderId) return [null, false];
if (Number.isNaN(ts)) return [orderId, false];
if (Date.now() - ts > MAX_AGE_MS) return [orderId, false];
return [orderId, true];
}
export function usePayStatusPoll(onPaid: (orderId: string) => void): boolean {
const [polling, setPolling] = useState(false);
const onPaidRef = useRef(onPaid);
onPaidRef.current = onPaid;
useEffect(() => {
const urlOrderId =
typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("order_id") || ""
: "";
const cookieRaw = getCookie(COOKIE_NAME);
const [cookieOrderId, cookieValid] = parsePendingOrder(cookieRaw);
const storageRaw = getStorage(STORAGE_NAME);
const [storageOrderId, storageValid] = parsePendingOrder(storageRaw);
let orderId = cookieValid ? cookieOrderId : storageValid ? storageOrderId : null;
let activeRaw = cookieValid ? cookieRaw : storageValid ? storageRaw : null;
if (!orderId && urlOrderId.trim()) {
orderId = urlOrderId.trim();
activeRaw = `${orderId}|${Date.now()}`;
persistPendingOrder(activeRaw);
}
if (!orderId) {
if (cookieRaw || storageRaw) {
clearPendingOrder();
}
return;
}
if (activeRaw) {
persistPendingOrder(activeRaw);
}
setPolling(true);
let attempts = 0;
let stopped = false;
let timer: number | undefined;
let inFlight = false;
const stop = () => {
stopped = true;
if (timer) window.clearTimeout(timer);
};
const scheduleNext = () => {
if (stopped) return;
timer = window.setTimeout(tick, POLL_INTERVAL_MS);
};
const tick = async () => {
if (stopped) return;
if (inFlight) {
scheduleNext();
return;
}
attempts += 1;
if (attempts > MAX_POLLS) {
stop();
clearPendingOrder();
setPolling(false);
return;
}
inFlight = true;
try {
const res = await fetch(
`/api/pay/status?order_id=${encodeURIComponent(orderId)}`,
{ cache: "no-store" }
);
const data = await res.json().catch(() => ({}));
if (data?.ok && data?.paid) {
stop();
clearPendingOrder();
setPolling(false);
// 对齐 nomadvip调用 confirm 写入 PocketBase site_vip不依赖 payjsapi 异步 notify
try {
const confirmRes = await fetch("/api/pay/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
cache: "no-store",
});
if (confirmRes.ok && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("vip:updated"));
}
} catch {
/* 忽略,不影响跳转 */
}
onPaidRef.current(orderId);
return;
}
} catch {
// keep polling
} finally {
inFlight = false;
}
scheduleNext();
};
const handleResume = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
return;
}
void tick();
};
document.addEventListener("visibilitychange", handleResume);
window.addEventListener("pageshow", handleResume);
window.addEventListener("focus", handleResume);
void tick();
return () => {
document.removeEventListener("visibilitychange", handleResume);
window.removeEventListener("pageshow", handleResume);
window.removeEventListener("focus", handleResume);
stop();
};
}, []);
return polling;
}