mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-22 07:30:51 +00:00
feat(payment): embed Stripe Payment Element inline instead of popup
Stripe payment now shows the Payment Element directly on the purchase page. User selects payment method (Alipay/WeChat/Card) inline, then confirms. Redirect-based methods (Alipay) navigate to the payment page; non-redirect methods (Card/WeChat) complete inline.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<div v-else-if="initError" class="card p-6 text-center">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ initError }}</p>
|
||||
<button class="btn btn-secondary mt-4" @click="$emit('back')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Amount -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 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">¥{{ payAmount.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stripe Payment Element -->
|
||||
<div class="card p-6">
|
||||
<div ref="stripeMount" class="min-h-[200px]"></div>
|
||||
<p v-if="error" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
<div v-if="success" 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>
|
||||
</div>
|
||||
<button v-if="!success" class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="submitting || !ready" @click="handlePay">
|
||||
<span v-if="submitting" 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>
|
||||
<!-- Back -->
|
||||
<div class="text-center">
|
||||
<button class="btn btn-secondary" @click="$emit('back')">{{ t('payment.result.backToRecharge') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
orderId: number
|
||||
clientSecret: string
|
||||
publishableKey: string
|
||||
payAmount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ success: []; back: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const stripeMount = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const initError = ref('')
|
||||
const error = ref('')
|
||||
const submitting = ref(false)
|
||||
const success = ref(false)
|
||||
const ready = ref(false)
|
||||
|
||||
let stripeInstance: Stripe | null = null
|
||||
let elementsInstance: StripeElements | null = null
|
||||
let successTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { loadStripe } = await import('@stripe/stripe-js')
|
||||
const stripe = await loadStripe(props.publishableKey)
|
||||
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
|
||||
|
||||
stripeInstance = stripe
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
if (!stripeMount.value) return
|
||||
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const elements = stripe.elements({
|
||||
clientSecret: props.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(stripeMount.value)
|
||||
paymentElement.on('ready', () => { ready.value = true })
|
||||
} catch (err: unknown) {
|
||||
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (successTimer) clearTimeout(successTimer)
|
||||
})
|
||||
|
||||
async function handlePay() {
|
||||
if (!stripeInstance || !elementsInstance || submitting.value) return
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { error: stripeError } = await stripeInstance.confirmPayment({
|
||||
elements: elementsInstance,
|
||||
confirmParams: {
|
||||
return_url: window.location.origin + '/payment/result?order_id=' + props.orderId + '&status=success',
|
||||
},
|
||||
redirect: 'if_required',
|
||||
})
|
||||
if (stripeError) {
|
||||
error.value = stripeError.message || t('payment.result.failed')
|
||||
} else {
|
||||
// Payment completed without redirect (card, WeChat popup)
|
||||
success.value = true
|
||||
successTimer = setTimeout(() => emit('success'), 1500)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -26,7 +26,7 @@
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.notAvailable') }}</p>
|
||||
</div>
|
||||
<!-- Payment status (replaces selection UI after order creation) -->
|
||||
<template v-else-if="paymentPhase !== 'select'">
|
||||
<template v-else-if="paymentPhase === 'paying'">
|
||||
<PaymentStatusPanel
|
||||
:order-id="paymentState.orderId"
|
||||
:qr-code="paymentState.qrCode"
|
||||
@@ -37,6 +37,17 @@
|
||||
@success="authStore.refreshUser()"
|
||||
/>
|
||||
</template>
|
||||
<!-- Stripe inline payment (select method → confirm → redirect/complete) -->
|
||||
<template v-else-if="paymentPhase === 'stripe'">
|
||||
<StripePaymentInline
|
||||
:order-id="paymentState.orderId"
|
||||
:client-secret="paymentState.clientSecret"
|
||||
:publishable-key="checkout.stripe_publishable_key"
|
||||
:pay-amount="paymentState.payAmount"
|
||||
@success="onStripeSuccess"
|
||||
@back="resetPayment"
|
||||
/>
|
||||
</template>
|
||||
<!-- Amount & method selection -->
|
||||
<template v-else>
|
||||
<div class="card p-6">
|
||||
@@ -144,7 +155,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useAppStore } from '@/stores'
|
||||
@@ -158,11 +168,11 @@ import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vu
|
||||
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const paymentStore = usePaymentStore()
|
||||
const appStore = useAppStore()
|
||||
@@ -178,13 +188,18 @@ const selectedMethod = ref('')
|
||||
const selectedPlan = ref<SubscriptionPlan | null>(null)
|
||||
const previewImage = ref('')
|
||||
|
||||
// Payment phase: 'select' → 'paying' (after order created)
|
||||
const paymentPhase = ref<'select' | 'paying'>('select')
|
||||
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '' })
|
||||
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
|
||||
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
|
||||
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0 })
|
||||
|
||||
function resetPayment() {
|
||||
paymentPhase.value = 'select'
|
||||
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '' }
|
||||
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0 }
|
||||
}
|
||||
|
||||
function onStripeSuccess() {
|
||||
authStore.refreshUser()
|
||||
resetPayment()
|
||||
}
|
||||
|
||||
// All checkout data from single API call
|
||||
@@ -313,22 +328,19 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
}
|
||||
}
|
||||
if (result.client_secret) {
|
||||
// Stripe: open payment page, show waiting state inline
|
||||
const stripeUrl = router.resolve({
|
||||
path: '/payment/stripe',
|
||||
query: { order_id: String(result.order_id), client_secret: result.client_secret },
|
||||
}).href
|
||||
openWindow(stripeUrl)
|
||||
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: '',
|
||||
paymentType: selectedMethod.value, payUrl: stripeUrl,
|
||||
paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: result.client_secret, payAmount: result.pay_amount,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
paymentPhase.value = 'stripe'
|
||||
} else if (result.qr_code) {
|
||||
// QR mode: show QR code inline
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: result.qr_code,
|
||||
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: '', payAmount: 0,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else if (result.pay_url) {
|
||||
@@ -337,6 +349,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
clientSecret: '', payAmount: 0,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user