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:
erio
2026-04-08 21:26:17 +08:00
parent dc731465d5
commit 384a1b3070
2 changed files with 159 additions and 15 deletions
@@ -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">&yen;{{ 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>
+28 -15
View File
@@ -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 {