Fix mobile payment launch detection
This commit is contained in:
@@ -106,6 +106,35 @@ describe('decidePaymentLaunch', () => {
|
|||||||
expect(decision.recovery.resumeToken).toBe('resume-2')
|
expect(decision.recovery.resumeToken).toBe('resume-2')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('prefers redirect on mobile when both pay_url and qr_code are present', () => {
|
||||||
|
const decision = decidePaymentLaunch(createOrderResult({
|
||||||
|
pay_url: 'https://pay.example.com/mobile/session',
|
||||||
|
qr_code: 'https://pay.example.com/qr/session',
|
||||||
|
}), {
|
||||||
|
visibleMethod: 'alipay',
|
||||||
|
orderType: 'balance',
|
||||||
|
isMobile: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(decision.kind).toBe('redirect_waiting')
|
||||||
|
expect(decision.paymentState.payUrl).toBe('https://pay.example.com/mobile/session')
|
||||||
|
expect(decision.paymentState.qrCode).toBe('https://pay.example.com/qr/session')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps QR flow on desktop when both pay_url and qr_code are present', () => {
|
||||||
|
const decision = decidePaymentLaunch(createOrderResult({
|
||||||
|
pay_url: 'https://pay.example.com/desktop/session',
|
||||||
|
qr_code: 'https://pay.example.com/qr/session',
|
||||||
|
}), {
|
||||||
|
visibleMethod: 'wxpay',
|
||||||
|
orderType: 'balance',
|
||||||
|
isMobile: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(decision.kind).toBe('qr_waiting')
|
||||||
|
expect(decision.paymentState.qrCode).toBe('https://pay.example.com/qr/session')
|
||||||
|
})
|
||||||
|
|
||||||
it('returns wechat oauth launch when backend requires in-app authorization', () => {
|
it('returns wechat oauth launch when backend requires in-app authorization', () => {
|
||||||
const decision = decidePaymentLaunch(createOrderResult({
|
const decision = decidePaymentLaunch(createOrderResult({
|
||||||
result_type: 'oauth_required',
|
result_type: 'oauth_required',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface PaymentLaunchContext {
|
|||||||
visibleMethod: string
|
visibleMethod: string
|
||||||
orderType: OrderType
|
orderType: OrderType
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
|
isWechatBrowser?: boolean
|
||||||
now?: number
|
now?: number
|
||||||
stripePopupUrl?: string
|
stripePopupUrl?: string
|
||||||
stripeRouteUrl?: string
|
stripeRouteUrl?: string
|
||||||
@@ -159,7 +160,23 @@ export function decidePaymentLaunch(
|
|||||||
return { kind: 'wechat_jsapi', paymentState: baseState, recovery: baseState, jsapi: jsapiPayload }
|
return { kind: 'wechat_jsapi', paymentState: baseState, recovery: baseState, jsapi: jsapiPayload }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseState.qrCode) {
|
const normalizedPaymentMode = baseState.paymentMode.trim().toLowerCase()
|
||||||
|
const prefersRedirect = normalizedPaymentMode === 'redirect'
|
||||||
|
|| normalizedPaymentMode === 'popup'
|
||||||
|
|| (context.isMobile && !!baseState.payUrl)
|
||||||
|
const prefersQr = normalizedPaymentMode === 'qrcode'
|
||||||
|
|| normalizedPaymentMode === 'native'
|
||||||
|
|| (!prefersRedirect && !!baseState.qrCode)
|
||||||
|
|
||||||
|
if (visibleMethod === 'wxpay' && context.isWechatBrowser && baseState.payUrl && !baseState.qrCode) {
|
||||||
|
return { kind: 'redirect_waiting', paymentState: baseState, recovery: baseState }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefersRedirect && baseState.payUrl) {
|
||||||
|
return { kind: 'redirect_waiting', paymentState: baseState, recovery: baseState }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefersQr && baseState.qrCode) {
|
||||||
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
|
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
55
frontend/src/utils/__tests__/device.spec.ts
Normal file
55
frontend/src/utils/__tests__/device.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { detectMobileDevice } from '../device'
|
||||||
|
|
||||||
|
describe('detectMobileDevice', () => {
|
||||||
|
it('prefers userAgentData.mobile when available', () => {
|
||||||
|
expect(detectMobileDevice({
|
||||||
|
navigator: {
|
||||||
|
userAgent: 'Mozilla/5.0',
|
||||||
|
userAgentData: { mobile: true },
|
||||||
|
},
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recognizes handheld browsers from the mobile UA token', () => {
|
||||||
|
expect(detectMobileDevice({
|
||||||
|
navigator: {
|
||||||
|
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/136.0 Mobile Safari/537.36',
|
||||||
|
maxTouchPoints: 5,
|
||||||
|
},
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recognizes iPadOS desktop mode via touch capability', () => {
|
||||||
|
expect(detectMobileDevice({
|
||||||
|
navigator: {
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15',
|
||||||
|
platform: 'MacIntel',
|
||||||
|
maxTouchPoints: 5,
|
||||||
|
},
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to input capability detection for touch-first devices', () => {
|
||||||
|
expect(detectMobileDevice({
|
||||||
|
navigator: {
|
||||||
|
userAgent: 'Mozilla/5.0',
|
||||||
|
maxTouchPoints: 10,
|
||||||
|
},
|
||||||
|
matchMedia: (query) => ({
|
||||||
|
matches: query === '(pointer: coarse)' || query === '(hover: none)',
|
||||||
|
}),
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps desktop environments as non-mobile', () => {
|
||||||
|
expect(detectMobileDevice({
|
||||||
|
navigator: {
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/136.0 Safari/537.36',
|
||||||
|
platform: 'MacIntel',
|
||||||
|
maxTouchPoints: 0,
|
||||||
|
},
|
||||||
|
matchMedia: () => ({ matches: false }),
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,62 @@
|
|||||||
/**
|
interface NavigatorUADataLike {
|
||||||
* Detect whether the current device is mobile.
|
mobile?: boolean
|
||||||
* Uses navigator.userAgentData (modern API) with UA regex fallback.
|
}
|
||||||
*/
|
|
||||||
export function isMobileDevice(): boolean {
|
interface NavigatorLike {
|
||||||
const nav = navigator as unknown as Record<string, unknown>
|
userAgent?: string
|
||||||
if (nav.userAgentData && typeof (nav.userAgentData as Record<string, unknown>).mobile === 'boolean') {
|
platform?: string
|
||||||
return (nav.userAgentData as Record<string, unknown>).mobile as boolean
|
maxTouchPoints?: number
|
||||||
}
|
userAgentData?: NavigatorUADataLike
|
||||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
}
|
||||||
|
|
||||||
|
interface MediaQueryResultLike {
|
||||||
|
matches: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceDetectionEnvironment {
|
||||||
|
navigator?: NavigatorLike
|
||||||
|
matchMedia?: (query: string) => MediaQueryResultLike | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_UA_RE = /\b(Mobi|Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|IEMobile)\b/i
|
||||||
|
const TABLET_UA_RE = /\b(iPad|Tablet)\b/i
|
||||||
|
|
||||||
|
function matchesQuery(
|
||||||
|
matchMedia: DeviceDetectionEnvironment['matchMedia'],
|
||||||
|
query: string,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
return matchMedia?.(query)?.matches === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectMobileDevice(env: DeviceDetectionEnvironment = {}): boolean {
|
||||||
|
const nav = env.navigator
|
||||||
|
if (!nav) return false
|
||||||
|
|
||||||
|
if (nav.userAgentData?.mobile === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = nav.userAgent || ''
|
||||||
|
const maxTouchPoints = nav.maxTouchPoints ?? 0
|
||||||
|
const isIPadOSDesktopMode = nav.platform === 'MacIntel' && maxTouchPoints > 1
|
||||||
|
const isMobileUA = MOBILE_UA_RE.test(userAgent)
|
||||||
|
const isTabletUA = TABLET_UA_RE.test(userAgent) || isIPadOSDesktopMode
|
||||||
|
const coarsePointer = matchesQuery(env.matchMedia, '(pointer: coarse)')
|
||||||
|
const noHover = matchesQuery(env.matchMedia, '(hover: none)')
|
||||||
|
const hasTouch = maxTouchPoints > 0
|
||||||
|
|
||||||
|
return isMobileUA || isTabletUA || (coarsePointer && noHover && hasTouch)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMobileDevice(): boolean {
|
||||||
|
if (typeof navigator === 'undefined') return false
|
||||||
|
|
||||||
|
return detectMobileDevice({
|
||||||
|
navigator,
|
||||||
|
matchMedia: typeof window !== 'undefined' ? window.matchMedia.bind(window) : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -627,7 +627,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
if (options.wechatResumeToken) {
|
if (options.wechatResumeToken) {
|
||||||
payload.wechat_resume_token = options.wechatResumeToken
|
payload.wechat_resume_token = options.wechatResumeToken
|
||||||
}
|
}
|
||||||
payload.is_mobile = isMobileDevice()
|
|
||||||
|
|
||||||
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
|
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
|
||||||
const openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => {
|
const openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => {
|
||||||
@@ -653,6 +652,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
visibleMethod,
|
visibleMethod,
|
||||||
orderType,
|
orderType,
|
||||||
isMobile: isMobileDevice(),
|
isMobile: isMobileDevice(),
|
||||||
|
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
|
||||||
stripePopupUrl: stripeRouteUrl,
|
stripePopupUrl: stripeRouteUrl,
|
||||||
stripeRouteUrl,
|
stripeRouteUrl,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user