mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -13,29 +13,75 @@
|
||||
<button class="btn btn-primary mt-6" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Amount header -->
|
||||
<div v-if="order" class="card overflow-hidden">
|
||||
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-6 text-center">
|
||||
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-white">¥{{ order.pay_amount.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-6">
|
||||
<div id="stripe-payment-element" class="min-h-[200px]"></div>
|
||||
<p v-if="stripeError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
|
||||
<div v-if="stripeSuccess" class="mt-4 flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
|
||||
<Icon name="checkCircle" size="md" />
|
||||
<span class="text-sm font-medium">{{ t('payment.stripeSuccessProcessing') }}</span>
|
||||
|
||||
<!-- WeChat QR Code display -->
|
||||
<template v-if="wechatQrUrl">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.qr.scanWxpay') }}</p>
|
||||
<div class="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<img :src="wechatQrUrl" alt="WeChat Pay QR" class="h-56 w-56" />
|
||||
</div>
|
||||
<p class="text-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.scanWxpayHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!stripeSuccess" class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="stripeSubmitting || !stripeReady" @click="handlePay">
|
||||
<span v-if="stripeSubmitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.stripePay') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button class="btn btn-secondary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
<div class="card p-4 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.waitingPayment') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Alipay redirecting state -->
|
||||
<template v-else-if="redirecting">
|
||||
<div class="card p-6">
|
||||
<div class="flex flex-col items-center space-y-4 py-4">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success state -->
|
||||
<template v-else-if="stripeSuccess">
|
||||
<div class="card p-6 text-center">
|
||||
<div class="flex flex-col items-center gap-3 py-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Icon name="check" size="lg" class="text-green-500" />
|
||||
</div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.stripeSuccessProcessing') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fallback: full Payment Element (no method param or unknown method) -->
|
||||
<template v-else-if="showPaymentElement">
|
||||
<div class="card p-6">
|
||||
<div id="stripe-payment-element" class="min-h-[200px]"></div>
|
||||
<p v-if="stripeError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
|
||||
<button class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="stripeSubmitting || !stripeReady" @click="handleGenericPay">
|
||||
<span v-if="stripeSubmitting" class="flex items-center justify-center gap-2">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.stripePay') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button class="btn btn-secondary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="stripeError && !showPaymentElement" class="card p-4">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ stripeError }}</p>
|
||||
<button class="btn btn-secondary mt-3 w-full" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -66,14 +112,18 @@ const stripeSubmitting = ref(false)
|
||||
const stripeSuccess = ref(false)
|
||||
const stripeReady = ref(false)
|
||||
const order = ref<PaymentOrder | null>(null)
|
||||
const wechatQrUrl = ref('')
|
||||
const redirecting = ref(false)
|
||||
const showPaymentElement = ref(false)
|
||||
|
||||
let stripeInstance: Stripe | null = null
|
||||
let redirectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elementsInstance: StripeElements | null = null
|
||||
let redirectTimer: ReturnType<typeof setTimeout> | 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<string, unknown>)
|
||||
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<string, unknown>) => 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<string, unknown>)
|
||||
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<typeof setInterval> | 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)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user