Skip to content

Commit 6e9db92

Browse files
committed
feat: highlight annual billing discount
1 parent 0d8223c commit 6e9db92

3 files changed

Lines changed: 105 additions & 12 deletions

File tree

src/pages/user.tsx

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
ANNUAL_BILLING_MONTHS,
2222
type BillingOption,
2323
DEFAULT_MONTHLY_PRICE_FACTOR,
24+
getAnnualSavings,
2425
getBillingOptions,
2526
resolveMonthlyPriceFactor,
2627
} from '@/utils/billing';
@@ -72,14 +73,39 @@ function formatMoney(value: number) {
7273
return Number.isInteger(value) ? `¥${value}` : `¥${value.toFixed(2)}`;
7374
}
7475

75-
function formatBillingOptionLabel(option: BillingOption, showAmount = true) {
76+
function formatDiscount(value: number) {
77+
return Number.isInteger(value) ? String(value) : value.toFixed(1);
78+
}
79+
80+
function formatBillingOptionText(option: BillingOption, showAmount = true) {
7681
const amount = showAmount ? `,${formatMoney(option.amount)}` : '';
7782
if (option.billingCycle === 'year') {
7883
return `年付(${option.billingMonths}个月${amount})`;
7984
}
8085
return `${option.billingMonths}个月${showAmount ? `(${formatMoney(option.amount)})` : ''}`;
8186
}
8287

88+
function BillingOptionLabel({
89+
option,
90+
showAmount,
91+
}: {
92+
option: BillingOption;
93+
showAmount: boolean;
94+
}) {
95+
const savings = getAnnualSavings(option);
96+
97+
return (
98+
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
99+
<span>{formatBillingOptionText(option, showAmount)}</span>
100+
{savings.discount > 0 && (
101+
<Tag color="gold" className="m-0">
102+
{formatDiscount(savings.discount)}折优惠
103+
</Tag>
104+
)}
105+
</span>
106+
);
107+
}
108+
83109
function BillingMonthsSelect({
84110
disabled,
85111
onChange,
@@ -109,6 +135,12 @@ function BillingMonthsSelect({
109135
const selectedValue = options.some((option) => option.value === value)
110136
? value
111137
: fallbackValue;
138+
const selectedOption = options.find(
139+
(option) => option.value === selectedValue,
140+
);
141+
const selectedSavings = selectedOption
142+
? getAnnualSavings(selectedOption)
143+
: { amount: 0, percent: 0, discount: 0 };
112144

113145
useEffect(() => {
114146
if (selectedValue !== value) {
@@ -121,17 +153,24 @@ function BillingMonthsSelect({
121153
}
122154

123155
return (
124-
<Select
125-
aria-label="选择付费周期"
126-
className="w-full sm:w-60"
127-
disabled={disabled}
128-
onChange={onChange}
129-
options={options.map((option) => ({
130-
label: formatBillingOptionLabel(option, showAmount),
131-
value: option.value,
132-
}))}
133-
value={selectedValue}
134-
/>
156+
<div className="flex w-full flex-col gap-1 sm:w-72">
157+
<Select
158+
aria-label="选择付费周期"
159+
className="w-full"
160+
disabled={disabled}
161+
onChange={onChange}
162+
options={options.map((option) => ({
163+
label: <BillingOptionLabel option={option} showAmount={showAmount} />,
164+
value: option.value,
165+
}))}
166+
value={selectedValue}
167+
/>
168+
{selectedSavings.discount > 0 && (
169+
<div className="rounded border border-red-200 bg-red-50 px-2 py-1 text-xs font-medium text-red-600">
170+
{`年付比按月购买节省 ${formatMoney(selectedSavings.amount)},约${formatDiscount(selectedSavings.discount)}折优惠`}
171+
</div>
172+
)}
173+
</div>
135174
);
136175
}
137176

src/utils/billing.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from 'bun:test';
22
import {
33
DEFAULT_MONTHLY_PRICE_FACTOR,
4+
getAnnualSavings,
45
getBillingOptions,
56
resolveBillingPlan,
67
} from './billing';
@@ -28,6 +29,26 @@ describe('resolveBillingPlan', () => {
2829
});
2930
});
3031

32+
describe('getAnnualSavings', () => {
33+
test('returns annual savings compared with paying monthly', () => {
34+
const plan = resolveBillingPlan(800, 12, DEFAULT_MONTHLY_PRICE_FACTOR);
35+
const savings = getAnnualSavings(plan);
36+
37+
expect(savings.amount).toBe(400);
38+
expect(savings.percent).toBe(33);
39+
expect(savings.discount).toBe(6.7);
40+
});
41+
42+
test('does not show savings for monthly plans', () => {
43+
const plan = resolveBillingPlan(800, 1, DEFAULT_MONTHLY_PRICE_FACTOR);
44+
const savings = getAnnualSavings(plan);
45+
46+
expect(savings.amount).toBe(0);
47+
expect(savings.percent).toBe(0);
48+
expect(savings.discount).toBe(0);
49+
});
50+
});
51+
3152
describe('getBillingOptions', () => {
3253
test('removes month counts that would use annual billing', () => {
3354
const options = getBillingOptions({

src/utils/billing.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,20 @@ export interface BillingOption extends BillingPlan {
1818
value: number;
1919
}
2020

21+
export interface AnnualSavings {
22+
amount: number;
23+
percent: number;
24+
discount: number;
25+
}
26+
2127
function roundMoney(value: number) {
2228
return Math.round((value + Number.EPSILON) * 100) / 100;
2329
}
2430

31+
function roundDiscount(value: number) {
32+
return Math.round((value + Number.EPSILON) * 10) / 10;
33+
}
34+
2535
function positiveFiniteNumber(value: unknown) {
2636
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
2737
return undefined;
@@ -90,6 +100,29 @@ export function resolveBillingPlan(
90100
};
91101
}
92102

103+
export function getAnnualSavings(
104+
plan: Pick<
105+
BillingPlan,
106+
'amount' | 'billingCycle' | 'billingMonths' | 'monthlyPrice'
107+
>,
108+
): AnnualSavings {
109+
if (plan.billingCycle !== 'year') {
110+
return { amount: 0, percent: 0, discount: 0 };
111+
}
112+
113+
const monthlyTotal = roundMoney(plan.monthlyPrice * plan.billingMonths);
114+
const savingsAmount = roundMoney(monthlyTotal - plan.amount);
115+
if (monthlyTotal <= 0 || savingsAmount <= 0) {
116+
return { amount: 0, percent: 0, discount: 0 };
117+
}
118+
119+
return {
120+
amount: savingsAmount,
121+
percent: Math.round((savingsAmount / monthlyTotal) * 100),
122+
discount: roundDiscount((plan.amount / monthlyTotal) * 10),
123+
};
124+
}
125+
93126
export function getBillingOptions({
94127
annualBillingMonths = ANNUAL_BILLING_MONTHS,
95128
annualPrice,

0 commit comments

Comments
 (0)