frontend: normalize payment error presentation
This commit is contained in:
@@ -86,10 +86,6 @@
|
||||
</span>
|
||||
<span v-else>{{ t('payment.createOrder') }} ¥{{ totalAmount.toFixed(2) }}</span>
|
||||
</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>
|
||||
<!-- Subscribe Tab -->
|
||||
@@ -173,10 +169,6 @@
|
||||
<span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
|
||||
</button>
|
||||
<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 -->
|
||||
<template v-else>
|
||||
@@ -196,7 +188,7 @@
|
||||
<div :class="['h-6 w-1 shrink-0 rounded-full', platformAccentBarClass(sub.group?.platform || '')]" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate text-xs font-semibold text-gray-900 dark:text-white">{{ sub.group?.name || `Group #${sub.group_id}` }}</span>
|
||||
<span class="truncate text-xs font-semibold text-gray-900 dark:text-white">{{ sub.group?.name || t('payment.groupFallback', { id: sub.group_id }) }}</span>
|
||||
<span :class="['shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-medium', platformBadgeLightClass(sub.group?.platform || '')]">{{ platformLabel(sub.group?.platform || '') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-3 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
@@ -283,7 +275,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'
|
||||
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
|
||||
import { parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -374,7 +366,7 @@ function waitForWeixinJSBridge(timeoutMs = 4000): Promise<WeixinJSBridgeLike | n
|
||||
async function invokeWechatJsapiPayment(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const bridge = await waitForWeixinJSBridge()
|
||||
if (!bridge) {
|
||||
throw new Error('WeixinJSBridge is unavailable')
|
||||
throw new Error('WECHAT_JSAPI_UNAVAILABLE')
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
bridge.invoke('getBrandWCPayRequest', payload, (result) => resolve(result || {}))
|
||||
@@ -714,19 +706,25 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||
errorHintMessage.value = ''
|
||||
} else {
|
||||
applyScenarioError(err, normalizeVisibleMethod(options.paymentType || selectedMethod.value) || selectedMethod.value)
|
||||
const handled = applyScenarioError(
|
||||
err,
|
||||
normalizeVisibleMethod(options.paymentType || selectedMethod.value) || selectedMethod.value,
|
||||
)
|
||||
if (!errorMessage.value) {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
errorHintMessage.value = ''
|
||||
}
|
||||
if (handled) {
|
||||
return
|
||||
}
|
||||
}
|
||||
appStore.showError(errorMessage.value)
|
||||
appStore.showError(buildPaymentErrorToastMessage(errorMessage.value, errorHintMessage.value))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyScenarioError(err: unknown, paymentMethod: string) {
|
||||
function applyScenarioError(err: unknown, paymentMethod: string): boolean {
|
||||
const descriptor = describePaymentScenarioError(err, {
|
||||
paymentMethod,
|
||||
isMobile: isMobileDevice(),
|
||||
@@ -735,10 +733,12 @@ function applyScenarioError(err: unknown, paymentMethod: string) {
|
||||
if (!descriptor) {
|
||||
errorMessage.value = ''
|
||||
errorHintMessage.value = ''
|
||||
return
|
||||
return false
|
||||
}
|
||||
errorMessage.value = t(descriptor.messageKey)
|
||||
errorHintMessage.value = descriptor.hintKey ? t(descriptor.hintKey) : ''
|
||||
appStore.showError(buildPaymentErrorToastMessage(errorMessage.value, errorHintMessage.value))
|
||||
return true
|
||||
}
|
||||
|
||||
async function resumeWechatPaymentFromQuery() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildPaymentErrorToastMessage,
|
||||
describePaymentScenarioError,
|
||||
normalizePaymentMethodForDisplay,
|
||||
} from '../paymentUx'
|
||||
@@ -37,6 +38,16 @@ describe('describePaymentScenarioError', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the internal JSAPI unavailable marker to the same prompt', () => {
|
||||
expect(describePaymentScenarioError(
|
||||
new Error('WECHAT_JSAPI_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' },
|
||||
@@ -47,3 +58,15 @@ describe('describePaymentScenarioError', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPaymentErrorToastMessage', () => {
|
||||
it('returns the main message when no hint is present', () => {
|
||||
expect(buildPaymentErrorToastMessage('Payment failed')).toBe('Payment failed')
|
||||
})
|
||||
|
||||
it('appends the hint to the toast body when present', () => {
|
||||
expect(buildPaymentErrorToastMessage('Payment failed', 'Open WeChat to continue.')).toBe(
|
||||
'Payment failed Open WeChat to continue.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,11 @@ export function paymentMethodI18nKey(paymentType: string): string {
|
||||
return `payment.methods.${normalizePaymentMethodForDisplay(paymentType)}`
|
||||
}
|
||||
|
||||
export function buildPaymentErrorToastMessage(message: string, hint?: string): string {
|
||||
if (!hint) return message
|
||||
return `${message} ${hint}`.trim()
|
||||
}
|
||||
|
||||
function defaultWechatHint(context: PaymentScenarioContext): string {
|
||||
if (!context.isMobile) return 'payment.errors.wechatScanOnDesktopHint'
|
||||
return 'payment.errors.wechatOpenInWeChatHint'
|
||||
@@ -78,7 +83,10 @@ export function describePaymentScenarioError(
|
||||
hintKey: defaultWechatHint(context),
|
||||
}
|
||||
}
|
||||
if (normalizedMessage.includes('weixinjsbridge is unavailable')) {
|
||||
if (
|
||||
normalizedMessage.includes('weixinjsbridge is unavailable') ||
|
||||
normalizedMessage.includes('wechat_jsapi_unavailable')
|
||||
) {
|
||||
return {
|
||||
messageKey: 'payment.errors.wechatJsapiUnavailable',
|
||||
hintKey: 'payment.errors.wechatOpenInWeChatHint',
|
||||
|
||||
Reference in New Issue
Block a user