frontend: normalize payment error presentation
This commit is contained in:
@@ -86,10 +86,6 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-else>{{ t('payment.createOrder') }} ¥{{ totalAmount.toFixed(2) }}</span>
|
<span v-else>{{ t('payment.createOrder') }} ¥{{ totalAmount.toFixed(2) }}</span>
|
||||||
</button>
|
</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>
|
||||||
</template>
|
</template>
|
||||||
<!-- Subscribe Tab -->
|
<!-- Subscribe Tab -->
|
||||||
@@ -173,10 +169,6 @@
|
|||||||
<span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
|
<span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</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>
|
</template>
|
||||||
<!-- Plan list -->
|
<!-- Plan list -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -196,7 +188,7 @@
|
|||||||
<div :class="['h-6 w-1 shrink-0 rounded-full', platformAccentBarClass(sub.group?.platform || '')]" />
|
<div :class="['h-6 w-1 shrink-0 rounded-full', platformAccentBarClass(sub.group?.platform || '')]" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-1.5">
|
<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>
|
<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>
|
||||||
<div class="flex flex-wrap gap-x-3 text-[11px] text-gray-400 dark:text-gray-500">
|
<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 PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||||
import { describePaymentScenarioError } from './paymentUx'
|
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
|
||||||
import { parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
|
import { parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
|
||||||
|
|
||||||
const { t } = useI18n()
|
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>> {
|
async function invokeWechatJsapiPayment(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||||
const bridge = await waitForWeixinJSBridge()
|
const bridge = await waitForWeixinJSBridge()
|
||||||
if (!bridge) {
|
if (!bridge) {
|
||||||
throw new Error('WeixinJSBridge is unavailable')
|
throw new Error('WECHAT_JSAPI_UNAVAILABLE')
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
bridge.invoke('getBrandWCPayRequest', payload, (result) => resolve(result || {}))
|
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')
|
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||||
errorHintMessage.value = ''
|
errorHintMessage.value = ''
|
||||||
} else {
|
} else {
|
||||||
applyScenarioError(err, normalizeVisibleMethod(options.paymentType || selectedMethod.value) || selectedMethod.value)
|
const handled = applyScenarioError(
|
||||||
|
err,
|
||||||
|
normalizeVisibleMethod(options.paymentType || selectedMethod.value) || selectedMethod.value,
|
||||||
|
)
|
||||||
if (!errorMessage.value) {
|
if (!errorMessage.value) {
|
||||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||||
errorHintMessage.value = ''
|
errorHintMessage.value = ''
|
||||||
}
|
}
|
||||||
|
if (handled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(buildPaymentErrorToastMessage(errorMessage.value, errorHintMessage.value))
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyScenarioError(err: unknown, paymentMethod: string) {
|
function applyScenarioError(err: unknown, paymentMethod: string): boolean {
|
||||||
const descriptor = describePaymentScenarioError(err, {
|
const descriptor = describePaymentScenarioError(err, {
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
isMobile: isMobileDevice(),
|
isMobile: isMobileDevice(),
|
||||||
@@ -735,10 +733,12 @@ function applyScenarioError(err: unknown, paymentMethod: string) {
|
|||||||
if (!descriptor) {
|
if (!descriptor) {
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
errorHintMessage.value = ''
|
errorHintMessage.value = ''
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
errorMessage.value = t(descriptor.messageKey)
|
errorMessage.value = t(descriptor.messageKey)
|
||||||
errorHintMessage.value = descriptor.hintKey ? t(descriptor.hintKey) : ''
|
errorHintMessage.value = descriptor.hintKey ? t(descriptor.hintKey) : ''
|
||||||
|
appStore.showError(buildPaymentErrorToastMessage(errorMessage.value, errorHintMessage.value))
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeWechatPaymentFromQuery() {
|
async function resumeWechatPaymentFromQuery() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
buildPaymentErrorToastMessage,
|
||||||
describePaymentScenarioError,
|
describePaymentScenarioError,
|
||||||
normalizePaymentMethodForDisplay,
|
normalizePaymentMethodForDisplay,
|
||||||
} from '../paymentUx'
|
} 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', () => {
|
it('maps generic desktop Alipay failures to QR guidance', () => {
|
||||||
expect(describePaymentScenarioError(
|
expect(describePaymentScenarioError(
|
||||||
{ reason: 'PAYMENT_GATEWAY_ERROR' },
|
{ 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)}`
|
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 {
|
function defaultWechatHint(context: PaymentScenarioContext): string {
|
||||||
if (!context.isMobile) return 'payment.errors.wechatScanOnDesktopHint'
|
if (!context.isMobile) return 'payment.errors.wechatScanOnDesktopHint'
|
||||||
return 'payment.errors.wechatOpenInWeChatHint'
|
return 'payment.errors.wechatOpenInWeChatHint'
|
||||||
@@ -78,7 +83,10 @@ export function describePaymentScenarioError(
|
|||||||
hintKey: defaultWechatHint(context),
|
hintKey: defaultWechatHint(context),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (normalizedMessage.includes('weixinjsbridge is unavailable')) {
|
if (
|
||||||
|
normalizedMessage.includes('weixinjsbridge is unavailable') ||
|
||||||
|
normalizedMessage.includes('wechat_jsapi_unavailable')
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
messageKey: 'payment.errors.wechatJsapiUnavailable',
|
messageKey: 'payment.errors.wechatJsapiUnavailable',
|
||||||
hintKey: 'payment.errors.wechatOpenInWeChatHint',
|
hintKey: 'payment.errors.wechatOpenInWeChatHint',
|
||||||
|
|||||||
Reference in New Issue
Block a user