nac-presale/client/src/components/WechatPayment.tsx

354 lines
15 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.

/**
* 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 15 minutes after confirmation</p>
</div>
</div>
);
}