Refine payment UX for wallet flows
This commit is contained in:
@@ -5453,6 +5453,18 @@ export default {
|
||||
errors: {
|
||||
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
|
||||
cancelRateLimited: 'Too many cancellations. Please try again later.',
|
||||
wechatH5NotAuthorized: 'This merchant has not enabled WeChat H5 payment. Open this page in WeChat to continue.',
|
||||
wechatPaymentMpNotConfigured: 'This site has not completed WeChat MP/JSAPI payment setup, so in-app WeChat payment is unavailable right now.',
|
||||
wechatJsapiUnavailable: 'WeChat payment could not be invoked in the current environment. Reopen this page inside WeChat and try again.',
|
||||
wechatJsapiFailed: 'WeChat payment did not complete. Try invoking it again or switch to QR payment.',
|
||||
wechatUnavailable: 'WeChat payment is temporarily unavailable. Please try again later.',
|
||||
wechatOpenInWeChatHint: 'Open the current page inside WeChat, or switch to desktop WeChat QR payment.',
|
||||
wechatScanOnDesktopHint: 'On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.',
|
||||
wechatSwitchBrowserHint: 'Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.',
|
||||
alipayDesktopUnavailable: 'The desktop Alipay flow could not generate a QR code.',
|
||||
alipayDesktopQrHint: 'Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.',
|
||||
alipayMobileUnavailable: 'This page could not hand off to Alipay.',
|
||||
alipayMobileOpenHint: 'Allow the current page to open the Alipay app, or retry from the system browser.',
|
||||
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
|
||||
},
|
||||
stripePay: 'Pay Now',
|
||||
|
||||
@@ -5641,6 +5641,18 @@ export default {
|
||||
errors: {
|
||||
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
|
||||
cancelRateLimited: '取消订单过于频繁,请稍后再试',
|
||||
wechatH5NotAuthorized: '当前商户未开通微信 H5 支付,请在微信中打开当前页面继续支付。',
|
||||
wechatPaymentMpNotConfigured: '当前站点未完成公众号/JSAPI 支付配置,暂时无法在微信内直接拉起支付。',
|
||||
wechatJsapiUnavailable: '当前环境未能拉起微信支付,请确认正在微信内打开本页后重试。',
|
||||
wechatJsapiFailed: '微信支付未完成,请重新拉起支付或改用扫码支付。',
|
||||
wechatUnavailable: '当前微信支付暂不可用,请稍后重试。',
|
||||
wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。',
|
||||
wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。',
|
||||
wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。',
|
||||
alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。',
|
||||
alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。',
|
||||
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
|
||||
alipayMobileOpenHint: '请允许当前页面打开支付宝 App,或改用系统浏览器重新发起支付。',
|
||||
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
|
||||
},
|
||||
stripePay: '立即支付',
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + order.payment_type, order.payment_type) }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ t(paymentMethodI18nKey(order.payment_type), normalizedOrderPaymentType(order.payment_type)) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</span>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
<div v-if="returnInfo.type" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ t('payment.methods.' + returnInfo.type, returnInfo.type) }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ t(paymentMethodI18nKey(returnInfo.type), normalizedOrderPaymentType(returnInfo.type)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,6 +98,7 @@ import { PAYMENT_RECOVERY_STORAGE_KEY, readPaymentRecoverySnapshot } from '@/com
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import { normalizePaymentMethodForDisplay, paymentMethodI18nKey } from './paymentUx'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -133,6 +134,10 @@ const isSuccess = computed(() => {
|
||||
return !!order.value && SUCCESS_STATUSES.has(order.value.status)
|
||||
})
|
||||
|
||||
function normalizedOrderPaymentType(paymentType: string): string {
|
||||
return normalizePaymentMethodForDisplay(paymentType) || paymentType
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const resumeToken = typeof route.query.resume_token === 'string'
|
||||
? route.query.resume_token
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
</button>
|
||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
<p v-if="errorHintMessage" class="mt-2 text-xs text-red-600 dark:text-red-300">{{ errorHintMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -174,6 +175,7 @@
|
||||
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
|
||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
<p v-if="errorHintMessage" class="mt-2 text-xs text-red-600 dark:text-red-300">{{ errorHintMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Plan list -->
|
||||
@@ -281,6 +283,7 @@ import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { describePaymentScenarioError } from './paymentUx'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -301,6 +304,7 @@ function getDaysRemaining(expiresAt: string): number {
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const errorHintMessage = ref('')
|
||||
const activeTab = ref<'recharge' | 'subscription'>('recharge')
|
||||
const amount = ref<number | null>(null)
|
||||
const selectedMethod = ref('')
|
||||
@@ -619,6 +623,7 @@ async function confirmSubscribe() {
|
||||
async function createOrder(orderAmount: number, orderType: OrderType, planId?: number, options: CreateOrderOptions = {}) {
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
errorHintMessage.value = ''
|
||||
try {
|
||||
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
|
||||
const payload = buildCreateOrderPayload({
|
||||
@@ -668,8 +673,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
}
|
||||
|
||||
if (decision.kind === 'unhandled') {
|
||||
errorMessage.value = t('payment.result.failed')
|
||||
appStore.showError(errorMessage.value)
|
||||
applyScenarioError({ reason: 'UNHANDLED_PAYMENT_SCENARIO' }, visibleMethod)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -691,7 +695,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
if (errMsg.includes('cancel')) {
|
||||
appStore.showInfo(t('payment.qr.cancelled'))
|
||||
} else if (errMsg && !errMsg.includes('ok')) {
|
||||
appStore.showError(t('payment.result.failed'))
|
||||
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -707,10 +711,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
if (apiErr.reason === 'TOO_MANY_PENDING') {
|
||||
const metadata = apiErr.metadata as Record<string, unknown> | undefined
|
||||
errorMessage.value = t('payment.errors.tooManyPending', { max: metadata?.max || '' })
|
||||
errorHintMessage.value = ''
|
||||
} else if (apiErr.reason === 'CANCEL_RATE_LIMITED') {
|
||||
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||
errorHintMessage.value = ''
|
||||
} else {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
applyScenarioError(err, normalizeVisibleMethod(options.paymentType || selectedMethod.value) || selectedMethod.value)
|
||||
if (!errorMessage.value) {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
errorHintMessage.value = ''
|
||||
}
|
||||
}
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
@@ -718,6 +728,21 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
}
|
||||
}
|
||||
|
||||
function applyScenarioError(err: unknown, paymentMethod: string) {
|
||||
const descriptor = describePaymentScenarioError(err, {
|
||||
paymentMethod,
|
||||
isMobile: isMobileDevice(),
|
||||
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
|
||||
})
|
||||
if (!descriptor) {
|
||||
errorMessage.value = ''
|
||||
errorHintMessage.value = ''
|
||||
return
|
||||
}
|
||||
errorMessage.value = t(descriptor.messageKey)
|
||||
errorHintMessage.value = descriptor.hintKey ? t(descriptor.hintKey) : ''
|
||||
}
|
||||
|
||||
async function resumeWechatPaymentFromQuery() {
|
||||
const openid = readRouteQueryValue(route.query.openid)
|
||||
if (readRouteQueryValue(route.query.wechat_resume) !== '1' || !openid) {
|
||||
|
||||
@@ -155,4 +155,29 @@ describe('PaymentResultView', () => {
|
||||
expect(wrapper.text()).toContain('payment.result.success')
|
||||
expect(verifyOrderPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes aliased payment methods before rendering the label', async () => {
|
||||
routeState.query = {
|
||||
resume_token: 'resume-88',
|
||||
}
|
||||
resolveOrderPublicByResumeToken.mockResolvedValueOnce({
|
||||
data: {
|
||||
...orderFactory('PAID'),
|
||||
payment_type: 'alipay_direct',
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(PaymentResultView, {
|
||||
global: {
|
||||
stubs: {
|
||||
OrderStatusBadge: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('payment.methods.alipay')
|
||||
expect(wrapper.text()).not.toContain('payment.methods.alipay_direct')
|
||||
})
|
||||
})
|
||||
|
||||
49
frontend/src/views/user/__tests__/paymentUx.spec.ts
Normal file
49
frontend/src/views/user/__tests__/paymentUx.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describePaymentScenarioError,
|
||||
normalizePaymentMethodForDisplay,
|
||||
} from '../paymentUx'
|
||||
|
||||
describe('normalizePaymentMethodForDisplay', () => {
|
||||
it('collapses visible payment aliases to canonical method ids', () => {
|
||||
expect(normalizePaymentMethodForDisplay(' alipay_direct ')).toBe('alipay')
|
||||
expect(normalizePaymentMethodForDisplay('wxpay_direct')).toBe('wxpay')
|
||||
expect(normalizePaymentMethodForDisplay('wechat_pay')).toBe('wxpay')
|
||||
})
|
||||
|
||||
it('leaves non-aliased methods untouched', () => {
|
||||
expect(normalizePaymentMethodForDisplay('stripe')).toBe('stripe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describePaymentScenarioError', () => {
|
||||
it('maps WeChat H5 authorization errors to explicit in-app guidance', () => {
|
||||
expect(describePaymentScenarioError(
|
||||
{ reason: 'WECHAT_H5_NOT_AUTHORIZED' },
|
||||
{ paymentMethod: 'wxpay', isMobile: true, isWechatBrowser: false },
|
||||
)).toEqual({
|
||||
messageKey: 'payment.errors.wechatH5NotAuthorized',
|
||||
hintKey: 'payment.errors.wechatOpenInWeChatHint',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps missing WeixinJSBridge to a JSAPI-specific prompt', () => {
|
||||
expect(describePaymentScenarioError(
|
||||
new Error('WeixinJSBridge is unavailable'),
|
||||
{ paymentMethod: 'wxpay', isMobile: true, isWechatBrowser: true },
|
||||
)).toEqual({
|
||||
messageKey: 'payment.errors.wechatJsapiUnavailable',
|
||||
hintKey: 'payment.errors.wechatOpenInWeChatHint',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps generic desktop Alipay failures to QR guidance', () => {
|
||||
expect(describePaymentScenarioError(
|
||||
{ reason: 'PAYMENT_GATEWAY_ERROR' },
|
||||
{ paymentMethod: 'alipay', isMobile: false, isWechatBrowser: false },
|
||||
)).toEqual({
|
||||
messageKey: 'payment.errors.alipayDesktopUnavailable',
|
||||
hintKey: 'payment.errors.alipayDesktopQrHint',
|
||||
})
|
||||
})
|
||||
})
|
||||
105
frontend/src/views/user/paymentUx.ts
Normal file
105
frontend/src/views/user/paymentUx.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { normalizeVisibleMethod } from '@/components/payment/paymentFlow'
|
||||
import { extractApiErrorCode } from '@/utils/apiError'
|
||||
|
||||
const DISPLAY_METHOD_ALIASES: Record<string, string> = {
|
||||
wechat: 'wxpay',
|
||||
wechat_pay: 'wxpay',
|
||||
}
|
||||
|
||||
export interface PaymentScenarioContext {
|
||||
paymentMethod: string
|
||||
isMobile: boolean
|
||||
isWechatBrowser: boolean
|
||||
}
|
||||
|
||||
export interface PaymentScenarioErrorDescriptor {
|
||||
messageKey: string
|
||||
hintKey?: string
|
||||
}
|
||||
|
||||
export function normalizePaymentMethodForDisplay(paymentType: string): string {
|
||||
const trimmed = paymentType.trim().toLowerCase()
|
||||
const visibleMethod = normalizeVisibleMethod(trimmed)
|
||||
if (visibleMethod) return visibleMethod
|
||||
return DISPLAY_METHOD_ALIASES[trimmed] ?? trimmed
|
||||
}
|
||||
|
||||
export function paymentMethodI18nKey(paymentType: string): string {
|
||||
return `payment.methods.${normalizePaymentMethodForDisplay(paymentType)}`
|
||||
}
|
||||
|
||||
function defaultWechatHint(context: PaymentScenarioContext): string {
|
||||
if (!context.isMobile) return 'payment.errors.wechatScanOnDesktopHint'
|
||||
return 'payment.errors.wechatOpenInWeChatHint'
|
||||
}
|
||||
|
||||
function defaultAlipayHint(context: PaymentScenarioContext): string {
|
||||
if (context.isMobile) return 'payment.errors.alipayMobileOpenHint'
|
||||
return 'payment.errors.alipayDesktopQrHint'
|
||||
}
|
||||
|
||||
export function describePaymentScenarioError(
|
||||
error: unknown,
|
||||
context: PaymentScenarioContext,
|
||||
): PaymentScenarioErrorDescriptor | null {
|
||||
const method = normalizePaymentMethodForDisplay(context.paymentMethod)
|
||||
const code = extractApiErrorCode(error)
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string'
|
||||
? error.message
|
||||
: String(error || ''))
|
||||
const normalizedMessage = message.toLowerCase()
|
||||
|
||||
if (method === 'wxpay') {
|
||||
if (code === 'WECHAT_H5_NOT_AUTHORIZED') {
|
||||
return {
|
||||
messageKey: 'payment.errors.wechatH5NotAuthorized',
|
||||
hintKey: defaultWechatHint(context),
|
||||
}
|
||||
}
|
||||
if (code === 'WECHAT_PAYMENT_MP_NOT_CONFIGURED') {
|
||||
return {
|
||||
messageKey: 'payment.errors.wechatPaymentMpNotConfigured',
|
||||
hintKey: context.isWechatBrowser
|
||||
? 'payment.errors.wechatSwitchBrowserHint'
|
||||
: defaultWechatHint(context),
|
||||
}
|
||||
}
|
||||
if (code === 'NO_AVAILABLE_INSTANCE') {
|
||||
return {
|
||||
messageKey: 'payment.errors.wechatUnavailable',
|
||||
hintKey: defaultWechatHint(context),
|
||||
}
|
||||
}
|
||||
if (code === 'WECHAT_JSAPI_FAILED' || normalizedMessage.includes('get_brand_wcpay_request:fail')) {
|
||||
return {
|
||||
messageKey: 'payment.errors.wechatJsapiFailed',
|
||||
hintKey: defaultWechatHint(context),
|
||||
}
|
||||
}
|
||||
if (normalizedMessage.includes('weixinjsbridge is unavailable')) {
|
||||
return {
|
||||
messageKey: 'payment.errors.wechatJsapiUnavailable',
|
||||
hintKey: 'payment.errors.wechatOpenInWeChatHint',
|
||||
}
|
||||
}
|
||||
if (code === 'PAYMENT_GATEWAY_ERROR' || code === 'UNHANDLED_PAYMENT_SCENARIO') {
|
||||
return {
|
||||
messageKey: 'payment.errors.wechatUnavailable',
|
||||
hintKey: defaultWechatHint(context),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'alipay' && (code === 'PAYMENT_GATEWAY_ERROR' || code === 'UNHANDLED_PAYMENT_SCENARIO')) {
|
||||
return {
|
||||
messageKey: context.isMobile
|
||||
? 'payment.errors.alipayMobileUnavailable'
|
||||
: 'payment.errors.alipayDesktopUnavailable',
|
||||
hintKey: defaultAlipayHint(context),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user