extract recharge model
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
.platform-profile-recharge-modal-backdrop {
|
||||
background: var(--platform-overlay-fill);
|
||||
color: var(--platform-text-strong);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal,
|
||||
.platform-recharge-modal {
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
background: var(--platform-modal-fill);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
0 24px 80px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__close {
|
||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||
background: var(--platform-profile-chip-fill);
|
||||
color: var(--platform-text-soft);
|
||||
box-shadow: 0 10px 24px rgba(112, 57, 30, 0.08);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__close:hover:not(:disabled) {
|
||||
background: var(--platform-profile-chip-hover-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__subpanel {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: var(--platform-subpanel-fill);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__interactive-card {
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__interactive-card:hover:not(:disabled) {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
background: var(--platform-subpanel-fill);
|
||||
box-shadow: 0 12px 26px rgba(112, 57, 30, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__interactive-card:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--platform-button-primary-border);
|
||||
background: var(--platform-button-primary-fill);
|
||||
color: var(--platform-button-primary-text);
|
||||
box-shadow: var(--platform-profile-action-shadow);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__primary-button:hover:not(:disabled) {
|
||||
box-shadow: 0 16px 34px rgba(182, 98, 63, 0.28);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__status-error {
|
||||
border: 1px solid var(--platform-button-danger-border);
|
||||
background: var(--platform-button-danger-fill);
|
||||
color: var(--platform-button-danger-text);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__empty {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: var(--platform-subpanel-fill);
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
|
||||
.platform-profile-recharge-modal__badge-warning {
|
||||
border: 1px solid var(--platform-warm-border);
|
||||
background: var(--platform-warm-bg);
|
||||
color: var(--platform-warm-text);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileRechargeProduct } from '@/packages/shared/src';
|
||||
|
||||
import { PlatformProfileRechargeModal } from '../../index';
|
||||
|
||||
function buildNormalMembership() {
|
||||
return {
|
||||
status: 'normal' as const,
|
||||
tier: 'normal' as const,
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
cycleStartedAt: null,
|
||||
cycleResetsAt: null,
|
||||
cycleGrantedPoints: 0,
|
||||
cycleRemainingPoints: 0,
|
||||
cyclePeriodDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPointProduct(
|
||||
overrides: Partial<ProfileRechargeProduct> = {},
|
||||
): ProfileRechargeProduct {
|
||||
return {
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points' as const,
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 0,
|
||||
durationDays: 0,
|
||||
badgeLabel: '',
|
||||
description: '60泥点',
|
||||
tier: 'normal' as const,
|
||||
membershipPeriodPoints: 0,
|
||||
membershipPeriodDays: 0,
|
||||
membershipQueueLimit: 0,
|
||||
membershipDiscountBps: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPointProducts(): ProfileRechargeProduct[] {
|
||||
return [
|
||||
buildPointProduct(),
|
||||
buildPointProduct({
|
||||
productId: 'points_180',
|
||||
title: '180泥点',
|
||||
priceCents: 1800,
|
||||
pointsAmount: 180,
|
||||
bonusPoints: 90,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠90泥点',
|
||||
}),
|
||||
buildPointProduct({
|
||||
productId: 'points_300',
|
||||
title: '300泥点',
|
||||
priceCents: 3000,
|
||||
pointsAmount: 300,
|
||||
bonusPoints: 150,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠150泥点',
|
||||
}),
|
||||
buildPointProduct({
|
||||
productId: 'points_680',
|
||||
title: '680泥点',
|
||||
priceCents: 6800,
|
||||
pointsAmount: 680,
|
||||
bonusPoints: 340,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠340泥点',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function buildMembershipProduct(): ProfileRechargeProduct {
|
||||
return {
|
||||
...buildPointProduct(),
|
||||
productId: 'member_month',
|
||||
title: '会员月卡',
|
||||
priceCents: 2800,
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
durationDays: 30,
|
||||
description: '会员月卡',
|
||||
tier: 'month',
|
||||
membershipPeriodDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn(async () => 'data:image/png;base64,wechat-native-qr'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PlatformProfileRechargeModal', () => {
|
||||
test('renders the four point products and forwards the selected buy action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBuy = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 29,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: buildPointProducts(),
|
||||
membershipProducts: [buildMembershipProduct()],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={null}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={onBuy}
|
||||
onConfirmNativePayment={vi.fn()}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '购买更多泥点' });
|
||||
expect(within(dialog).getByText('当前余额 29 泥点')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '180泥点 +90泥点 ¥18 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '300泥点 +150泥点 ¥30 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '680泥点 +340泥点 ¥68 购买',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getAllByText('首充加赠')).toHaveLength(3);
|
||||
expect(within(dialog).queryByRole('tablist')).toBeNull();
|
||||
expect(within(dialog).queryByText('会员月卡')).toBeNull();
|
||||
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', {
|
||||
name: '180泥点 +90泥点 ¥18 购买',
|
||||
}),
|
||||
);
|
||||
expect(onBuy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ productId: 'points_180' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the point-product empty state without exposing membership purchase', () => {
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 0,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [buildMembershipProduct()],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={null}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={vi.fn()}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
|
||||
expect(screen.queryByText('会员月卡')).toBeNull();
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
|
||||
test('opens native payment QR code in a separate dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirmNativePayment = vi.fn();
|
||||
const onCloseNativePayment = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 12,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={{
|
||||
orderId: 'rcg_native_001',
|
||||
productTitle: '100泥点',
|
||||
amountCents: 1000,
|
||||
codeUrl: 'weixin://wxpay/bizpayurl?pr=test',
|
||||
expiresAt: '2099-01-01T00:05:00Z',
|
||||
isConfirming: false,
|
||||
confirmMessage: '暂未确认到账,请确认付款完成后再点一次。',
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={onConfirmNativePayment}
|
||||
onCloseNativePayment={onCloseNativePayment}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rechargeDialog = screen.getByRole('dialog', {
|
||||
name: '购买更多泥点',
|
||||
});
|
||||
const paymentDialog = screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
});
|
||||
|
||||
expect(
|
||||
within(rechargeDialog).queryByAltText('微信 Native 支付二维码'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await within(paymentDialog).findByAltText('微信 Native 支付二维码'),
|
||||
).toBeTruthy();
|
||||
expect(within(paymentDialog).getByText('100泥点')).toBeTruthy();
|
||||
expect(within(paymentDialog).getByText('¥10')).toBeTruthy();
|
||||
expect(
|
||||
within(paymentDialog).getByText(
|
||||
'暂未确认到账,请确认付款完成后再点一次。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
within(paymentDialog).getByRole('button', { name: '我已支付' }),
|
||||
);
|
||||
expect(onConfirmNativePayment).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(
|
||||
within(paymentDialog).getByRole('button', {
|
||||
name: '关闭微信扫码支付',
|
||||
}),
|
||||
);
|
||||
expect(onCloseNativePayment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows expired native payment state when qr countdown ends', async () => {
|
||||
const onConfirmNativePayment = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 12,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={{
|
||||
orderId: 'rcg_native_expired',
|
||||
productTitle: '100泥点',
|
||||
amountCents: 1000,
|
||||
codeUrl: 'weixin://wxpay/bizpayurl?pr=expired',
|
||||
expiresAt: '2000-01-01T00:00:00Z',
|
||||
isConfirming: false,
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={onConfirmNativePayment}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const paymentDialog = screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
});
|
||||
const confirmButton = within(paymentDialog).getByRole('button', {
|
||||
name: '已过期',
|
||||
});
|
||||
|
||||
expect(
|
||||
await within(paymentDialog).findByAltText('微信 Native 支付二维码'),
|
||||
).toBeTruthy();
|
||||
expect(within(paymentDialog).getAllByText('已过期')).toHaveLength(2);
|
||||
expect(confirmButton).toHaveProperty('disabled', true);
|
||||
expect(onConfirmNativePayment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,3 +53,5 @@ export type * from './contracts/visualNovel';
|
||||
export * from './http';
|
||||
export * from './llm/narrativeLanguage';
|
||||
export * from './llm/parsers';
|
||||
|
||||
// should not export component, instead, they should be ref by relative path
|
||||
|
||||
@@ -8,3 +8,8 @@ export function formatMudPointCount(value: number, compact = false) {
|
||||
}
|
||||
return normalizedValue.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user