203 lines
5.8 KiB
TypeScript
203 lines
5.8 KiB
TypeScript
"use client";
|
||
|
||
import { Copy, Loader2, QrCode } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useSession } from "next-auth/react";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
|
||
export function WechatPricingButton({
|
||
planId,
|
||
planName,
|
||
enabled,
|
||
}: {
|
||
planId: string;
|
||
planName: string;
|
||
enabled: boolean;
|
||
}) {
|
||
const { data: session } = useSession();
|
||
const router = useRouter();
|
||
|
||
const [isPending, setIsPending] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
const [outTradeNo, setOutTradeNo] = useState<string | null>(null);
|
||
const [qrCodeImageUrl, setQrCodeImageUrl] = useState<string | null>(null);
|
||
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||
const [statusText, setStatusText] = useState("请使用微信扫码完成支付");
|
||
|
||
const canPoll = useMemo(() => open && Boolean(outTradeNo), [open, outTradeNo]);
|
||
|
||
useEffect(() => {
|
||
if (!canPoll || !outTradeNo) {
|
||
return;
|
||
}
|
||
|
||
let timer: number | undefined;
|
||
let stopped = false;
|
||
|
||
const poll = async () => {
|
||
try {
|
||
const response = await fetch(`/api/billing/wechat/order-status?outTradeNo=${encodeURIComponent(outTradeNo)}`);
|
||
if (!response.ok) {
|
||
return;
|
||
}
|
||
|
||
const data = (await response.json()) as { status?: string };
|
||
if (stopped || !data.status) {
|
||
return;
|
||
}
|
||
|
||
if (data.status === "paid") {
|
||
setStatusText("支付成功,正在跳转...");
|
||
stopped = true;
|
||
setTimeout(() => {
|
||
router.push(`/lovart/pricing?checkout=success&plan=${encodeURIComponent(planId)}`);
|
||
}, 800);
|
||
return;
|
||
}
|
||
|
||
if (data.status !== "created" && data.status !== "processing") {
|
||
setStatusText(`当前状态:${data.status}`);
|
||
}
|
||
} catch {
|
||
// ignore polling failure
|
||
}
|
||
|
||
if (!stopped) {
|
||
timer = window.setTimeout(poll, 3000);
|
||
}
|
||
};
|
||
|
||
void poll();
|
||
|
||
return () => {
|
||
stopped = true;
|
||
if (timer) {
|
||
window.clearTimeout(timer);
|
||
}
|
||
};
|
||
}, [canPoll, outTradeNo, planId, router]);
|
||
|
||
async function handleCheckout() {
|
||
if (isPending) {
|
||
return;
|
||
}
|
||
|
||
if (!enabled) {
|
||
window.alert("微信支付尚未配置,请先配置微信支付环境变量。");
|
||
return;
|
||
}
|
||
|
||
if (!session?.user || session.user.type === "guest") {
|
||
router.push("/login?callbackUrl=/lovart/pricing");
|
||
return;
|
||
}
|
||
|
||
setIsPending(true);
|
||
|
||
try {
|
||
const response = await fetch("/api/billing/wechat/checkout", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ planId }),
|
||
});
|
||
|
||
const data = (await response.json()) as {
|
||
error?: string;
|
||
outTradeNo?: string;
|
||
qrCodeImageUrl?: string;
|
||
codeUrl?: string;
|
||
};
|
||
|
||
if (!response.ok || !data.outTradeNo || !data.qrCodeImageUrl || !data.codeUrl) {
|
||
window.alert(data.error || "创建微信支付订单失败,请稍后重试。");
|
||
return;
|
||
}
|
||
|
||
setOutTradeNo(data.outTradeNo);
|
||
setQrCodeImageUrl(data.qrCodeImageUrl);
|
||
setCodeUrl(data.codeUrl);
|
||
setStatusText("请使用微信扫码完成支付");
|
||
setOpen(true);
|
||
} catch (error) {
|
||
console.error("[WechatPricingButton] checkout error", error);
|
||
window.alert("创建微信支付订单失败,请稍后重试。");
|
||
} finally {
|
||
setIsPending(false);
|
||
}
|
||
}
|
||
|
||
async function handleCopyLink() {
|
||
if (!codeUrl) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(codeUrl);
|
||
window.alert("支付链接已复制");
|
||
} catch {
|
||
window.alert("复制失败,请手动复制");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
className={cn(
|
||
"inline-flex w-full items-center justify-center gap-2 rounded-xl border border-emerald-300/30 bg-emerald-500/10 px-4 py-2 text-sm font-semibold text-emerald-100 hover:border-emerald-300/60",
|
||
isPending && "cursor-not-allowed opacity-75"
|
||
)}
|
||
disabled={isPending}
|
||
onClick={handleCheckout}
|
||
type="button"
|
||
>
|
||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <QrCode className="h-4 w-4" />}
|
||
微信支付 {planName}
|
||
</button>
|
||
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogContent className="max-w-sm border-white/10 bg-[#0a1120] text-zinc-100">
|
||
<DialogHeader>
|
||
<DialogTitle>微信扫码支付</DialogTitle>
|
||
<DialogDescription className="text-zinc-300">{statusText}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-3">
|
||
{qrCodeImageUrl ? (
|
||
<div className="mx-auto w-fit rounded-xl bg-white p-2">
|
||
<img alt="wechat pay qrcode" className="h-56 w-56" src={qrCodeImageUrl} />
|
||
</div>
|
||
) : (
|
||
<div className="mx-auto flex h-56 w-56 items-center justify-center rounded-xl border border-dashed border-white/20 text-sm text-zinc-400">
|
||
正在生成二维码...
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-300">
|
||
<p>订单号:{outTradeNo || "-"}</p>
|
||
</div>
|
||
|
||
<button
|
||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-white/20 px-3 py-2 text-xs text-zinc-100 hover:border-white/40"
|
||
onClick={handleCopyLink}
|
||
type="button"
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
复制支付链接
|
||
</button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|