354 lines
15 KiB
TypeScript
354 lines
15 KiB
TypeScript
/**
|
||
* WechatPayment Component
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* Handles WeChat Pay Native (QR code) for PC and H5 for mobile browsers.
|
||
*
|
||
* Flow:
|
||
* 1. User enters CNY amount
|
||
* 2. Component calls payment.createWechatOrder mutation
|
||
* 3. PC (NATIVE): displays QR code for user to scan with WeChat
|
||
* Mobile (H5): redirects to WeChat H5 payment page
|
||
* 4. Polls payment.queryWechatOrder every 5 seconds
|
||
* 5. On success (dbStatus === "distributed"), shows success message
|
||
*
|
||
* Note: JSAPI pay (inside WeChat browser) requires openid — not implemented here.
|
||
* For WeChat browser users, the H5 pay type is used as fallback.
|
||
*/
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { trpc } from "@/lib/trpc";
|
||
import { toast } from "sonner";
|
||
|
||
const XIC_PRICE_USD = 0.02;
|
||
const CNY_USD_RATE = 0.138;
|
||
|
||
function calcXicFromCny(cny: number): number {
|
||
const usd = cny * CNY_USD_RATE;
|
||
return Math.floor(usd / XIC_PRICE_USD);
|
||
}
|
||
|
||
// Simple QR code display using a public QR API (no external dependency needed)
|
||
// In production, use a proper QR library like qrcode.react
|
||
function QRCodeDisplay({ url, size = 200 }: { url: string; size?: number }) {
|
||
// Use Google Charts QR API as fallback (works without npm package)
|
||
// TODO: Replace with qrcode.react for production (npm install qrcode.react)
|
||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(url)}&bgcolor=0a0a0f&color=f0b429&margin=10`;
|
||
return (
|
||
<div className="flex flex-col items-center gap-2">
|
||
<div
|
||
className="rounded-xl overflow-hidden"
|
||
style={{ border: "2px solid rgba(240,180,41,0.4)", padding: "8px", background: "#0a0a0f" }}
|
||
>
|
||
<img
|
||
src={qrUrl}
|
||
alt="WeChat Pay QR Code"
|
||
width={size}
|
||
height={size}
|
||
style={{ display: "block" }}
|
||
onError={(e) => {
|
||
// Fallback: show text URL if image fails
|
||
(e.target as HTMLImageElement).style.display = "none";
|
||
}}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-center" style={{ color: "rgba(255,255,255,0.4)" }}>
|
||
Scan with WeChat to pay
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface WechatPaymentProps {
|
||
xicReceiveAddress: string;
|
||
onSuccess?: (xicAmount: number, orderId: string) => void;
|
||
}
|
||
|
||
export default function WechatPayment({ xicReceiveAddress, onSuccess }: WechatPaymentProps) {
|
||
const [cnyInput, setCnyInput] = useState("100");
|
||
const [orderId, setOrderId] = useState<string | null>(null);
|
||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
|
||
const [h5Url, setH5Url] = useState<string | null>(null);
|
||
const [paymentStatus, setPaymentStatus] = useState<"idle" | "waiting" | "success" | "failed">("idle");
|
||
|
||
const cnyAmount = parseFloat(cnyInput) || 0;
|
||
const xicAmount = calcXicFromCny(cnyAmount);
|
||
const fenAmount = Math.round(cnyAmount * 100); // convert to fen (integer)
|
||
|
||
const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
|
||
const isWechat = /MicroMessenger/i.test(navigator.userAgent);
|
||
const payType = isWechat ? "JSAPI" : isMobile ? "H5" : "NATIVE";
|
||
|
||
const createOrder = trpc.payment.createWechatOrder.useMutation({
|
||
onSuccess: (data) => {
|
||
setOrderId(data.orderId);
|
||
setPaymentStatus("waiting");
|
||
|
||
if (data.qrCodeUrl) {
|
||
setQrCodeUrl(data.qrCodeUrl);
|
||
} else if (data.h5Url) {
|
||
setH5Url(data.h5Url);
|
||
window.location.href = data.h5Url;
|
||
} else if (data.jsapiParams) {
|
||
// JSAPI: call WeixinJSBridge (inside WeChat browser)
|
||
invokeWechatJsapi(data.jsapiParams);
|
||
}
|
||
},
|
||
onError: (err) => {
|
||
toast.error(`Failed to create WeChat Pay order: ${err.message}`);
|
||
},
|
||
});
|
||
|
||
const queryOrder = trpc.payment.queryWechatOrder.useQuery(
|
||
{ orderId: orderId! },
|
||
{
|
||
enabled: !!orderId && paymentStatus === "waiting",
|
||
refetchInterval: 5000,
|
||
refetchIntervalInBackground: true,
|
||
}
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!queryOrder.data) return;
|
||
const { dbStatus, xicAmount: dbXicAmount } = queryOrder.data as any;
|
||
if (dbStatus === "distributed" || dbStatus === "paid") {
|
||
setPaymentStatus("success");
|
||
onSuccess?.(parseFloat(dbXicAmount || "0"), orderId!);
|
||
toast.success(`Payment confirmed! ${parseFloat(dbXicAmount || "0").toLocaleString()} XIC tokens will be distributed.`);
|
||
} else if (dbStatus === "failed" || dbStatus === "expired") {
|
||
setPaymentStatus("failed");
|
||
toast.error("Payment failed or expired. Please try again.");
|
||
}
|
||
}, [queryOrder.data]);
|
||
|
||
function invokeWechatJsapi(params: any) {
|
||
const wx = (window as any).WeixinJSBridge;
|
||
if (!wx) {
|
||
toast.error("WeChat browser not detected. Please open this page in WeChat.");
|
||
return;
|
||
}
|
||
wx.invoke("getBrandWCPayRequest", {
|
||
appId: params.appId,
|
||
timeStamp: params.timeStamp,
|
||
nonceStr: params.nonceStr,
|
||
package: params.package,
|
||
signType: params.signType,
|
||
paySign: params.paySign,
|
||
}, (res: any) => {
|
||
if (res.err_msg === "get_brand_wcpay_request:ok") {
|
||
setPaymentStatus("success");
|
||
toast.success("Payment successful!");
|
||
} else if (res.err_msg === "get_brand_wcpay_request:cancel") {
|
||
toast.info("Payment cancelled.");
|
||
setPaymentStatus("idle");
|
||
} else {
|
||
toast.error("Payment failed. Please try again.");
|
||
setPaymentStatus("failed");
|
||
}
|
||
});
|
||
}
|
||
|
||
const handlePay = () => {
|
||
if (!xicReceiveAddress || xicReceiveAddress.length < 10) {
|
||
toast.error("Please enter your XIC receive address first.");
|
||
return;
|
||
}
|
||
if (cnyAmount < 0.01) {
|
||
toast.error("Minimum payment is ¥0.01 CNY.");
|
||
return;
|
||
}
|
||
createOrder.mutate({
|
||
totalFen: fenAmount,
|
||
xicReceiveAddress,
|
||
payType,
|
||
});
|
||
};
|
||
|
||
const handleReset = () => {
|
||
setOrderId(null);
|
||
setQrCodeUrl(null);
|
||
setH5Url(null);
|
||
setPaymentStatus("idle");
|
||
setCnyInput("100");
|
||
};
|
||
|
||
// ── Success State ──────────────────────────────────────────────────────────
|
||
if (paymentStatus === "success") {
|
||
return (
|
||
<div className="space-y-4 text-center py-6">
|
||
<div className="text-5xl mb-3">🎉</div>
|
||
<h3 className="text-xl font-bold" style={{ color: "#00e676", fontFamily: "'Space Grotesk', sans-serif" }}>
|
||
Payment Successful!
|
||
</h3>
|
||
<p style={{ color: "rgba(255,255,255,0.7)" }}>
|
||
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>
|
||
{xicAmount.toLocaleString()}
|
||
</span>{" "}
|
||
XIC tokens are being distributed to your address.
|
||
</p>
|
||
<button onClick={handleReset} className="px-6 py-2 rounded-lg text-sm font-semibold transition-all"
|
||
style={{ background: "rgba(0,212,255,0.15)", border: "1px solid rgba(0,212,255,0.4)", color: "#00d4ff" }}>
|
||
Make Another Purchase
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── QR Code Waiting State ──────────────────────────────────────────────────
|
||
if (paymentStatus === "waiting" && qrCodeUrl) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="rounded-xl p-4 text-center" style={{ background: "rgba(7,193,96,0.08)", border: "1px solid rgba(7,193,96,0.3)" }}>
|
||
<p className="font-semibold mb-3" style={{ color: "#07c160" }}>
|
||
Scan QR Code with WeChat
|
||
</p>
|
||
<div className="flex justify-center mb-3">
|
||
<QRCodeDisplay url={qrCodeUrl} size={180} />
|
||
</div>
|
||
<p className="text-sm mb-1" style={{ color: "rgba(255,255,255,0.6)" }}>
|
||
Amount: <strong style={{ color: "#f0b429" }}>¥{cnyAmount.toFixed(2)} CNY</strong>
|
||
</p>
|
||
<p className="text-sm" style={{ color: "rgba(255,255,255,0.6)" }}>
|
||
You will receive: <strong style={{ color: "#f0b429" }}>{xicAmount.toLocaleString()} XIC</strong>
|
||
</p>
|
||
</div>
|
||
<div className="text-center">
|
||
<p className="text-xs mb-2" style={{ color: "rgba(255,255,255,0.4)" }}>
|
||
Checking payment status every 5 seconds...
|
||
</p>
|
||
<p className="text-xs" style={{ color: "rgba(255,255,255,0.3)" }}>
|
||
Order ID: <span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{orderId}</span>
|
||
</p>
|
||
</div>
|
||
<button onClick={handleReset} className="w-full py-2 rounded-lg text-sm transition-all"
|
||
style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.4)" }}>
|
||
Cancel / Start Over
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── H5 Waiting State ───────────────────────────────────────────────────────
|
||
if (paymentStatus === "waiting" && !qrCodeUrl) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="rounded-xl p-4 text-center" style={{ background: "rgba(7,193,96,0.08)", border: "1px solid rgba(7,193,96,0.3)" }}>
|
||
<div className="text-3xl mb-2">⏳</div>
|
||
<p className="font-semibold mb-1" style={{ color: "#07c160" }}>Redirected to WeChat Pay</p>
|
||
<p className="text-sm" style={{ color: "rgba(255,255,255,0.6)" }}>
|
||
Complete the payment in WeChat, then return here.
|
||
</p>
|
||
<p className="text-xs mt-2" style={{ color: "rgba(255,255,255,0.3)" }}>
|
||
Order ID: <span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{orderId}</span>
|
||
</p>
|
||
</div>
|
||
{h5Url && (
|
||
<button onClick={() => window.location.href = h5Url!}
|
||
className="w-full py-2 rounded-lg text-sm font-semibold transition-all"
|
||
style={{ background: "rgba(7,193,96,0.15)", border: "1px solid rgba(7,193,96,0.4)", color: "#07c160" }}>
|
||
Reopen WeChat Pay
|
||
</button>
|
||
)}
|
||
<button onClick={handleReset} className="w-full py-2 rounded-lg text-sm transition-all"
|
||
style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.4)" }}>
|
||
Cancel / Start Over
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Input State ────────────────────────────────────────────────────────────
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Amount Input */}
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium" style={{ color: "rgba(255,255,255,0.7)" }}>
|
||
Payment Amount (CNY ¥)
|
||
</label>
|
||
<div className="relative">
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm font-bold" style={{ color: "#07c160" }}>¥</span>
|
||
<input
|
||
type="number"
|
||
value={cnyInput}
|
||
onChange={(e) => setCnyInput(e.target.value)}
|
||
min="0.01"
|
||
step="1"
|
||
className="w-full pl-8 pr-4 py-3 rounded-xl text-white font-mono focus:outline-none"
|
||
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(7,193,96,0.3)", fontSize: "1rem" }}
|
||
placeholder="100"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{[100, 500, 1000, 5000].map((amt) => (
|
||
<button key={amt} onClick={() => setCnyInput(String(amt))}
|
||
className="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-all"
|
||
style={{
|
||
background: cnyAmount === amt ? "rgba(7,193,96,0.2)" : "rgba(255,255,255,0.05)",
|
||
border: `1px solid ${cnyAmount === amt ? "rgba(7,193,96,0.5)" : "rgba(255,255,255,0.1)"}`,
|
||
color: cnyAmount === amt ? "#07c160" : "rgba(255,255,255,0.5)",
|
||
}}>
|
||
¥{amt}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Conversion Preview */}
|
||
{cnyAmount > 0 && (
|
||
<div className="rounded-xl p-3 space-y-1" style={{ background: "rgba(7,193,96,0.05)", border: "1px solid rgba(7,193,96,0.15)" }}>
|
||
<div className="flex justify-between text-sm">
|
||
<span style={{ color: "rgba(255,255,255,0.5)" }}>CNY Amount</span>
|
||
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>¥{cnyAmount.toFixed(2)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span style={{ color: "rgba(255,255,255,0.5)" }}>≈ USD</span>
|
||
<span style={{ color: "rgba(255,255,255,0.8)", fontFamily: "'JetBrains Mono', monospace" }}>${(cnyAmount * CNY_USD_RATE).toFixed(2)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm font-semibold">
|
||
<span style={{ color: "rgba(255,255,255,0.7)" }}>XIC Tokens</span>
|
||
<span style={{ color: "#f0b429", fontFamily: "'JetBrains Mono', monospace" }}>{xicAmount.toLocaleString()} XIC</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Pay Button */}
|
||
<button
|
||
onClick={handlePay}
|
||
disabled={createOrder.isPending || cnyAmount < 0.01 || !xicReceiveAddress}
|
||
className="w-full py-3 rounded-xl text-base font-bold transition-all"
|
||
style={{
|
||
background: createOrder.isPending ? "rgba(255,255,255,0.1)" : "linear-gradient(135deg, #07c160 0%, #059a4a 100%)",
|
||
color: "white",
|
||
cursor: createOrder.isPending ? "not-allowed" : "pointer",
|
||
fontFamily: "'Space Grotesk', sans-serif",
|
||
}}
|
||
>
|
||
{createOrder.isPending ? (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeDasharray="30 70" />
|
||
</svg>
|
||
Creating Order...
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center justify-center gap-2">
|
||
{/* WeChat icon */}
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="12" fill="#07c160"/>
|
||
<path d="M8 10.5C8 8.015 10.015 6 12.5 6C14.985 6 17 8.015 17 10.5C17 12.985 14.985 15 12.5 15C11.89 15 11.31 14.87 10.79 14.64L8.5 15.5L9.14 13.5C8.43 12.73 8 11.67 8 10.5Z" fill="white"/>
|
||
</svg>
|
||
Pay with WeChat Pay
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Info */}
|
||
<div className="rounded-lg p-3 text-xs"
|
||
style={{ background: "rgba(7,193,96,0.08)", border: "1px solid rgba(7,193,96,0.2)", color: "rgba(255,255,255,0.5)" }}>
|
||
<p>• PC: Scan QR code with WeChat app</p>
|
||
<p>• Mobile: Redirects to WeChat H5 payment</p>
|
||
<p>• XIC tokens distributed within 1–5 minutes after confirmation</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|