'use client'; import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import QRCode from 'qrcode'; import type { Locale } from '@/lib/locale'; import type { PublicOrderStatusSnapshot } from '@/lib/order/status'; import { isStripeType, getPaymentMeta, getPaymentIconSrc, getPaymentChannelLabel } from '@/lib/pay-utils'; import { buildOrderStatusUrl } from '@/lib/order/status-url'; import { TERMINAL_STATUSES } from '@/lib/constants'; interface PaymentQRCodeProps { orderId: string; token?: string; payUrl?: string | null; qrCode?: string | null; clientSecret?: string | null; stripePublishableKey?: string | null; paymentType?: string; amount: number; payAmount?: number; expiresAt: string; statusAccessToken?: string; onStatusChange: (status: PublicOrderStatusSnapshot) => void; onBack: () => void; dark?: boolean; isEmbedded?: boolean; isMobile?: boolean; locale?: Locale; } function isVisibleOrderOutcome(data: PublicOrderStatusSnapshot): boolean { return data.paymentSuccess || TERMINAL_STATUSES.has(data.status); } export default function PaymentQRCode({ orderId, token, payUrl, qrCode, clientSecret, stripePublishableKey, paymentType, amount, payAmount: payAmountProp, expiresAt, statusAccessToken, onStatusChange, onBack, dark = false, isEmbedded = false, isMobile = false, locale = 'zh', }: PaymentQRCodeProps) { const displayAmount = payAmountProp ?? amount; const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount; const [timeLeft, setTimeLeft] = useState(''); const [timeLeftSeconds, setTimeLeftSeconds] = useState(Infinity); const [expired, setExpired] = useState(false); const [qrDataUrl, setQrDataUrl] = useState(''); const [imageLoading, setImageLoading] = useState(false); const [cancelBlocked, setCancelBlocked] = useState(false); const [redirected, setRedirected] = useState(false); 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); const [stripePaymentMethod, setStripePaymentMethod] = useState('card'); const [popupBlocked, setPopupBlocked] = useState(false); const paymentMethodListenerAdded = useRef(false); const t = { expired: locale === 'en' ? 'Order Expired' : '订单已超时', remaining: locale === 'en' ? 'Time Remaining' : '剩余支付时间', scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付', back: locale === 'en' ? 'Back' : '返回', cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单', h5Hint: locale === 'en' ? 'After payment, please return to this page. The system will confirm automatically.' : '支付完成后请返回此页面,系统将自动确认', paid: locale === 'en' ? 'Order Paid' : '订单已支付', paidCancelBlocked: locale === 'en' ? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.' : '该订单已支付完成,无法取消。充值将自动到账。', backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值', credited: locale === 'en' ? 'Credited ¥' : '到账 ¥', stripeLoadFailed: locale === 'en' ? 'Failed to load payment component. Please refresh and try again.' : '支付组件加载失败,请刷新页面重试', initFailed: locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试', loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...', payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试', successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...', processing: locale === 'en' ? 'Processing...' : '处理中...', payNow: locale === 'en' ? 'Pay' : '支付', popupBlocked: locale === 'en' ? 'Popup was blocked by your browser. Please allow popups for this site and try again.' : '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试', redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到', redirectingSuffix: locale === 'en' ? '...' : '...', redirectRetryHint: locale === 'en' ? 'If the payment app does not open automatically, go back and try again.' : '如未自动拉起支付应用,请返回上一页后重新发起支付。', notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往', goPaySuffix: locale === 'en' ? '' : '', gotoPrefix: locale === 'en' ? 'Open ' : '前往', gotoSuffix: locale === 'en' ? ' to pay' : '支付', openScanPrefix: locale === 'en' ? 'Open ' : '请打开', openScanSuffix: locale === 'en' ? ' and scan to complete payment' : '扫一扫完成支付', }; const shouldAutoRedirect = !expired && !isStripeType(paymentType) && !!payUrl && (isMobile || !qrCode); useEffect(() => { if (!shouldAutoRedirect || redirected) return; setRedirected(true); if (isEmbedded) { window.open(payUrl!, '_blank'); } else { window.location.replace(payUrl!); } }, [shouldAutoRedirect, redirected, payUrl, isEmbedded]); const qrPayload = useMemo(() => { return (qrCode || '').trim(); }, [qrCode]); 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]); const isStripe = isStripeType(paymentType); 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(t.stripeLoadFailed); 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, t.stripeLoadFailed]); 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; 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'); if (statusAccessToken) { returnUrl.searchParams.set('access_token', statusAccessToken); } if (locale === 'en') { returnUrl.searchParams.set('lang', 'en'); } const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl.toString(), }, redirect: 'if_required', }); if (error) { setStripeError(error.message || t.payFailed); setStripeSubmitting(false); } else { setStripeSuccess(true); setStripeSubmitting(false); } }; const handleOpenPopup = () => { if (!clientSecret || !stripePublishableKey) return; setPopupBlocked(false); 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); if (statusAccessToken) { popupUrl.searchParams.set('access_token', statusAccessToken); } if (locale === 'en') { popupUrl.searchParams.set('lang', 'en'); } const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes'); if (!popup || popup.closed) { setPopupBlocked(true); return; } 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(t.expired); setTimeLeftSeconds(0); setExpired(true); return; } const totalSeconds = Math.floor(diff / 1000); const minutes = Math.floor(diff / 60000); const seconds = Math.floor((diff % 60000) / 1000); setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`); setTimeLeftSeconds(totalSeconds); }; updateTimer(); const timer = setInterval(updateTimer, 1000); return () => clearInterval(timer); }, [expiresAt, t.expired]); const pollStatus = useCallback(async () => { try { const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken)); if (res.ok) { const data = (await res.json()) as PublicOrderStatusSnapshot; if (isVisibleOrderOutcome(data)) { onStatusChange(data); } } } catch {} }, [orderId, onStatusChange, statusAccessToken]); 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(buildOrderStatusUrl(orderId, statusAccessToken)); if (!res.ok) return; const data = (await res.json()) as PublicOrderStatusSnapshot; if (data.paymentSuccess || TERMINAL_STATUSES.has(data.status)) { onStatusChange(data); 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({ id: orderId, status: 'CANCELLED', expiresAt, paymentSuccess: false, rechargeSuccess: false, rechargeStatus: 'closed', }); } else { await pollStatus(); } } catch {} }; const meta = getPaymentMeta(paymentType || 'alipay'); const iconSrc = getPaymentIconSrc(paymentType || 'alipay'); const channelLabel = getPaymentChannelLabel(paymentType || 'alipay', locale); const iconBgClass = meta.iconBg; if (cancelBlocked) { return (
{t.paidCancelBlocked}
{t.initFailed}
{t.successProcessing}
{t.h5Hint}
> ) : ( <> {qrDataUrl && ({t.scanPay}
{`${t.openScanPrefix}${channelLabel}${t.openScanSuffix}`}
> )} > )}