mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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',
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user