feat(payment): open Stripe and redirect payments in popup window

Instead of navigating the main page away during payment, open Stripe
and EasyPay redirect payments in a popup window while showing a
waiting dialog on the purchase page. Extract POPUP_WINDOW_FEATURES
constant to providerConfig.ts.
This commit is contained in:
erio
2026-04-08 16:33:01 +08:00
parent 0ed9816487
commit 93a43950fe
5 changed files with 71 additions and 18 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.108.91
0.1.108.92
@@ -2,19 +2,31 @@
<BaseDialog :show="show" :title="dialogTitle" width="narrow" @close="handleClose">
<!-- QR Code + Polling State -->
<div v-if="!success" class="flex flex-col items-center space-y-4">
<!-- QR Code -->
<div v-if="qrUrl" class="rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
</div>
<p v-if="qrUrl && scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
{{ scanHint }}
</p>
<!-- QR Code mode -->
<template v-if="qrUrl">
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
</div>
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
{{ scanHint }}
</p>
</template>
<!-- Popup window waiting mode (no QR code) -->
<template v-else>
<div class="flex flex-col items-center py-4">
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
<button v-if="payUrl" class="btn btn-secondary mt-3 text-sm" @click="reopenPopup">
{{ t('payment.qr.openPayWindow') }}
</button>
</div>
</template>
<!-- Countdown -->
<div v-if="expired" class="text-center">
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
</div>
<div v-else class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ qrUrl ? t('payment.qr.expiresIn') : '' }}</p>
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
</div>
@@ -63,6 +75,7 @@ import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
@@ -74,6 +87,8 @@ const props = defineProps<{
qrCode: string
expiresAt: string
paymentType: string
/** URL for reopening the payment popup window */
payUrl?: string
}>()
const emit = defineEmits<{
@@ -101,6 +116,7 @@ const isWxpay = computed(() => props.paymentType.includes('wxpay'))
const dialogTitle = computed(() => {
if (success.value) return t('payment.result.success')
if (!qrUrl.value) return t('payment.qr.payInNewWindow')
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
return t('payment.qr.scanToPay')
@@ -124,6 +140,13 @@ function getLogoForType(): string | null {
return null
}
function reopenPopup() {
if (props.payUrl) {
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
}
}
async function renderQR() {
await nextTick()
if (!qrCanvas.value || !qrUrl.value) return
@@ -43,6 +43,9 @@ export const METHOD_ORDER = ['alipay', 'wxpay', 'stripe'] as const
export const PAYMENT_MODE_REDIRECT = 'redirect'
export const PAYMENT_MODE_API = 'api'
/** Window features for payment popup windows */
export const POPUP_WINDOW_FEATURES = 'width=520,height=700,left=200,top=100,scrollbars=yes,resizable=yes'
/** Webhook paths for each provider (relative to origin). */
export const WEBHOOK_PATHS: Record<string, string> = {
easypay: '/api/v1/payment/webhook/easypay',
+28 -6
View File
@@ -124,6 +124,7 @@
:qr-code="qrDialog.qrCode"
:expires-at="qrDialog.expiresAt"
:payment-type="qrDialog.paymentType"
:pay-url="qrDialog.payUrl"
@close="qrDialog.show = false"
@success="authStore.refreshUser()"
/>
@@ -152,7 +153,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
import { METHOD_ORDER } from '@/components/payment/providerConfig'
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentQRDialog from '@/components/payment/PaymentQRDialog.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -176,7 +177,7 @@ const selectedPlan = ref<SubscriptionPlan | null>(null)
const previewImage = ref('')
// Inline QR payment dialog state
const qrDialog = ref({ show: false, orderId: 0, qrCode: '', expiresAt: '', paymentType: '' })
const qrDialog = ref({ show: false, orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '' })
// All checkout data from single API call
const checkout = ref<CheckoutInfoResponse>({
@@ -296,7 +297,20 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
plan_id: planId,
})
if (result.client_secret) {
router.push({ path: '/payment/stripe', query: { order_id: String(result.order_id), client_secret: result.client_secret } })
// Stripe: open in popup window, show waiting dialog on main page
const stripeUrl = router.resolve({
path: '/payment/stripe',
query: { order_id: String(result.order_id), client_secret: result.client_secret },
}).href
window.open(stripeUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
qrDialog.value = {
show: true,
orderId: result.order_id,
qrCode: '',
expiresAt: '',
paymentType: selectedMethod.value,
payUrl: stripeUrl,
}
} else if (result.qr_code) {
// QR mode: show inline dialog, no page navigation
qrDialog.value = {
@@ -305,11 +319,19 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
qrCode: result.qr_code,
expiresAt: result.expires_at || '',
paymentType: selectedMethod.value,
payUrl: '',
}
} else if (result.pay_url) {
// Redirect mode: open new window + navigate to polling page
window.open(result.pay_url, '_blank')
router.push({ path: '/payment/qrcode', query: { order_id: String(result.order_id), pay_url: result.pay_url, expires_at: result.expires_at || '', payment_type: selectedMethod.value } })
// Redirect mode: open in popup window, show waiting dialog on main page
window.open(result.pay_url, 'paymentPopup', POPUP_WINDOW_FEATURES)
qrDialog.value = {
show: true,
orderId: result.order_id,
qrCode: '',
expiresAt: result.expires_at || '',
paymentType: selectedMethod.value,
payUrl: result.pay_url,
}
} else {
errorMessage.value = t('payment.result.failed')
appStore.showError(errorMessage.value)
@@ -135,9 +135,14 @@ async function handlePay() {
stripeError.value = error.message || t('payment.result.failed')
} else {
stripeSuccess.value = true
redirectTimer = setTimeout(() => {
router.push({ path: '/payment/result', query: { order_id: String(route.query.order_id || ''), status: 'success' } })
}, 2000)
// 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)
}
}
} catch (err: unknown) {
stripeError.value = extractApiErrorMessage(err, t('payment.result.failed'))