From 906802abe3ac8604aac2c0e199a3a5797f94f669 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Tue, 21 Apr 2026 09:22:40 -0700 Subject: [PATCH] Fix mobile payment launch detection --- .../payment/__tests__/paymentFlow.spec.ts | 29 ++++++++ .../src/components/payment/paymentFlow.ts | 19 ++++- frontend/src/utils/__tests__/device.spec.ts | 55 ++++++++++++++ frontend/src/utils/device.ts | 71 ++++++++++++++++--- frontend/src/views/user/PaymentView.vue | 2 +- 5 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 frontend/src/utils/__tests__/device.spec.ts diff --git a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts index 66c41fe3..7f4d6186 100644 --- a/frontend/src/components/payment/__tests__/paymentFlow.spec.ts +++ b/frontend/src/components/payment/__tests__/paymentFlow.spec.ts @@ -106,6 +106,35 @@ describe('decidePaymentLaunch', () => { 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', () => { const decision = decidePaymentLaunch(createOrderResult({ result_type: 'oauth_required', diff --git a/frontend/src/components/payment/paymentFlow.ts b/frontend/src/components/payment/paymentFlow.ts index 955fa3ba..7fbc1435 100644 --- a/frontend/src/components/payment/paymentFlow.ts +++ b/frontend/src/components/payment/paymentFlow.ts @@ -46,6 +46,7 @@ export interface PaymentLaunchContext { visibleMethod: string orderType: OrderType isMobile: boolean + isWechatBrowser?: boolean now?: number stripePopupUrl?: string stripeRouteUrl?: string @@ -159,7 +160,23 @@ export function decidePaymentLaunch( 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 } } diff --git a/frontend/src/utils/__tests__/device.spec.ts b/frontend/src/utils/__tests__/device.spec.ts new file mode 100644 index 00000000..fad14e75 --- /dev/null +++ b/frontend/src/utils/__tests__/device.spec.ts @@ -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) + }) +}) diff --git a/frontend/src/utils/device.ts b/frontend/src/utils/device.ts index 098bd90a..e60b10fd 100644 --- a/frontend/src/utils/device.ts +++ b/frontend/src/utils/device.ts @@ -1,11 +1,62 @@ -/** - * Detect whether the current device is mobile. - * Uses navigator.userAgentData (modern API) with UA regex fallback. - */ -export function isMobileDevice(): boolean { - const nav = navigator as unknown as Record - if (nav.userAgentData && typeof (nav.userAgentData as Record).mobile === 'boolean') { - return (nav.userAgentData as Record).mobile as boolean - } - return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) +interface NavigatorUADataLike { + mobile?: boolean +} + +interface NavigatorLike { + userAgent?: string + platform?: string + maxTouchPoints?: number + userAgentData?: NavigatorUADataLike +} + +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, + }) } diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index fd5c7b22..7d037917 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -627,7 +627,6 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n if (options.wechatResumeToken) { payload.wechat_resume_token = options.wechatResumeToken } - payload.is_mobile = isMobileDevice() const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string } const openWindow = (url: string) => { @@ -653,6 +652,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n visibleMethod, orderType, isMobile: isMobileDevice(), + isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent), stripePopupUrl: stripeRouteUrl, stripeRouteUrl, })