feat(subscription): load vip and credit plans from api

This commit is contained in:
2026-06-26 15:09:27 +08:00
parent abf6d5ae88
commit 49b67064f7
15 changed files with 748 additions and 50 deletions
+2
View File
@@ -6,9 +6,11 @@ export * from "./subscription-back-link";
export * from "./subscription-banner"; export * from "./subscription-banner";
export * from "./subscription-benefits-card"; export * from "./subscription-benefits-card";
export * from "./subscription-checkout-button"; export * from "./subscription-checkout-button";
export * from "./subscription-coins-offer-section";
export * from "./subscription-cta-button"; export * from "./subscription-cta-button";
export * from "./subscription-payment-method"; export * from "./subscription-payment-method";
export * from "./subscription-payment-success-dialog"; export * from "./subscription-payment-success-dialog";
export * from "./subscription-plan-card"; export * from "./subscription-plan-card";
export * from "./subscription-user-row"; export * from "./subscription-user-row";
export * from "./subscription-vip-offer-section";
export * from "./stripe-payment-dialog"; export * from "./stripe-payment-dialog";
@@ -0,0 +1,116 @@
.section {
margin: 18px -30px 0;
}
.heading {
position: relative;
padding: 14px 16px 18px;
background: #d9d9d9;
}
.title {
margin: 0;
color: #111111;
font-size: 22px;
font-weight: 800;
line-height: 1.2;
}
.notch {
position: absolute;
left: 50%;
bottom: -12px;
width: 24px;
height: 24px;
background: #d9d9d9;
transform: translateX(-50%) rotate(45deg);
}
.panel {
position: relative;
z-index: 1;
margin-top: 12px;
padding: 8px 16px 8px 12px;
border: 1px solid #8d8d8d;
border-radius: 14px;
background: #d9d9d9;
}
.row {
display: grid;
grid-template-columns: 50px minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-height: 42px;
padding: 0;
border: 0;
border-bottom: 3px solid #828282;
background: transparent;
color: #4f4f4f;
cursor: pointer;
font: inherit;
text-align: left;
transition: background-color 0.15s ease, color 0.15s ease;
}
.row:last-child {
border-bottom-width: 3px;
}
.row:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 2px;
}
.selected {
color: #111111;
}
.coinIcon {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
justify-self: center;
border-radius: 999px;
background: #ffffff;
color: #5f5f5f;
font-size: 12px;
line-height: 1;
text-transform: lowercase;
}
.coins {
min-width: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.2;
}
.price {
font-size: 16px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
@media (max-width: 360px) {
.title {
font-size: 20px;
}
.panel {
padding-right: 14px;
padding-left: 10px;
}
.row {
grid-template-columns: 46px minmax(0, 1fr) auto;
}
.coins,
.price {
font-size: 14px;
}
}
@@ -0,0 +1,54 @@
"use client";
import styles from "./subscription-coins-offer-section.module.css";
export interface CoinsOfferPlanView {
id: string;
coins: number;
price: string;
currency: string;
}
export interface SubscriptionCoinsOfferSectionProps {
plans: readonly CoinsOfferPlanView[];
selectedPlanId: string | null;
onSelectPlan: (planId: string) => void;
}
export function SubscriptionCoinsOfferSection({
plans,
selectedPlanId,
onSelectPlan,
}: SubscriptionCoinsOfferSectionProps) {
return (
<section className={styles.section} aria-label="Buy coins directly">
<div className={styles.heading}>
<h2 className={styles.title}></h2>
<span className={styles.notch} aria-hidden="true" />
</div>
<div className={styles.panel}>
{plans.map((plan) => {
const selected = selectedPlanId === plan.id;
return (
<button
key={plan.id}
type="button"
className={`${styles.row} ${selected ? styles.selected : ""}`}
aria-pressed={selected}
aria-label={`${plan.coins} Coins, ${plan.currency}${plan.price}`}
onClick={() => onSelectPlan(plan.id)}
>
<span className={styles.coinIcon}>coins</span>
<span className={styles.coins}>{plan.coins} Coins</span>
<span className={styles.price}>
{plan.currency}
{plan.price}
</span>
</button>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,139 @@
.section {
margin: 18px -30px 0;
}
.summary {
position: relative;
padding: 10px 10px 16px;
background: #d9d9d9;
}
.title {
display: flex;
align-items: baseline;
gap: 10px;
margin: 0;
line-height: 1.25;
}
.titleText {
color: #111111;
font-size: 20px;
font-weight: 800;
}
.renewText {
color: #7d7d7d;
font-size: 14px;
font-weight: 700;
}
.subtitle {
margin: 8px 0 0;
color: #666666;
font-size: 15px;
font-weight: 700;
line-height: 1.3;
}
.notch {
position: absolute;
left: 50%;
bottom: -12px;
width: 24px;
height: 24px;
background: #d9d9d9;
transform: translateX(-50%) rotate(45deg);
}
.planGrid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 14px 10px 0;
}
.planCard {
display: flex;
min-width: 0;
min-height: 116px;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px 6px 10px;
border: 0;
border-radius: 14px;
background: #f4f4f4;
color: #111111;
cursor: pointer;
font: inherit;
text-align: center;
transition: background-color 0.15s ease, transform 0.05s ease;
}
.planCard:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 2px;
}
.planCard:active {
transform: scale(0.98);
}
.selected {
background: #d9d9d9;
}
.planTitle {
font-size: 19px;
font-weight: 800;
line-height: 1.15;
white-space: nowrap;
}
.priceLine {
display: inline-flex;
align-items: flex-end;
justify-content: center;
margin-top: 4px;
line-height: 1;
}
.price {
font-size: 30px;
font-weight: 500;
letter-spacing: -0.8px;
}
.currency {
margin-bottom: 3px;
color: #777777;
font-size: 12px;
font-weight: 700;
text-transform: lowercase;
}
.originalPrice {
margin-top: 10px;
color: #7c7c7c;
font-size: 14px;
font-weight: 800;
line-height: 1;
text-decoration: line-through;
}
@media (max-width: 360px) {
.planGrid {
gap: 8px;
}
.planTitle {
font-size: 17px;
}
.price {
font-size: 27px;
}
}
@@ -0,0 +1,59 @@
"use client";
import styles from "./subscription-vip-offer-section.module.css";
export interface VipOfferPlanView {
id: string;
title: string;
price: string;
currency: string;
originalPrice: string;
}
export interface SubscriptionVipOfferSectionProps {
plans: readonly VipOfferPlanView[];
selectedPlanId: string | null;
onSelectPlan: (planId: string) => void;
}
export function SubscriptionVipOfferSection({
plans,
selectedPlanId,
onSelectPlan,
}: SubscriptionVipOfferSectionProps) {
return (
<section className={styles.section} aria-label="Choose a VIP plan">
<div className={styles.summary}>
<h1 className={styles.title}>
<span className={styles.titleText}>VIP会员</span>
<span className={styles.renewText}></span>
</h1>
<p className={styles.subtitle}>3000</p>
<span className={styles.notch} aria-hidden="true" />
</div>
<div className={styles.planGrid}>
{plans.map((plan) => {
const selected = selectedPlanId === plan.id;
return (
<button
key={plan.id}
type="button"
className={`${styles.planCard} ${selected ? styles.selected : ""}`}
aria-pressed={selected}
aria-label={`${plan.title}, ${plan.price} ${plan.currency}`}
onClick={() => onSelectPlan(plan.id)}
>
<span className={styles.planTitle}>{plan.title}</span>
<span className={styles.priceLine}>
<span className={styles.price}>{plan.price}</span>
<span className={styles.currency}>{plan.currency}</span>
</span>
<span className={styles.originalPrice}>{plan.originalPrice}</span>
</button>
);
})}
</div>
</section>
);
}
@@ -1,6 +1,8 @@
import type { PaymentPlan } from "@/data/dto/payment"; import type { PaymentPlan } from "@/data/dto/payment";
import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section";
import type { SubscriptionPlanView } from "./components/subscription-plan-card"; import type { SubscriptionPlanView } from "./components/subscription-plan-card";
import type { VipOfferPlanView } from "./components/subscription-vip-offer-section";
export type SubscriptionType = "vip" | "voice"; export type SubscriptionType = "vip" | "voice";
@@ -9,17 +11,29 @@ export const SUBSCRIPTION_BANNER_TITLES: Record<SubscriptionType, string> = {
voice: "Buy Voice Message Package", voice: "Buy Voice Message Package",
}; };
function isVipPlan(plan: PaymentPlan): boolean { export function isVipPlan(plan: PaymentPlan): boolean {
return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_"); return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_");
} }
export function isCreditPlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase();
const planId = plan.planId.toLowerCase();
return (
plan.dolAmount !== null ||
orderType.startsWith("dol_") ||
orderType.startsWith("credit_") ||
planId.startsWith("dol_") ||
planId.startsWith("credit_") ||
orderType.includes("credit") ||
planId.includes("credit")
);
}
function isVoicePackagePlan(plan: PaymentPlan): boolean { function isVoicePackagePlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase(); const orderType = plan.orderType.toLowerCase();
const planId = plan.planId.toLowerCase(); const planId = plan.planId.toLowerCase();
return ( return (
plan.dolAmount !== null || isCreditPlan(plan) ||
orderType.startsWith("dol_") ||
planId.startsWith("dol_") ||
orderType.includes("voice") || orderType.includes("voice") ||
planId.includes("voice") planId.includes("voice")
); );
@@ -35,8 +49,9 @@ export function isPlanForType(
} }
function currencySymbol(currency: string): string { function currencySymbol(currency: string): string {
if (currency === "CNY") return "¥"; const normalized = currency.toUpperCase();
if (currency === "USD") return "$"; if (normalized === "CNY") return "¥";
if (normalized === "USD") return "$";
return `${currency} `; return `${currency} `;
} }
@@ -46,6 +61,33 @@ function formatAmount(amountCents: number | null): string | null {
return (amountCents / 100).toFixed(2).replace(/\.?0+$/, ""); return (amountCents / 100).toFixed(2).replace(/\.?0+$/, "");
} }
function formatOfferAmount(amountCents: number | null): string {
return (formatAmount(amountCents) ?? "").replace(".", ",");
}
function formatOfferCurrency(currency: string): string {
const normalized = currency.toUpperCase();
if (normalized === "USD") return "usd";
return currency.toLowerCase();
}
function formatCoinCurrency(currency: string): string {
const normalized = currency.toUpperCase();
if (normalized === "USD") return "US$";
if (normalized === "CNY") return "¥";
return `${currency} `;
}
function vipOfferTitle(plan: PaymentPlan): string {
const key = `${plan.planId} ${plan.orderType} ${plan.planName}`.toLowerCase();
if (key.includes("year")) return "连续包年";
if (key.includes("quarter")) return "连续包季";
if (key.includes("month")) return "连续包月";
if (plan.vipDays !== null && plan.vipDays >= 300) return "连续包年";
if (plan.vipDays !== null && plan.vipDays >= 80) return "连续包季";
return plan.planName;
}
function planCaption( function planCaption(
plan: PaymentPlan, plan: PaymentPlan,
subscriptionType: SubscriptionType, subscriptionType: SubscriptionType,
@@ -79,3 +121,22 @@ export function toPlanView(
currencySymbol: currencySymbol(plan.currency), currencySymbol: currencySymbol(plan.currency),
}; };
} }
export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView {
return {
id: plan.planId,
title: vipOfferTitle(plan),
price: formatOfferAmount(plan.amountCents),
currency: formatOfferCurrency(plan.currency),
originalPrice: formatOfferAmount(plan.originalAmountCents),
};
}
export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView {
return {
id: plan.planId,
coins: plan.dolAmount ?? 0,
price: formatOfferAmount(plan.amountCents),
currency: formatCoinCurrency(plan.currency),
};
}
+82 -12
View File
@@ -17,7 +17,6 @@ import { useRouter } from "next/navigation";
import { Checkbox, MobileShell } from "@/app/_components/core"; import { Checkbox, MobileShell } from "@/app/_components/core";
import { AppConstants } from "@/core/app_constants"; import { AppConstants } from "@/core/app_constants";
import { VIP_BENEFITS } from "@/data/constants/vip-benefits";
import { PendingPaymentOrderStorage } from "@/data/storage"; import { PendingPaymentOrderStorage } from "@/data/storage";
import { import {
usePaymentDispatch, usePaymentDispatch,
@@ -29,18 +28,23 @@ import { ROUTES } from "@/router/routes";
import { import {
SubscriptionBackLink, SubscriptionBackLink,
SubscriptionBanner, SubscriptionBanner,
SubscriptionBenefitsCard,
SubscriptionCheckoutButton, SubscriptionCheckoutButton,
SubscriptionCoinsOfferSection,
SubscriptionPaymentMethod, SubscriptionPaymentMethod,
SubscriptionPaymentSuccessDialog, SubscriptionPaymentSuccessDialog,
SubscriptionPlanCard, SubscriptionPlanCard,
SubscriptionUserRow, SubscriptionUserRow,
SubscriptionVipOfferSection,
} from "./components"; } from "./components";
import styles from "./components/subscription-screen.module.css"; import styles from "./components/subscription-screen.module.css";
import { import {
SUBSCRIPTION_BANNER_TITLES, SUBSCRIPTION_BANNER_TITLES,
isCreditPlan,
isPlanForType, isPlanForType,
isVipPlan,
toCoinsOfferPlanView,
toPlanView, toPlanView,
toVipOfferPlanView,
type SubscriptionType, type SubscriptionType,
} from "./subscription-screen.helpers"; } from "./subscription-screen.helpers";
@@ -71,6 +75,7 @@ export function SubscriptionScreen({
useState(false); useState(false);
const [pendingChatReturnAfterVipSync, setPendingChatReturnAfterVipSync] = const [pendingChatReturnAfterVipSync, setPendingChatReturnAfterVipSync] =
useState(false); useState(false);
const isVipSubscription = subscriptionType === "vip";
useEffect(() => { useEffect(() => {
if (payment.status === "idle") { if (payment.status === "idle") {
@@ -187,9 +192,25 @@ export function SubscriptionScreen({
.map((plan) => toPlanView(plan, subscriptionType)), .map((plan) => toPlanView(plan, subscriptionType)),
[payment.plans, subscriptionType], [payment.plans, subscriptionType],
); );
const vipOfferPlans = useMemo(
() =>
payment.plans
.filter(isVipPlan)
.slice(0, 3)
.map(toVipOfferPlanView),
[payment.plans],
);
const directCoinsPlans = useMemo(
() => payment.plans.filter(isCreditPlan).map(toCoinsOfferPlanView),
[payment.plans],
);
const selectedPlan = const selectedPlan =
plans.find((plan) => plan.id === payment.selectedPlanId) ?? null; (isVipSubscription
? [...vipOfferPlans, ...directCoinsPlans].find(
(plan) => plan.id === payment.selectedPlanId,
)
: plans.find((plan) => plan.id === payment.selectedPlanId)) ?? null;
const isPaymentBusy = const isPaymentBusy =
payment.isCreatingOrder || payment.isCreatingOrder ||
payment.isPollingOrder || payment.isPollingOrder ||
@@ -199,9 +220,6 @@ export function SubscriptionScreen({
const name = user.currentUser?.username ?? FALLBACK_USERNAME; const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null; const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
const autoRenewCaption = payment.autoRenew
? "It will automatically renew upon expiration, and can be canceled at any time"
: "This plan is a one-time purchase and will not automatically renew";
const plansStatusMessage = const plansStatusMessage =
subscriptionType === "voice" subscriptionType === "voice"
? "Voice packages are unavailable." ? "Voice packages are unavailable."
@@ -260,6 +278,29 @@ export function SubscriptionScreen({
}, [pendingChatReturnAfterVipSync, subscriptionType, user.currentUser?.isVip]); }, [pendingChatReturnAfterVipSync, subscriptionType, user.currentUser?.isVip]);
useEffect(() => { useEffect(() => {
if (isVipSubscription) {
const firstVipPlanId = vipOfferPlans[0]?.id ?? "";
const hasSelectedVipPlan = vipOfferPlans.some(
(plan) => plan.id === payment.selectedPlanId,
);
const hasSelectedCoinsPlan = directCoinsPlans.some(
(plan) => plan.id === payment.selectedPlanId,
);
const canSelectPlan =
firstVipPlanId.length > 0 &&
(payment.status === "ready" ||
payment.status === "paid" ||
payment.status === "failed");
if (!canSelectPlan || hasSelectedVipPlan || hasSelectedCoinsPlan) return;
paymentDispatch({
type: "PaymentPlanSelected",
planId: firstVipPlanId,
});
return;
}
if (payment.isLoadingPlans || payment.plans.length === 0) return; if (payment.isLoadingPlans || payment.plans.length === 0) return;
if (selectedPlan !== null) return; if (selectedPlan !== null) return;
@@ -276,8 +317,13 @@ export function SubscriptionScreen({
payment.isLoadingPlans, payment.isLoadingPlans,
payment.plans, payment.plans,
paymentDispatch, paymentDispatch,
payment.selectedPlanId,
payment.status,
directCoinsPlans,
isVipSubscription,
selectedPlan, selectedPlan,
subscriptionType, subscriptionType,
vipOfferPlans,
]); ]);
return ( return (
@@ -287,6 +333,31 @@ export function SubscriptionScreen({
<SubscriptionBackLink className={styles.backSlot} /> <SubscriptionBackLink className={styles.backSlot} />
</header> </header>
{isVipSubscription ? (
<>
<SubscriptionVipOfferSection
plans={vipOfferPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
/>
<SubscriptionCoinsOfferSection
plans={directCoinsPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
/>
</>
) : (
<>
<section className={styles.userSlot}> <section className={styles.userSlot}>
<SubscriptionUserRow name={name} avatarUrl={avatarUrl} /> <SubscriptionUserRow name={name} avatarUrl={avatarUrl} />
</section> </section>
@@ -297,10 +368,7 @@ export function SubscriptionScreen({
/> />
</section> </section>
<section <section className={styles.plansRow} aria-label={plansAriaLabel}>
className={styles.plansRow}
aria-label={plansAriaLabel}
>
{plans.length > 0 ? ( {plans.length > 0 ? (
plans.map((plan, index) => ( plans.map((plan, index) => (
<SubscriptionPlanCard <SubscriptionPlanCard
@@ -324,8 +392,10 @@ export function SubscriptionScreen({
</p> </p>
)} )}
</section> </section>
</>
)}
<p className={styles.autoRenewCaption}>{autoRenewCaption}</p> {/* <p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
<section className={styles.benefitsSlot}> <section className={styles.benefitsSlot}>
{subscriptionType === "voice" ? ( {subscriptionType === "voice" ? (
@@ -336,7 +406,7 @@ export function SubscriptionScreen({
items={VIP_BENEFITS} items={VIP_BENEFITS}
/> />
)} )}
</section> </section> */}
<section className={styles.paymentMethodSlot}> <section className={styles.paymentMethodSlot}>
<SubscriptionPaymentMethod <SubscriptionPaymentMethod
+1
View File
@@ -15,6 +15,7 @@ export class PaymentPlan {
declare readonly originalAmountCents: number | null; declare readonly originalAmountCents: number | null;
declare readonly dailyPriceCents: number | null; declare readonly dailyPriceCents: number | null;
declare readonly currency: string; declare readonly currency: string;
declare readonly pricingTier: string;
declare readonly vipDays: number | null; declare readonly vipDays: number | null;
declare readonly dolAmount: number | null; declare readonly dolAmount: number | null;
@@ -0,0 +1,69 @@
{
"code": 200,
"message": "success",
"success": true,
"data": {
"plans": [
{
"plan_id": "credit_1000",
"plan_name": "1000 Credits",
"order_type": "credit_recharge",
"amount_cents": 990,
"original_amount_cents": null,
"daily_price_cents": null,
"currency": "USD",
"pricing_tier": "T1",
"vip_days": null,
"dol_amount": 1000
},
{
"plan_id": "credit_2000",
"plan_name": "2000 Credits",
"order_type": "credit_recharge",
"amount_cents": 1690,
"original_amount_cents": null,
"daily_price_cents": null,
"currency": "USD",
"pricing_tier": "T1",
"vip_days": null,
"dol_amount": 2000
},
{
"plan_id": "credit_3000",
"plan_name": "3000 Credits",
"order_type": "credit_recharge",
"amount_cents": 2290,
"original_amount_cents": null,
"daily_price_cents": null,
"currency": "USD",
"pricing_tier": "T1",
"vip_days": null,
"dol_amount": 3000
},
{
"plan_id": "credit_5000",
"plan_name": "5000 Credits",
"order_type": "credit_recharge",
"amount_cents": 3490,
"original_amount_cents": null,
"daily_price_cents": null,
"currency": "USD",
"pricing_tier": "T1",
"vip_days": null,
"dol_amount": 5000
},
{
"plan_id": "credit_10000",
"plan_name": "10000 Credits",
"order_type": "credit_recharge",
"amount_cents": 6490,
"original_amount_cents": null,
"daily_price_cents": null,
"currency": "USD",
"pricing_tier": "T1",
"vip_days": null,
"dol_amount": 10000
}
]
}
}
@@ -0,0 +1,45 @@
{
"code": 200,
"message": "success",
"success": true,
"data": {
"plans": [
{
"plan_id": "vip_monthly",
"plan_name": "月度会员",
"order_type": "vip_monthly",
"amount_cents": 1999,
"original_amount_cents": 2499,
"daily_price_cents": 66,
"currency": "USD",
"pricing_tier": "T1",
"vip_days": 30,
"dol_amount": null
},
{
"plan_id": "vip_quarterly",
"plan_name": "季度会员",
"order_type": "vip_quarterly",
"amount_cents": 4999,
"original_amount_cents": 5999,
"daily_price_cents": 55,
"currency": "USD",
"pricing_tier": "T1",
"vip_days": 90,
"dol_amount": null
},
{
"plan_id": "vip_yearly",
"plan_name": "年度会员",
"order_type": "vip_yearly",
"amount_cents": 17999,
"original_amount_cents": 19999,
"daily_price_cents": 49,
"currency": "USD",
"pricing_tier": "T1",
"vip_days": 365,
"dol_amount": null
}
]
}
}
@@ -7,6 +7,7 @@ import type {
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
PayChannel, PayChannel,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentPlan,
PaymentPlansResponse, PaymentPlansResponse,
PaymentVipStatusResponse, PaymentVipStatusResponse,
} from "@/data/dto/payment"; } from "@/data/dto/payment";
@@ -16,6 +17,12 @@ export interface IPaymentRepository {
/** 获取套餐列表。 */ /** 获取套餐列表。 */
getPlans(): Promise<Result<PaymentPlansResponse>>; getPlans(): Promise<Result<PaymentPlansResponse>>;
/** 获取会员套餐列表。 */
getVipPlans(): Promise<Result<PaymentPlan[]>>;
/** 获取积分套餐列表。 */
getCreditPlans(): Promise<Result<PaymentPlan[]>>;
/** 创建支付订单。 */ /** 创建支付订单。 */
createOrder( createOrder(
planId: string, planId: string,
@@ -10,6 +10,7 @@ import {
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
PayChannel, PayChannel,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentPlan,
PaymentPlansResponse, PaymentPlansResponse,
PaymentVipStatusResponse, PaymentVipStatusResponse,
} from "@/data/dto/payment"; } from "@/data/dto/payment";
@@ -25,6 +26,22 @@ export class PaymentRepository implements IPaymentRepository {
return Result.wrap(() => this.api.getPlans()); return Result.wrap(() => this.api.getPlans());
} }
/** 获取会员套餐列表。 */
async getVipPlans(): Promise<Result<PaymentPlan[]>> {
return Result.wrap(async () => {
const response = await this.api.getPlans();
return response.plans.filter(isVipPaymentPlan);
});
}
/** 获取积分套餐列表。 */
async getCreditPlans(): Promise<Result<PaymentPlan[]>> {
return Result.wrap(async () => {
const response = await this.api.getPlans();
return response.plans.filter(isCreditPaymentPlan);
});
}
/** 创建支付订单。 */ /** 创建支付订单。 */
async createOrder( async createOrder(
planId: string, planId: string,
@@ -54,3 +71,23 @@ export class PaymentRepository implements IPaymentRepository {
/** 全局单例。 */ /** 全局单例。 */
export const paymentRepository = new PaymentRepository(paymentApi); export const paymentRepository = new PaymentRepository(paymentApi);
export function isVipPaymentPlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase();
const planId = plan.planId.toLowerCase();
return orderType.startsWith("vip_") || planId.startsWith("vip_");
}
export function isCreditPaymentPlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase();
const planId = plan.planId.toLowerCase();
return (
plan.dolAmount !== null ||
orderType.startsWith("dol_") ||
orderType.startsWith("credit_") ||
planId.startsWith("dol_") ||
planId.startsWith("credit_") ||
orderType.includes("credit") ||
planId.includes("credit")
);
}
+3
View File
@@ -11,6 +11,7 @@ const PaymentPlanWireSchema = z.object({
original_amount_cents: z.number().nullable().default(null), original_amount_cents: z.number().nullable().default(null),
daily_price_cents: z.number().nullable().default(null), daily_price_cents: z.number().nullable().default(null),
currency: z.string(), currency: z.string(),
pricing_tier: z.string(),
vip_days: z.number().nullable(), vip_days: z.number().nullable(),
dol_amount: z.number().nullable(), dol_amount: z.number().nullable(),
}); });
@@ -28,6 +29,7 @@ export const PaymentPlanSchema = z
original_amount_cents: data.originalAmountCents, original_amount_cents: data.originalAmountCents,
daily_price_cents: data.dailyPriceCents, daily_price_cents: data.dailyPriceCents,
currency: data.currency, currency: data.currency,
pricing_tier: data.pricingTier,
vip_days: data.vipDays, vip_days: data.vipDays,
dol_amount: data.dolAmount, dol_amount: data.dolAmount,
}; };
@@ -42,6 +44,7 @@ export const PaymentPlanSchema = z
originalAmountCents: data.original_amount_cents, originalAmountCents: data.original_amount_cents,
dailyPriceCents: data.daily_price_cents, dailyPriceCents: data.daily_price_cents,
currency: data.currency, currency: data.currency,
pricingTier: data.pricing_tier,
vipDays: data.vip_days, vipDays: data.vip_days,
dolAmount: data.dol_amount, dolAmount: data.dol_amount,
})); }));
@@ -26,6 +26,7 @@ const monthlyPlan = {
original_amount_cents: null, original_amount_cents: null,
daily_price_cents: 33, daily_price_cents: 33,
currency: "usd", currency: "usd",
pricing_tier: "T1",
vip_days: 30, vip_days: 30,
dol_amount: null, dol_amount: null,
}; };
@@ -38,10 +39,24 @@ const lifetimePlan = {
original_amount_cents: null, original_amount_cents: null,
daily_price_cents: null, daily_price_cents: null,
currency: "usd", currency: "usd",
pricing_tier: "T1",
vip_days: null, vip_days: null,
dol_amount: null, dol_amount: null,
}; };
const creditPlan = {
plan_id: "credit_1000",
plan_name: "1000 Credits",
order_type: "credit_recharge",
amount_cents: 990,
original_amount_cents: null,
daily_price_cents: null,
currency: "usd",
pricing_tier: "T1",
vip_days: null,
dol_amount: 1000,
};
function createTestPaymentMachine( function createTestPaymentMachine(
overrides: Partial<{ overrides: Partial<{
createOrderSpy: CreateOrderSpy; createOrderSpy: CreateOrderSpy;
@@ -218,4 +233,19 @@ describe("paymentMachine", () => {
actor.stop(); actor.stop();
}); });
it("disables auto renew for credit recharge plans", async () => {
const actor = createActor(createTestPaymentMachine()).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentPlanSelected", planId: creditPlan.plan_id });
const context = actor.getSnapshot().context;
expect(context.selectedPlanId).toBe("credit_1000");
expect(context.autoRenew).toBe(false);
actor.stop();
});
}); });
+6 -1
View File
@@ -25,7 +25,12 @@ function toErrorMessage(error: unknown): string {
} }
function defaultAutoRenewForPlan(planId: string): boolean { function defaultAutoRenewForPlan(planId: string): boolean {
return !planId.includes("lifetime") && !planId.startsWith("dol_"); return (
!planId.includes("lifetime") &&
!planId.startsWith("dol_") &&
!planId.startsWith("points_") &&
!planId.startsWith("credit_")
);
} }
function getDefaultPlanId(plans: readonly PaymentPlan[]): string { function getDefaultPlanId(plans: readonly PaymentPlan[]): string {