From fbdb68a0aa7fc47b365850fb2441fd58268d0088 Mon Sep 17 00:00:00 2001 From: erio Date: Thu, 9 Apr 2026 02:04:23 +0800 Subject: [PATCH] refactor(payment): direct Stripe confirm for alipay/wechat popup Instead of opening a popup that renders the full Payment Element again (forcing users to re-select payment method), pass the selected method via query param. The popup now directly calls: - confirmAlipayPayment() for Alipay (redirects to Alipay page) - confirmWechatPayPayment() for WeChat (displays QR code) Card and Google Pay remain inline on the main page. --- .../payment/StripePaymentInline.vue | 15 +- frontend/src/views/user/StripePaymentView.vue | 204 ++++++++++++++---- 2 files changed, 174 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/payment/StripePaymentInline.vue b/frontend/src/components/payment/StripePaymentInline.vue index 19303ff97d..a508cb60e5 100644 --- a/frontend/src/components/payment/StripePaymentInline.vue +++ b/frontend/src/components/payment/StripePaymentInline.vue @@ -50,8 +50,8 @@ import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig' import type { Stripe, StripeElements } from '@stripe/stripe-js' import Icon from '@/components/icons/Icon.vue' -// Stripe payment methods that require full-page redirect (cannot complete inline) -const REDIRECT_METHODS = new Set(['alipay']) +// Stripe payment methods that need a popup (redirect or QR code) +const POPUP_METHODS = new Set(['alipay', 'wechat_pay']) const props = defineProps<{ orderId: number @@ -120,12 +120,15 @@ onUnmounted(() => { async function handlePay() { if (!stripeInstance || !elementsInstance || submitting.value) return - // For redirect-based methods (Alipay): open StripePaymentView in new window, - // let the redirect happen there instead of navigating the main page away - if (REDIRECT_METHODS.has(selectedType.value)) { + // For popup-based methods (Alipay redirect / WeChat QR): open popup with direct confirm + if (POPUP_METHODS.has(selectedType.value)) { const stripeUrl = router.resolve({ path: '/payment/stripe', - query: { order_id: String(props.orderId), client_secret: props.clientSecret }, + query: { + order_id: String(props.orderId), + client_secret: props.clientSecret, + method: selectedType.value, + }, }).href window.open(stripeUrl, 'paymentPopup', POPUP_WINDOW_FEATURES) emit('redirect', props.orderId, stripeUrl) diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index c2a6acb0d8..22e35115df 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -13,29 +13,75 @@ @@ -66,14 +112,18 @@ const stripeSubmitting = ref(false) const stripeSuccess = ref(false) const stripeReady = ref(false) const order = ref(null) +const wechatQrUrl = ref('') +const redirecting = ref(false) +const showPaymentElement = ref(false) let stripeInstance: Stripe | null = null -let redirectTimer: ReturnType | null = null let elementsInstance: StripeElements | null = null +let redirectTimer: ReturnType | null = null onMounted(async () => { const orderId = Number(route.query.order_id) const clientSecret = String(route.query.client_secret || '') + const method = String(route.query.method || '') if (!orderId || !clientSecret) { loading.value = false @@ -95,19 +145,18 @@ onMounted(async () => { stripeInstance = stripe loading.value = false - await nextTick() - const isDark = document.documentElement.classList.contains('dark') - const elements = stripe.elements({ - clientSecret, - appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } }, - }) - elementsInstance = elements - const paymentElement = elements.create('payment', { - layout: 'tabs', - paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'], - } as Record) - paymentElement.mount('#stripe-payment-element') - paymentElement.on('ready', () => { stripeReady.value = true }) + + // Direct confirm for specific methods (no Payment Element needed) + if (method === 'alipay') { + await confirmAlipay(stripe, clientSecret, orderId) + } else if (method === 'wechat_pay') { + await confirmWechatPay(stripe, clientSecret) + } else { + // Fallback: render full Payment Element + showPaymentElement.value = true + await nextTick() + mountPaymentElement(stripe, clientSecret) + } } catch (err: unknown) { initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed')) } finally { @@ -119,7 +168,59 @@ onUnmounted(() => { if (redirectTimer) clearTimeout(redirectTimer) }) -async function handlePay() { +async function confirmAlipay(stripe: Stripe, clientSecret: string, orderId: number) { + redirecting.value = true + const returnUrl = window.location.origin + '/payment/result?order_id=' + orderId + '&status=success' + const { error } = await stripe.confirmAlipayPayment(clientSecret, { return_url: returnUrl }) + if (error) { + redirecting.value = false + stripeError.value = error.message || t('payment.result.failed') + } + // If no error, Stripe redirects automatically — nothing else to do +} + +async function confirmWechatPay(stripe: Stripe, clientSecret: string) { + const { paymentIntent, error } = await (stripe as Stripe & { + confirmWechatPayPayment: (cs: string, opts: Record) => Promise<{ paymentIntent?: { status: string; next_action?: { wechat_pay_display_qr_code?: { image_data_url?: string } } }; error?: { message?: string } }> + }).confirmWechatPayPayment(clientSecret, { + payment_method_options: { wechat_pay: { client: 'web' } }, + }) + + if (error) { + stripeError.value = error.message || t('payment.result.failed') + return + } + + // Extract QR code image from next_action + const qrData = paymentIntent?.next_action?.wechat_pay_display_qr_code?.image_data_url + if (qrData) { + wechatQrUrl.value = qrData + // Poll for completion + startPolling() + } else if (paymentIntent?.status === 'succeeded') { + stripeSuccess.value = true + scheduleClose() + } else { + stripeError.value = t('payment.result.failed') + } +} + +function mountPaymentElement(stripe: Stripe, clientSecret: string) { + const isDark = document.documentElement.classList.contains('dark') + const elements = stripe.elements({ + clientSecret, + appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } }, + }) + elementsInstance = elements + const paymentElement = elements.create('payment', { + layout: 'tabs', + paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'], + } as Record) + paymentElement.mount('#stripe-payment-element') + paymentElement.on('ready', () => { stripeReady.value = true }) +} + +async function handleGenericPay() { if (!stripeInstance || !elementsInstance || stripeSubmitting.value) return stripeSubmitting.value = true stripeError.value = '' @@ -135,14 +236,7 @@ async function handlePay() { stripeError.value = error.message || t('payment.result.failed') } else { stripeSuccess.value = true - // If opened as popup, close the window after a short delay - if (window.opener) { - redirectTimer = setTimeout(() => { window.close() }, 2000) - } else { - redirectTimer = setTimeout(() => { - router.push({ path: '/payment/result', query: { order_id: String(route.query.order_id || ''), status: 'success' } }) - }, 2000) - } + scheduleClose() } } catch (err: unknown) { stripeError.value = extractApiErrorMessage(err, t('payment.result.failed')) @@ -150,4 +244,36 @@ async function handlePay() { stripeSubmitting.value = false } } + +let pollTimer: ReturnType | null = null + +function startPolling() { + const orderId = Number(route.query.order_id) + if (!orderId) return + pollTimer = setInterval(async () => { + const o = await paymentStore.pollOrderStatus(orderId) + if (!o) return + if (o.status === 'COMPLETED' || o.status === 'PAID') { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null } + stripeSuccess.value = true + wechatQrUrl.value = '' + scheduleClose() + } + }, 3000) +} + +function scheduleClose() { + if (window.opener) { + redirectTimer = setTimeout(() => { window.close() }, 2000) + } else { + redirectTimer = setTimeout(() => { + router.push({ path: '/payment/result', query: { order_id: String(route.query.order_id || ''), status: 'success' } }) + }, 2000) + } +} + +onUnmounted(() => { + if (redirectTimer) clearTimeout(redirectTimer) + if (pollTimer) clearInterval(pollTimer) +})