From 93a43950feabf2cd628ca6ba92a55db886c49926 Mon Sep 17 00:00:00 2001 From: erio Date: Wed, 8 Apr 2026 16:33:01 +0800 Subject: [PATCH] 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. --- backend/cmd/server/VERSION | 2 +- .../components/payment/PaymentQRDialog.vue | 39 +++++++++++++++---- .../src/components/payment/providerConfig.ts | 3 ++ frontend/src/views/user/PaymentView.vue | 34 +++++++++++++--- frontend/src/views/user/StripePaymentView.vue | 11 ++++-- 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 28e69135ae..407872f322 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.108.91 +0.1.108.92 diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index 8062435100..49b2d5b3dc 100644 --- a/frontend/src/components/payment/PaymentQRDialog.vue +++ b/frontend/src/components/payment/PaymentQRDialog.vue @@ -2,19 +2,31 @@
- -
- -
-

- {{ scanHint }} -

+ + + +

{{ t('payment.qr.expired') }}

-

{{ t('payment.qr.expiresIn') }}

+

{{ qrUrl ? t('payment.qr.expiresIn') : '' }}

{{ countdownDisplay }}

{{ t('payment.qr.waitingPayment') }}

@@ -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 diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index 1fd3f49544..58ddc896c6 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -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 = { easypay: '/api/v1/payment/webhook/easypay', diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 4d35b40ef6..3ec654d753 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -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(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({ @@ -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) diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index a7ec948234..c2a6acb0d8 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -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'))