Files
gitlab-instance-0a899031_cn…/app/[locale]/join/paid/page.tsx
2026-03-13 05:51:29 -05:00

314 lines
12 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.
"use client";
import { useState, useEffect } from "react";
import { Link } from "@/i18n/navigation";
const DEFAULT_PASSWORD = "12345678";
function clearPendingOrder(): void {
try {
localStorage.removeItem("join_pay_order");
} catch {}
document.cookie = "join_pay_order=; path=/; max-age=0";
}
/**
* 支付完成回跳页
* Z-Pay 的 return_url 不支持带查询参数,故使用 /join/paid 作为专用成功页
* 新用户支付成功后弹窗显示默认密码 12345678并提供修改密码选项
*/
export default function JoinPaidPage() {
const [needPasswordChange, setNeedPasswordChange] = useState<boolean | null>(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [newPassword, setNewPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [changeError, setChangeError] = useState<string | null>(null);
const [changing, setChanging] = useState(false);
const [showChangeForm, setShowChangeForm] = useState(false);
const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending");
const [errorDetail, setErrorDetail] = useState<string | null>(null);
const [countdown, setCountdown] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string }> => {
const r = await fetch("/api/meetup/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 { ok: false, error: d?.error || `HTTP ${r.status}` };
if (d?.token && d?.record) {
await fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, record: d.record }),
credentials: "include",
});
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(d.token, d.record);
}
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
setTimeout(() => {
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
}, 400);
if (d.is_new) {
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
if (!cancelled) {
setNeedPasswordChange(true);
setShowPasswordModal(true);
}
} else {
const needRes = await fetch("/api/meetup/need-password-change", { credentials: "include" });
const needData = await needRes.json().catch(() => ({}));
if (!cancelled && needData?.need === true) {
setNeedPasswordChange(true);
setShowPasswordModal(true);
}
}
return { ok: true };
};
const run = async () => {
const urlOrderId = typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("order_id") || ""
: "";
const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order="));
const rawValue = cookie?.split("=")[1]?.trim();
const storageValue = (() => {
try {
return localStorage.getItem("join_pay_order") || "";
} catch {
return "";
}
})();
const decoded = rawValue ? decodeURIComponent(rawValue) : storageValue;
const orderId = urlOrderId || decoded.split("|")[0]?.trim() || "";
if (!orderId) {
clearPendingOrder();
window.location.replace(`/${window.location.pathname.split("/")[1] || "zh"}/join`);
return;
}
const maxClientRetries = 5;
const retryDelays = [2000, 3000, 4000, 5000, 6000];
let lastError = "";
for (let i = 0; i <= maxClientRetries; i++) {
if (cancelled) return;
try {
const result = await tryComplete(orderId);
if (result.ok) {
clearPendingOrder();
if (!cancelled) setCompleteStatus("success");
return;
}
lastError = result.error || "unknown";
} catch (e) {
lastError = e instanceof Error ? e.message : "network error";
}
if (i < maxClientRetries) {
await new Promise((r) => setTimeout(r, retryDelays[i]));
}
}
if (!cancelled) {
setCompleteStatus("error");
setErrorDetail(lastError);
}
fetch("/api/meetup/need-password-change")
.then((res) => res.json())
.then((d) => {
if (cancelled) return;
setNeedPasswordChange(d?.need === true);
if (d?.need === true) setShowPasswordModal(true);
})
.catch(() => { if (!cancelled) setNeedPasswordChange(false); });
};
run();
return () => { cancelled = true; };
}, []);
const COUNTDOWN_SECONDS = 5;
useEffect(() => {
if (completeStatus !== "success" || showPasswordModal) return;
setCountdown(COUNTDOWN_SECONDS);
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev == null || prev <= 1) {
clearInterval(timer);
const locale = window.location.pathname.split("/")[1] || "zh";
window.location.replace(`/${locale}`);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [completeStatus, showPasswordModal]);
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setChangeError(null);
if (newPassword.length < 8) {
setChangeError("密码至少 8 位");
return;
}
if (newPassword !== passwordConfirm) {
setChangeError("两次密码不一致");
return;
}
setChanging(true);
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newPassword, passwordConfirm }),
credentials: "include",
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "修改失败");
}
setShowPasswordModal(false);
} catch (err) {
setChangeError(err instanceof Error ? err.message : "修改失败");
} finally {
setChanging(false);
}
};
const handleCopyPassword = async () => {
try {
await navigator.clipboard.writeText(DEFAULT_PASSWORD);
} catch {
/* ignore */
}
};
return (
<>
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-gray-900 p-10 text-center shadow-lg border border-gray-100 dark:border-gray-800">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
</div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{completeStatus === "pending" ? "处理中…" : completeStatus === "success" ? "支付成功" : "处理异常"}
</h2>
<p className="mt-3 text-slate-500 dark:text-slate-400">
{completeStatus === "success"
? countdown != null
? `${countdown} 秒后返回首页`
: "感谢您的支持"
: completeStatus === "error"
? "请稍后刷新页面重试"
: "正在确认支付状态…"}
</p>
{completeStatus === "error" && errorDetail && (
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">{errorDetail}</p>
)}
{completeStatus === "error" && (
<button
type="button"
onClick={() => window.location.reload()}
className="mt-4 rounded-full border border-[#ff4d4f] px-6 py-2.5 text-sm font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10 transition-colors"
>
</button>
)}
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
</Link>
</div>
</div>
{showPasswordModal && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white dark:bg-gray-900 p-6 shadow-xl border border-gray-100 dark:border-gray-800">
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-100"></h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
</p>
<div className="mt-4 flex items-center gap-2 rounded-lg bg-slate-100 dark:bg-slate-800 px-4 py-3">
<code className="flex-1 font-mono text-lg font-semibold text-slate-800 dark:text-slate-100">
{DEFAULT_PASSWORD}
</code>
<button
type="button"
onClick={handleCopyPassword}
className="rounded-lg px-3 py-1.5 text-sm font-medium text-[#ff4d4f] hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
>
</button>
</div>
{!showChangeForm ? (
<button
type="button"
onClick={() => setShowChangeForm(true)}
className="mt-4 w-full rounded-lg border border-[#ff4d4f] py-2.5 text-sm font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10 transition-colors"
>
</button>
) : (
<form onSubmit={handleChangePassword} className="mt-4 space-y-3">
<input
type="password"
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setChangeError(null); }}
placeholder="新密码(至少 8 位)"
minLength={8}
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800"
/>
<input
type="password"
value={passwordConfirm}
onChange={(e) => { setPasswordConfirm(e.target.value); setChangeError(null); }}
placeholder="确认新密码"
minLength={8}
required
className="w-full rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-2.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-gray-800"
/>
{changeError && <p className="text-sm text-red-600 dark:text-red-400">{changeError}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowChangeForm(false)}
className="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 py-2.5 text-sm font-medium text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800"
>
</button>
<button
type="submit"
disabled={changing}
className="flex-1 rounded-lg bg-[#ff4d4f] py-2.5 font-semibold text-white disabled:opacity-70"
>
{changing ? "提交中…" : "确认修改"}
</button>
</div>
</form>
)}
<button
type="button"
onClick={() => setShowPasswordModal(false)}
className="mt-3 w-full text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
>
</button>
</div>
</div>
)}
</>
);
}