'use client'; import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import QRCode from 'qrcode'; interface PaymentQRCodeProps { orderId: string; token?: string; payUrl?: string | null; qrCode?: string | null; clientSecret?: string | null; stripePublishableKey?: string | null; paymentType?: 'alipay' | 'wxpay' | 'stripe'; amount: number; payAmount?: number; expiresAt: string; onStatusChange: (status: string) => void; onBack: () => void; dark?: boolean; isEmbedded?: boolean; isMobile?: boolean; } const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6'; const TEXT_REMAINING = '\u5269\u4F59\u652F\u4ED8\u65F6\u95F4'; const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8'; const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8'; const TEXT_BACK = '\u8FD4\u56DE'; const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355'; const TEXT_H5_HINT = '\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4'; const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']); export default function PaymentQRCode({ orderId, token, payUrl, qrCode, clientSecret, stripePublishableKey, paymentType, amount, payAmount: payAmountProp, expiresAt, onStatusChange, onBack, dark = false, isEmbedded = false, isMobile = false, }: PaymentQRCodeProps) { const displayAmount = payAmountProp ?? amount; const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount; const [timeLeft, setTimeLeft] = useState(''); const [expired, setExpired] = useState(false); const [qrDataUrl, setQrDataUrl] = useState(''); const [imageLoading, setImageLoading] = useState(false); const [cancelBlocked, setCancelBlocked] = useState(false); // Stripe Payment Element state const [stripeLoaded, setStripeLoaded] = useState(false); const [stripeSubmitting, setStripeSubmitting] = useState(false); const [stripeError, setStripeError] = useState(''); const [stripeSuccess, setStripeSuccess] = useState(false); const [stripeLib, setStripeLib] = useState<{ stripe: import('@stripe/stripe-js').Stripe; elements: import('@stripe/stripe-js').StripeElements; } | null>(null); // Track selected payment method in Payment Element (for embedded popup decision) const [stripePaymentMethod, setStripePaymentMethod] = useState('card'); const [popupBlocked, setPopupBlocked] = useState(false); const paymentMethodListenerAdded = useRef(false); const qrPayload = useMemo(() => { const value = (qrCode || payUrl || '').trim(); return value; }, [qrCode, payUrl]); useEffect(() => { let cancelled = false; if (!qrPayload) { setQrDataUrl(''); return; } setImageLoading(true); QRCode.toDataURL(qrPayload, { width: 224, margin: 1, errorCorrectionLevel: 'M', }) .then((url) => { if (!cancelled) { setQrDataUrl(url); } }) .catch(() => { if (!cancelled) { setQrDataUrl(''); } }) .finally(() => { if (!cancelled) { setImageLoading(false); } }); return () => { cancelled = true; }; }, [qrPayload]); // Initialize Stripe Payment Element const isStripe = paymentType === 'stripe'; useEffect(() => { if (!isStripe || !clientSecret || !stripePublishableKey) return; let cancelled = false; import('@stripe/stripe-js').then(({ loadStripe }) => { loadStripe(stripePublishableKey).then((stripe) => { if (cancelled) return; if (!stripe) { setStripeError('支付组件加载失败,请刷新页面重试'); setStripeLoaded(true); return; } const elements = stripe.elements({ clientSecret, appearance: { theme: dark ? 'night' : 'stripe', variables: { borderRadius: '8px', }, }, }); setStripeLib({ stripe, elements }); setStripeLoaded(true); }); }); return () => { cancelled = true; }; }, [isStripe, clientSecret, stripePublishableKey, dark]); // Mount Payment Element when container is available const stripeContainerRef = useCallback( (node: HTMLDivElement | null) => { if (!node || !stripeLib) return; let pe = stripeLib.elements.getElement('payment'); if (pe) { pe.mount(node); } else { pe = stripeLib.elements.create('payment', { layout: 'tabs' }); pe.mount(node); } if (!paymentMethodListenerAdded.current) { paymentMethodListenerAdded.current = true; pe.on('change', (event: { value?: { type?: string } }) => { if (event.value?.type) { setStripePaymentMethod(event.value.type); } }); } }, [stripeLib], ); const handleStripeSubmit = async () => { if (!stripeLib || stripeSubmitting) return; // In embedded mode, Alipay redirects to a page with X-Frame-Options that breaks iframe if (isEmbedded && stripePaymentMethod === 'alipay') { handleOpenPopup(); return; } setStripeSubmitting(true); setStripeError(''); const { stripe, elements } = stripeLib; const returnUrl = new URL(window.location.href); returnUrl.pathname = '/pay/result'; returnUrl.search = ''; returnUrl.searchParams.set('order_id', orderId); returnUrl.searchParams.set('status', 'success'); const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl.toString(), }, redirect: 'if_required', }); if (error) { setStripeError(error.message || '支付失败,请重试'); setStripeSubmitting(false); } else { // Payment succeeded (or no redirect needed) setStripeSuccess(true); setStripeSubmitting(false); // Polling will pick up the status change } }; const handleOpenPopup = () => { if (!clientSecret || !stripePublishableKey) return; setPopupBlocked(false); // Only pass display params in URL — sensitive data sent via postMessage const popupUrl = new URL(window.location.href); popupUrl.pathname = '/pay/stripe-popup'; popupUrl.search = ''; popupUrl.searchParams.set('order_id', orderId); popupUrl.searchParams.set('amount', String(amount)); popupUrl.searchParams.set('theme', dark ? 'dark' : 'light'); popupUrl.searchParams.set('method', stripePaymentMethod); const popup = window.open( popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes', ); if (!popup || popup.closed) { setPopupBlocked(true); return; } // Send sensitive data via postMessage after popup loads const onReady = (event: MessageEvent) => { if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return; window.removeEventListener('message', onReady); popup.postMessage({ type: 'STRIPE_POPUP_INIT', clientSecret, publishableKey: stripePublishableKey, }, window.location.origin); }; window.addEventListener('message', onReady); }; useEffect(() => { const updateTimer = () => { const now = Date.now(); const expiry = new Date(expiresAt).getTime(); const diff = expiry - now; if (diff <= 0) { setTimeLeft(TEXT_EXPIRED); setExpired(true); return; } const minutes = Math.floor(diff / 60000); const seconds = Math.floor((diff % 60000) / 1000); setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`); }; updateTimer(); const timer = setInterval(updateTimer, 1000); return () => clearInterval(timer); }, [expiresAt]); const pollStatus = useCallback(async () => { try { const res = await fetch(`/api/orders/${orderId}`); if (res.ok) { const data = await res.json(); if (TERMINAL_STATUSES.has(data.status)) { onStatusChange(data.status); } } } catch { // ignore polling errors } }, [orderId, onStatusChange]); useEffect(() => { if (expired) return; pollStatus(); const timer = setInterval(pollStatus, 2000); return () => clearInterval(timer); }, [pollStatus, expired]); const handleCancel = async () => { if (!token) return; try { // 先检查当前订单状态 const res = await fetch(`/api/orders/${orderId}`); if (!res.ok) return; const data = await res.json(); if (TERMINAL_STATUSES.has(data.status)) { onStatusChange(data.status); return; } const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); if (cancelRes.ok) { const cancelData = await cancelRes.json(); if (cancelData.status === 'PAID') { setCancelBlocked(true); return; } onStatusChange('CANCELLED'); } else { await pollStatus(); } } catch { // ignore } }; const isWx = paymentType === 'wxpay'; const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg'; const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D'; const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]'; if (cancelBlocked) { return (
{'\u2713'}

{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}

{'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'}

); } return (
{'\u00A5'}{displayAmount.toFixed(2)}
{hasFeeDiff && (
到账 ¥{amount.toFixed(2)}
)}
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
{!expired && ( <> {isStripe ? (
{!clientSecret || !stripePublishableKey ? (

支付初始化失败,请返回重试

) : !stripeLoaded ? (
正在加载支付表单...
) : stripeError && !stripeLib ? (
{stripeError}
) : ( <>
{stripeError && (
{stripeError}
)} {stripeSuccess ? (
{'\u2713'}

支付成功,正在处理订单...

) : ( )} {popupBlocked && (
弹出窗口被浏览器拦截,请允许本站弹出窗口后重试
)} )}
) : isMobile && payUrl ? ( <> {channelLabel} {`打开${channelLabel}支付`}

{TEXT_H5_HINT}

) : ( <> {qrDataUrl && (
{imageLoading && (
)} payment qrcode
{channelLabel}
)} {!qrDataUrl && payUrl && ( {TEXT_GO_PAY} )} {!qrDataUrl && !payUrl && (

{TEXT_SCAN_PAY}

)}

{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}

)} )}
{!expired && token && ( )}
); }