feat: gate recharge payment by login device
This commit is contained in:
@@ -401,6 +401,8 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
}));
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const originalUserAgent = navigator.userAgent;
|
||||
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
|
||||
@@ -629,6 +631,37 @@ function mockDesktopLayout() {
|
||||
});
|
||||
}
|
||||
|
||||
function mockWechatDesktopLayout() {
|
||||
mockDesktopLayout();
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/8.0',
|
||||
});
|
||||
}
|
||||
|
||||
function mockWechatMobileLayout() {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit MicroMessenger/8.0 Mobile',
|
||||
});
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: '(max-width: 767px)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
@@ -1013,6 +1046,14 @@ afterEach(() => {
|
||||
writable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: originalUserAgent,
|
||||
});
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', {
|
||||
configurable: true,
|
||||
value: originalMaxTouchPoints,
|
||||
});
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
@@ -1046,7 +1087,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
|
||||
test('profile recharge modal shows native qr code on desktop web by default', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDesktopLayout();
|
||||
mockWechatDesktopLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-native-1',
|
||||
@@ -1111,24 +1152,7 @@ test('profile recharge modal shows native qr code on desktop web by default', as
|
||||
|
||||
test('profile recharge modal jumps to h5 payment on mobile web by default', async () => {
|
||||
const user = userEvent.setup();
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
|
||||
});
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: '(max-width: 767px)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
mockWechatMobileLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-h5-1',
|
||||
@@ -1603,7 +1627,7 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
|
||||
test('profile native qr confirmation refreshes only after server reports paid', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
mockDesktopLayout();
|
||||
mockWechatDesktopLayout();
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-native-paid',
|
||||
@@ -1687,6 +1711,23 @@ test('profile native qr confirmation refreshes only after server reports paid',
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('non-wechat profile shows reward code instead of recharge entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', { name: /充值/u }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /兑换码/u }),
|
||||
).toBeTruthy();
|
||||
await user.click(within(shortcutRegion).getByRole('button', { name: /兑换码/u }));
|
||||
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('profile daily task shortcut opens task center and claims reward', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
@@ -1925,7 +1966,10 @@ test('opens reward code modal from profile action on mobile', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByRole('button', { name: /兑换码/u }));
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /兑换码/u }),
|
||||
);
|
||||
|
||||
const modal = await screen.findByPlaceholderText('输入兑换码');
|
||||
expect(modal).toBeTruthy();
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import {
|
||||
resolveProfileRechargePaymentChannel,
|
||||
shouldShowRechargeEntry,
|
||||
WECHAT_H5_PAYMENT_CHANNEL,
|
||||
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
|
||||
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
||||
@@ -3468,6 +3469,7 @@ export function RpgEntryHomeView({
|
||||
hasUnreadDraftUpdate = false,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const showRechargeEntry = shouldShowRechargeEntry();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
|
||||
@@ -4096,6 +4098,14 @@ export function RpgEntryHomeView({
|
||||
setIsRechargeOpen(true);
|
||||
loadRechargeCenter();
|
||||
};
|
||||
const openRechargeOrRewardCodeModal = () => {
|
||||
if (showRechargeEntry) {
|
||||
openRechargeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
openRewardCodeModal();
|
||||
};
|
||||
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
@@ -5463,13 +5473,21 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargeModal}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Coins className="h-4 w-4" />
|
||||
{showRechargeEntry ? (
|
||||
<Coins className="h-4 w-4" />
|
||||
) : (
|
||||
<Ticket className="h-4 w-4" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs font-bold">充值</div>
|
||||
<div className="text-[10px] opacity-80">泥点/会员</div>
|
||||
<div className="text-xs font-bold">
|
||||
{showRechargeEntry ? '充值' : '兑换码'}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{showRechargeEntry ? '泥点/会员' : '福利奖励'}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -5558,17 +5576,19 @@ export function RpgEntryHomeView({
|
||||
onClick={openTaskCenterPanel}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="充值"
|
||||
subLabel="泥点/会员"
|
||||
icon={Coins}
|
||||
onClick={openRechargeModal}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="福利奖励"
|
||||
icon={Ticket}
|
||||
onClick={openRewardCodeModal}
|
||||
label={showRechargeEntry ? '充值' : '兑换码'}
|
||||
subLabel={showRechargeEntry ? '泥点/会员' : '福利奖励'}
|
||||
icon={showRechargeEntry ? Coins : Ticket}
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
/>
|
||||
{showRechargeEntry ? (
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="福利奖励"
|
||||
icon={Ticket}
|
||||
onClick={openRewardCodeModal}
|
||||
/>
|
||||
) : null}
|
||||
<ProfileShortcutButton
|
||||
label="邀请好友"
|
||||
subLabel={
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveProfileRechargePaymentChannel,
|
||||
shouldShowRechargeEntry,
|
||||
WECHAT_H5_PAYMENT_CHANNEL,
|
||||
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
|
||||
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
||||
@@ -51,6 +52,19 @@ describe('resolveProfileRechargePaymentChannel', () => {
|
||||
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('桌面微信内网页选择 wechat_native', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '' },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit MicroMessenger/8.0',
|
||||
},
|
||||
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
|
||||
}),
|
||||
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('默认路径永远不会解析成 mock', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
@@ -61,3 +75,44 @@ describe('resolveProfileRechargePaymentChannel', () => {
|
||||
).not.toBe('mock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowRechargeEntry', () => {
|
||||
test('小程序运行态显示充值入口', () => {
|
||||
expect(
|
||||
shouldShowRechargeEntry({
|
||||
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('微信内网页显示充值入口', () => {
|
||||
expect(
|
||||
shouldShowRechargeEntry({
|
||||
location: { search: '' },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile',
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('普通浏览器不显示充值入口', () => {
|
||||
expect(
|
||||
shouldShowRechargeEntry({
|
||||
location: { search: '' },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowRechargeEntry({
|
||||
location: { search: '' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,20 @@ export type PaymentPlatformContext = {
|
||||
matchMedia?: Window['matchMedia'] | null;
|
||||
};
|
||||
|
||||
export function shouldShowRechargeEntry(
|
||||
context: PaymentPlatformContext = {},
|
||||
) {
|
||||
const location =
|
||||
context.location ?? (typeof window !== 'undefined' ? window.location : null);
|
||||
const navigatorLike =
|
||||
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
|
||||
|
||||
return (
|
||||
isWechatMiniProgramRuntime(location) ||
|
||||
isWechatBrowserRuntime(navigatorLike)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveProfileRechargePaymentChannel(
|
||||
context: PaymentPlatformContext = {},
|
||||
): ProfileRechargeWechatPaymentChannel {
|
||||
@@ -55,16 +69,21 @@ function isWechatMiniProgramRuntime(
|
||||
);
|
||||
}
|
||||
|
||||
function isWechatBrowserRuntime(
|
||||
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
|
||||
) {
|
||||
return (
|
||||
navigatorLike?.userAgent?.toLowerCase().includes('micromessenger') ??
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function isMobileWebRuntime(
|
||||
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
|
||||
matchMedia: Window['matchMedia'] | null | undefined,
|
||||
) {
|
||||
const userAgent = navigatorLike?.userAgent?.toLowerCase() ?? '';
|
||||
if (
|
||||
/android|iphone|ipad|ipod|mobile|micromessenger|windows phone/u.test(
|
||||
userAgent,
|
||||
)
|
||||
) {
|
||||
if (/android|iphone|ipad|ipod|mobile|windows phone/u.test(userAgent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user