This commit is contained in:
eric
2026-03-16 20:08:21 -05:00
parent e30d4ad950
commit c93eb7b2ee
11 changed files with 314 additions and 211 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useRef, type FormEvent } from "react";
import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling";
import Link from "next/link";
import ThemeToggle from "../components/ThemeToggle";
import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config";
@@ -50,13 +51,6 @@ function getPendingOrderId(): string | null {
}
}
function clearPendingOrder(): void {
try {
localStorage.removeItem(ORDER_COOKIE);
document.cookie = `${ORDER_COOKIE}=; path=/; max-age=0`;
} catch {}
}
interface UserRecord {
hasRecord: boolean;
record: {
@@ -165,6 +159,7 @@ export default function JoinPage() {
const initialWechatCheckDone = useRef(false);
const lastSubmitTime = useRef(0);
const submittingRef = useRef(false);
const fetchByWechatRef = useRef<(w: string) => Promise<unknown>>(() => Promise.resolve(null));
const SUBMIT_THROTTLE_MS = 3000;
const fetchByWechat = async (w: string) => {
@@ -186,6 +181,7 @@ export default function JoinPage() {
setCheckingWechat(false);
}
};
fetchByWechatRef.current = fetchByWechat;
const handleWechatBlur = () => {
const w = wechat.trim();
@@ -435,14 +431,54 @@ export default function JoinPage() {
const params = new URLSearchParams(window.location.search);
setFromPaid(params.get("paid") === "1");
setFromVolunteer(params.get("volunteer") === "1");
const pendingId = getPendingOrderId();
const stored = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
// 有 pending 时先查 by-wechat若已支付(vip/amount)则直接展示报名成功,不跳转 /join/paid
if (pendingId && !params.has("paid") && stored && WECHAT_REGEX.test(stored)) {
setWechat(stored);
let cancelled = false;
fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(stored)}`, { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
if (cancelled) return;
const isPaid = data?.vip || data?.isComplete || (data?.record && Number(data.record?.amount || 0) > 0);
if (isPaid) {
setWechat(stored);
setUserRecord(toUserRecord(data));
if (!initialWechatCheckDone.current) {
initialWechatCheckDone.current = true;
}
setHydrated(true);
return;
}
window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
})
.catch(() => {
if (!cancelled) window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
});
setHydrated(true);
return () => {
cancelled = true;
};
}
if (pendingId && !params.has("paid")) {
try {
window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`);
return;
} catch {
/* ignore */
}
}
if (!initialWechatCheckDone.current) {
initialWechatCheckDone.current = true;
try {
const stored = localStorage.getItem(WECHAT_STORAGE_KEY);
const w = (stored || "").trim();
if (w && WECHAT_REGEX.test(w)) {
setWechat(w);
fetchByWechat(w);
if (stored && WECHAT_REGEX.test(stored)) {
setWechat(stored);
fetchByWechat(stored);
}
} catch {
/* ignore */
@@ -451,65 +487,42 @@ export default function JoinPage() {
setHydrated(true);
}, []);
// 手机浏览器支付后若回到 /join有 pending order 时主动轮询 complete-order触发后端更新 solanRedZPAY notify 可能未到)
useEffect(() => {
if (typeof window === "undefined") return;
const orderId = getPendingOrderId();
if (!orderId) return;
const shouldComplete =
userRecord?.hasRecord &&
userRecord?.isWechatFriend &&
!userRecord?.vip &&
!userRecord?.isComplete;
if (!shouldComplete) return;
let cancelled = false;
const tryComplete = async (): Promise<boolean> => {
const r = await fetch("/api/salon/complete-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
credentials: "include",
});
const d = await r.json();
if (!d?.ok) return false;
if (d?.token && d?.record) {
fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, record: d.record }),
credentials: "include",
}).catch(() => {});
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(d.token, d.record));
}
clearPendingOrder();
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
return true;
};
const run = async () => {
const retryDelays = [1500, 2500, 3500, 4500, 5500];
for (let i = 0; i <= retryDelays.length; i++) {
if (cancelled) return;
if (await tryComplete()) {
if (wechat.trim()) fetchByWechat(wechat.trim());
return;
}
if (i < retryDelays.length && !cancelled) {
await new Promise((r) => setTimeout(r, retryDelays[i]));
}
const refreshOnReturn = () => {
try {
const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w);
} catch {
/* ignore */
}
};
void run();
return () => { cancelled = true; };
}, [
userRecord?.hasRecord,
userRecord?.isWechatFriend,
userRecord?.vip,
userRecord?.isComplete,
wechat,
]);
const onVisibilityChange = () => {
if (document.visibilityState === "visible") refreshOnReturn();
};
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) refreshOnReturn();
};
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("pageshow", onPageShow);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("pageshow", onPageShow);
};
}, []);
const [completeOrderId] = useState<string | null>(() =>
typeof window !== "undefined" ? getPendingOrderId() : null
);
useCompleteOrderPolling({
orderId: completeOrderId,
onSuccess: () => {
const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim();
if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w);
},
retryDelays: [500, 1000, 1500, 2500, 4000],
});
// 微信内/手机浏览器支付后:页面可能被关闭或跳转,返回时 notify 可能未到。轮询 fetchByWechat 检测支付成功
// 手机浏览器唤醒微信 app 支付完成后,用户可能直接回 /join故 h5 也需轮询(参考 nomadvip
@@ -629,6 +642,13 @@ export default function JoinPage() {
if (showPayPage) {
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950">
{paying && (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white/95 dark:bg-stone-950/95 backdrop-blur-sm">
<span className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-amber-500 border-t-transparent" />
<p className="mt-4 text-base font-medium text-stone-700 dark:text-stone-300"></p>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400"></p>
</div>
)}
<header className="sticky top-0 z-40 w-full border-b border-stone-200/80 dark:border-stone-800 bg-[#faf8f5]/95 dark:bg-stone-950/95 backdrop-blur">
<div className="max-w-2xl mx-auto px-4 h-12 sm:h-14 flex items-center justify-between">
<Link href="/" className="text-sm font-medium text-stone-600 dark:text-stone-400"> </Link>