feat(payment): sync plans and add coins rules

This commit is contained in:
2026-06-29 14:11:50 +08:00
parent 274fe87ced
commit e372d58b4c
24 changed files with 1099 additions and 224 deletions
+34
View File
@@ -0,0 +1,34 @@
GET https://proapi.banlv-ai.com/api/payment/plans
结果:
status=200
success=true
plans 数量 8
VIP 套餐 3 个
credits 套餐 5 个
snake_keys=[],已经没有 plan_id / amount_cents 这种字段
schema 校验通过:proapi_plans_camel_schema_ok true
前端按这个结构用:
type PaymentPlan = {
planId: string;
planName: string;
orderType: 'vip_monthly' | 'vip_quarterly' | 'vip_annual' | 'dol';
vipDays: number | null;
dolAmount: number | null;
creditBalance: number;
amountCents: number;
originalAmountCents: number | null;
dailyPriceCents: number | null;
currency: string;
};
分组:
const plans = res.data.plans;
const vipPlans = plans.filter(p => p.vipDays !== null);
const creditPlans = plans.filter(p => p.dolAmount !== null);
下单仍然传 planId
{
"planId": "vip_monthly",
"payChannel": "stripe",
"autoRenew": true
}
价格、币种全部以前端拿到的 amountCents / currency 展示,不要写死。当前我这边访问 pro 返回的是 JPY,这是 Cloudflare 根据访问 IP 判的国家币种。
@@ -0,0 +1,259 @@
.screen {
min-height: 100dvh;
box-sizing: border-box;
padding: 18px 20px 28px;
background:
radial-gradient(circle at 16% 5%, rgba(255, 196, 143, 0.2), transparent 28%),
radial-gradient(circle at 86% 8%, rgba(246, 87, 160, 0.2), transparent 26%),
linear-gradient(180deg, #fcf3f4 0%, #fffafa 48%, #fff 100%);
color: #191316;
}
.header {
display: flex;
flex-direction: column;
gap: 18px;
}
.backLink {
display: inline-flex;
width: 42px;
height: 42px;
align-items: center;
justify-content: center;
color: #21191d;
text-decoration: none;
border: 1px solid rgba(25, 19, 22, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 10px 22px rgba(50, 30, 40, 0.08);
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.16s ease;
}
.backLink:hover {
background: #ffffff;
box-shadow: 0 14px 26px rgba(50, 30, 40, 0.12);
transform: translateY(-1px);
}
.backLink:active {
transform: translateY(1px);
}
.backLink:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 3px;
}
.hero {
position: relative;
overflow: hidden;
padding: 22px;
border: 1px solid rgba(246, 87, 160, 0.14);
border-radius: 28px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 237, 246, 0.94) 100%),
#ffffff;
box-shadow: 0 18px 46px rgba(86, 45, 62, 0.1);
}
.hero::after {
position: absolute;
right: -34px;
bottom: -42px;
width: 150px;
height: 150px;
border-radius: 999px;
background: radial-gradient(circle, rgba(246, 87, 160, 0.2), transparent 68%);
content: "";
}
.heroIcon {
display: inline-flex;
width: 46px;
height: 46px;
align-items: center;
justify-content: center;
border-radius: 16px;
background: linear-gradient(180deg, #ffe89c 0%, #f8b83e 100%);
color: #8f5400;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
0 12px 24px rgba(216, 132, 22, 0.2);
}
.eyebrow {
margin: 16px 0 0;
color: #f657a0;
font-size: 12px;
font-weight: 850;
letter-spacing: 0.12em;
line-height: 1;
text-transform: uppercase;
}
.title {
margin: 8px 0 0;
color: #171114;
font-size: 30px;
font-weight: 900;
letter-spacing: -0.8px;
line-height: 1.08;
}
.subtitle {
max-width: 300px;
margin: 10px 0 0;
color: #6d5d64;
font-size: 15px;
font-weight: 650;
line-height: 1.55;
}
.freeCard {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
margin-top: 16px;
padding: 16px;
border: 1px solid rgba(248, 184, 62, 0.24);
border-radius: 22px;
background: rgba(255, 250, 235, 0.84);
box-shadow: 0 12px 28px rgba(142, 93, 18, 0.08);
}
.freeIcon {
display: inline-flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: #ffffff;
color: #d98100;
box-shadow: 0 8px 18px rgba(142, 93, 18, 0.1);
}
.freeTitle {
margin: 0;
color: #2a1f14;
font-size: 16px;
font-weight: 850;
line-height: 1.1;
}
.freeText {
margin: 5px 0 0;
color: #7a6040;
font-size: 13px;
font-weight: 650;
line-height: 1.45;
}
.ruleList {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
.ruleCard {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 14px;
border: 1px solid rgba(25, 19, 22, 0.06);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10px 26px rgba(55, 36, 44, 0.06);
animation: cardIn 0.42s ease both;
transition: box-shadow 0.18s ease, transform 0.18s ease;
}
.ruleCard:hover {
box-shadow: 0 15px 32px rgba(55, 36, 44, 0.1);
transform: translateY(-2px);
}
.ruleIcon {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: #fff0f6;
color: #f657a0;
}
.ruleContent {
min-width: 0;
}
.ruleTitle {
margin: 0;
color: #21191d;
font-size: 16px;
font-weight: 850;
line-height: 1.15;
}
.freeBadge {
display: inline-flex;
margin: 7px 0 0;
padding: 4px 9px;
border-radius: 999px;
background: #edf4ff;
color: #2e6eea;
font-size: 12px;
font-weight: 780;
line-height: 1;
}
.cost {
margin: 0;
color: #171114;
font-size: 15px;
font-weight: 900;
line-height: 1.15;
text-align: right;
white-space: nowrap;
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 360px) {
.screen {
padding-right: 16px;
padding-left: 16px;
}
.hero {
padding: 20px;
}
.title {
font-size: 27px;
}
.ruleCard {
grid-template-columns: auto minmax(0, 1fr);
}
.cost {
grid-column: 2;
text-align: left;
}
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import {
ArrowLeft,
Coins,
Gift,
ImageIcon,
MessageCircle,
Mic2,
Phone,
Sparkles,
} from "lucide-react";
import Link from "next/link";
import { MobileShell } from "@/app/_components/core";
import { ROUTES } from "@/router/routes";
import styles from "./coins-rules-screen.module.css";
interface CoinRuleItem {
title: string;
cost: string;
free?: string;
icon: typeof MessageCircle;
}
const COIN_RULES: readonly CoinRuleItem[] = [
{
title: "普通聊天",
cost: "2 coins / 条",
free: "每周免费 50 条",
icon: MessageCircle,
},
{
title: "私密聊天",
cost: "10 coins / 条",
free: "每天免费 1 条",
icon: Sparkles,
},
{
title: "语音消息",
cost: "20 coins / 条",
icon: Mic2,
},
{
title: "语音电话",
cost: "20 coins / 张",
icon: Phone,
},
{
title: "照片",
cost: "40 coins / 张",
icon: ImageIcon,
},
{
title: "私密相册",
cost: "10 张 300 coins",
icon: ImageIcon,
},
{
title: "私密相册",
cost: "20 张 600 coins",
icon: ImageIcon,
},
] as const;
export function CoinsRulesScreen() {
return (
<MobileShell background="#fcf3f4">
<main className={styles.screen}>
<header className={styles.header}>
<Link
href={ROUTES.sidebar}
className={styles.backLink}
aria-label="Back to sidebar"
>
<ArrowLeft size={22} strokeWidth={2.4} aria-hidden="true" />
</Link>
<div className={styles.hero}>
<span className={styles.heroIcon} aria-hidden="true">
<Coins size={24} strokeWidth={2.4} />
</span>
<p className={styles.eyebrow}>Coins Guide</p>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}>
</p>
</div>
</header>
<section className={styles.freeCard} aria-label="Free quota">
<div className={styles.freeIcon} aria-hidden="true">
<Gift size={20} strokeWidth={2.3} />
</div>
<div>
<h2 className={styles.freeTitle}></h2>
<p className={styles.freeText}>
50 1
</p>
</div>
</section>
<section className={styles.ruleList} aria-label="Coins usage rules">
{COIN_RULES.map((rule, index) => {
const Icon = rule.icon;
return (
<article
key={`${rule.title}-${rule.cost}`}
className={styles.ruleCard}
style={{ animationDelay: `${index * 45}ms` }}
>
<span className={styles.ruleIcon} aria-hidden="true">
<Icon size={18} strokeWidth={2.35} />
</span>
<div className={styles.ruleContent}>
<h2 className={styles.ruleTitle}>{rule.title}</h2>
{rule.free ? (
<p className={styles.freeBadge}>{rule.free}</p>
) : null}
</div>
<p className={styles.cost}>{rule.cost}</p>
</article>
);
})}
</section>
</main>
</MobileShell>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { CoinsRulesScreen } from "./coins-rules-screen";
export default function CoinsRulesPage() {
return <CoinsRulesScreen />;
}
@@ -1,24 +1,230 @@
/* ============================================================
* 侧边栏主屏幕(浅色主题)
* ============================================================ */
.shell { .shell {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--color-page-background, #ffffff); gap: 14px;
color: var(--color-auth-text-primary, #333333); overflow: hidden;
min-height: 100dvh; min-height: 100dvh;
padding: 0 var(--page-padding-x, 28px) var(--spacing-xxl, 24px);
box-sizing: border-box; box-sizing: border-box;
padding: 0 var(--page-padding-x, 28px) 28px;
background:
radial-gradient(circle at 18% 6%, rgba(255, 206, 160, 0.22), transparent 28%),
radial-gradient(circle at 92% 0%, rgba(246, 87, 160, 0.22), transparent 30%),
linear-gradient(180deg, #fcf3f4 0%, #fffafa 44%, #ffffff 100%);
color: #191316;
}
.bgOrbOne,
.bgOrbTwo {
position: absolute;
z-index: 0;
pointer-events: none;
border-radius: 999px;
filter: blur(4px);
}
.bgOrbOne {
top: 86px;
right: -72px;
width: 168px;
height: 168px;
background: rgba(246, 87, 160, 0.11);
}
.bgOrbTwo {
bottom: 56px;
left: -88px;
width: 180px;
height: 180px;
background: rgba(248, 184, 62, 0.1);
}
.topBar,
.hero,
.userSlot,
.cardSlot,
.settingsSlot {
position: relative;
z-index: 1;
}
.topBar {
margin: 4px 0 -2px;
}
.hero {
padding: 12px 2px 6px;
}
.kicker {
margin: 0;
color: #f657a0;
font-size: 12px;
font-weight: 850;
letter-spacing: 0.14em;
line-height: 1;
text-transform: uppercase;
}
.title {
margin: 8px 0 0;
color: #171114;
font-size: 34px;
font-weight: 920;
letter-spacing: -1px;
line-height: 1;
}
.subtitle {
max-width: 320px;
margin: 10px 0 0;
color: #75636a;
font-size: 14px;
font-weight: 650;
line-height: 1.5;
} }
.userSlot { .userSlot {
padding: var(--sidebar-user-padding-y, 20px) 0; padding: 18px;
border: 1px solid rgba(25, 19, 22, 0.06);
border-radius: 26px;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 16px 40px rgba(55, 36, 44, 0.08);
backdrop-filter: blur(18px);
} }
.cardSlot { .cardSlot {
padding: var(--sidebar-card-gap-y, 8px) 0; padding: 0;
} }
.settingsSlot { .settingsSlot {
padding: var(--spacing-lg, 16px) 0 0; margin-top: 2px;
padding: 2px 0 0;
}
.settingsLabel {
margin: 0 0 9px;
padding: 0 4px;
color: #817076;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.logoutCard {
display: flex;
width: 100%;
align-items: center;
gap: 12px;
box-sizing: border-box;
padding: 16px;
border: 1px solid rgba(25, 19, 22, 0.06);
border-radius: 22px;
background: rgba(255, 255, 255, 0.86);
color: #171114;
cursor: pointer;
font: inherit;
text-align: left;
box-shadow: 0 12px 30px rgba(55, 36, 44, 0.06);
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.16s ease;
}
.logoutCard:hover {
background: #ffffff;
box-shadow: 0 16px 34px rgba(55, 36, 44, 0.1);
transform: translateY(-1px);
}
.logoutCard:active {
transform: translateY(1px);
}
.logoutCard:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 3px;
}
.logoutIcon {
display: inline-flex;
width: 40px;
height: 40px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 15px;
background: rgba(255, 107, 107, 0.1);
color: #ef4d64;
}
.logoutText {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.logoutTitle {
color: #ef4d64;
font-size: 16px;
font-weight: 760;
line-height: 1.15;
}
.logoutSubtitle {
color: #817076;
font-size: 13px;
font-weight: 620;
line-height: 1.25;
}
.revealOne,
.revealTwo,
.revealThree {
animation: revealUp 0.42s ease both;
}
.revealOne {
animation-delay: 40ms;
}
.revealTwo {
animation-delay: 95ms;
}
.revealThree {
animation-delay: 150ms;
}
@keyframes revealUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 360px) {
.shell {
gap: 12px;
padding-right: 20px;
padding-left: 20px;
}
.title {
font-size: 30px;
}
.subtitle {
font-size: 13px;
}
.userSlot {
padding: 16px;
border-radius: 22px;
}
} }
@@ -7,6 +7,7 @@ import styles from "./sidebar-wallet-card.module.css";
export interface SidebarWalletCardProps { export interface SidebarWalletCardProps {
creditBalance: number; creditBalance: number;
onTopUp: () => void; onTopUp: () => void;
onRulesClick: () => void;
} }
const formatCoins = (value: number): string => const formatCoins = (value: number): string =>
@@ -15,6 +16,7 @@ const formatCoins = (value: number): string =>
export function SidebarWalletCard({ export function SidebarWalletCard({
creditBalance, creditBalance,
onTopUp, onTopUp,
onRulesClick,
}: SidebarWalletCardProps) { }: SidebarWalletCardProps) {
return ( return (
<section className={styles.card} aria-label="My wallet"> <section className={styles.card} aria-label="My wallet">
@@ -31,7 +33,11 @@ export function SidebarWalletCard({
<span className={styles.balanceUnit}> coins</span> <span className={styles.balanceUnit}> coins</span>
</p> </p>
<button type="button" className={styles.rulesButton}> <button
type="button"
className={styles.rulesButton}
onClick={onRulesClick}
>
Coins Coins
</button> </button>
</div> </div>
+41 -18
View File
@@ -1,9 +1,10 @@
"use client"; "use client";
import { LogOut } from "lucide-react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MobileShell, SettingsSection } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { useUserDispatch, useUserState } from "@/stores/user/user-context";
@@ -20,6 +21,12 @@ import styles from "./components/sidebar-screen.module.css";
const FALLBACK_USERNAME = "User name"; const FALLBACK_USERNAME = "User name";
const VIP_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=vip`; const VIP_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=vip`;
const STATUS_LABELS: Record<SidebarUserState, string> = {
guest: "Sign in to keep your coins and unlock more features.",
member: "Manage your wallet, subscription and account settings.",
vip: "Your VIP benefits are active. Enjoy the full experience.",
};
/** /**
* 侧边栏主屏幕 * 侧边栏主屏幕
* *
@@ -58,12 +65,22 @@ export function SidebarScreen() {
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null; const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
return ( return (
<MobileShell> <MobileShell background="#fcf3f4">
<div className={styles.shell}> <div className={styles.shell}>
<BackBar /> <div className={styles.bgOrbOne} aria-hidden="true" />
<div className={styles.bgOrbTwo} aria-hidden="true" />
{/* 用户信息区:三种状态 */} <div className={styles.topBar}>
<section className={styles.userSlot}> <BackBar />
</div>
<section className={styles.hero}>
<p className={styles.kicker}>Cozsweet</p>
<h1 className={styles.title}>Account</h1>
<p className={styles.subtitle}>{STATUS_LABELS[sidebarState]}</p>
</section>
<section className={`${styles.userSlot} ${styles.revealOne}`}>
<UserHeader <UserHeader
state={sidebarState} state={sidebarState}
name={name} name={name}
@@ -73,10 +90,11 @@ export function SidebarScreen() {
/> />
</section> </section>
<section className={styles.cardSlot}> <section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<SidebarWalletCard <SidebarWalletCard
creditBalance={user.creditBalance} creditBalance={user.creditBalance}
onTopUp={() => router.push(VIP_SUBSCRIPTION_URL)} onTopUp={() => router.push(VIP_SUBSCRIPTION_URL)}
onRulesClick={() => router.push(ROUTES.coinsRules)}
/> />
</section> </section>
@@ -91,18 +109,23 @@ export function SidebarScreen() {
*/} */}
{sidebarState !== "guest" ? ( {sidebarState !== "guest" ? (
<section className={styles.settingsSlot}> <section className={`${styles.settingsSlot} ${styles.revealThree}`}>
<SettingsSection <p className={styles.settingsLabel}>Other</p>
title="Other" <button
items={[ type="button"
{ className={styles.logoutCard}
id: "logout", onClick={handleLogoutClick}
title: "Log out", >
destructive: true, <span className={styles.logoutIcon} aria-hidden="true">
onClick: handleLogoutClick, <LogOut size={19} strokeWidth={2.2} />
}, </span>
]} <span className={styles.logoutText}>
/> <span className={styles.logoutTitle}>Log out</span>
<span className={styles.logoutSubtitle}>
End the current session safely
</span>
</span>
</button>
</section> </section>
) : null} ) : null}
</div> </div>
@@ -12,21 +12,11 @@ export const SUBSCRIPTION_BANNER_TITLES: Record<SubscriptionType, string> = {
}; };
export function isVipPlan(plan: PaymentPlan): boolean { export function isVipPlan(plan: PaymentPlan): boolean {
return plan.orderType.startsWith("vip_") || plan.planId.startsWith("vip_"); return plan.vipDays !== null;
} }
export function isCreditPlan(plan: PaymentPlan): boolean { export function isCreditPlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase(); return plan.dolAmount !== null;
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 {
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { PaymentPlan, PaymentPlansResponse } from "@/data/dto/payment";
describe("PaymentPlan", () => {
it("parses the camelCase payment plan shape", () => {
const plan = PaymentPlan.from({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 1999,
originalAmountCents: 2499,
dailyPriceCents: 66,
currency: "JPY",
});
expect(plan.planId).toBe("vip_monthly");
expect(plan.creditBalance).toBe(3000);
expect(plan.toJson()).toEqual({
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 1999,
originalAmountCents: 2499,
dailyPriceCents: 66,
currency: "JPY",
});
});
it("rejects the legacy snake_case payment plan shape", () => {
expect(() =>
PaymentPlan.fromJson({
plan_id: "vip_monthly",
plan_name: "VIP Monthly",
order_type: "vip_monthly",
amount_cents: 1999,
original_amount_cents: 2499,
daily_price_cents: 66,
currency: "JPY",
vip_days: 30,
dol_amount: null,
}),
).toThrow(z.ZodError);
});
});
describe("PaymentPlansResponse", () => {
it("parses plans from the new backend response data shape", () => {
const response = PaymentPlansResponse.from({
plans: [
{
planId: "dol_1000",
planName: "1000 Credits",
orderType: "dol",
vipDays: null,
dolAmount: 1000,
creditBalance: 1000,
amountCents: 990,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
},
],
});
expect(response.plans).toHaveLength(1);
expect(response.plans[0]?.dolAmount).toBe(1000);
});
});
+3 -3
View File
@@ -11,13 +11,13 @@ export class PaymentPlan {
declare readonly planId: string; declare readonly planId: string;
declare readonly planName: string; declare readonly planName: string;
declare readonly orderType: string; declare readonly orderType: string;
declare readonly vipDays: number | null;
declare readonly dolAmount: number | null;
declare readonly creditBalance: number;
declare readonly amountCents: number; declare readonly amountCents: number;
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 dolAmount: number | null;
private constructor(input: PaymentPlanInput) { private constructor(input: PaymentPlanInput) {
const data = PaymentPlanSchema.parse(input); const data = PaymentPlanSchema.parse(input);
@@ -5,64 +5,64 @@
"data": { "data": {
"plans": [ "plans": [
{ {
"plan_id": "credit_1000", "planId": "dol_1000",
"plan_name": "1000 Credits", "planName": "1000 Credits",
"order_type": "credit_recharge", "orderType": "dol",
"amount_cents": 990, "vipDays": null,
"original_amount_cents": null, "dolAmount": 1000,
"daily_price_cents": null, "creditBalance": 1000,
"currency": "USD", "amountCents": 990,
"pricing_tier": "T1", "originalAmountCents": null,
"vip_days": null, "dailyPriceCents": null,
"dol_amount": 1000 "currency": "USD"
}, },
{ {
"plan_id": "credit_2000", "planId": "dol_2000",
"plan_name": "2000 Credits", "planName": "2000 Credits",
"order_type": "credit_recharge", "orderType": "dol",
"amount_cents": 1690, "vipDays": null,
"original_amount_cents": null, "dolAmount": 2000,
"daily_price_cents": null, "creditBalance": 2000,
"currency": "USD", "amountCents": 1690,
"pricing_tier": "T1", "originalAmountCents": null,
"vip_days": null, "dailyPriceCents": null,
"dol_amount": 2000 "currency": "USD"
}, },
{ {
"plan_id": "credit_3000", "planId": "dol_3000",
"plan_name": "3000 Credits", "planName": "3000 Credits",
"order_type": "credit_recharge", "orderType": "dol",
"amount_cents": 2290, "vipDays": null,
"original_amount_cents": null, "dolAmount": 3000,
"daily_price_cents": null, "creditBalance": 3000,
"currency": "USD", "amountCents": 2290,
"pricing_tier": "T1", "originalAmountCents": null,
"vip_days": null, "dailyPriceCents": null,
"dol_amount": 3000 "currency": "USD"
}, },
{ {
"plan_id": "credit_5000", "planId": "dol_5000",
"plan_name": "5000 Credits", "planName": "5000 Credits",
"order_type": "credit_recharge", "orderType": "dol",
"amount_cents": 3490, "vipDays": null,
"original_amount_cents": null, "dolAmount": 5000,
"daily_price_cents": null, "creditBalance": 5000,
"currency": "USD", "amountCents": 3490,
"pricing_tier": "T1", "originalAmountCents": null,
"vip_days": null, "dailyPriceCents": null,
"dol_amount": 5000 "currency": "USD"
}, },
{ {
"plan_id": "credit_10000", "planId": "dol_10000",
"plan_name": "10000 Credits", "planName": "10000 Credits",
"order_type": "credit_recharge", "orderType": "dol",
"amount_cents": 6490, "vipDays": null,
"original_amount_cents": null, "dolAmount": 10000,
"daily_price_cents": null, "creditBalance": 10000,
"currency": "USD", "amountCents": 6490,
"pricing_tier": "T1", "originalAmountCents": null,
"vip_days": null, "dailyPriceCents": null,
"dol_amount": 10000 "currency": "USD"
} }
] ]
} }
@@ -5,40 +5,40 @@
"data": { "data": {
"plans": [ "plans": [
{ {
"plan_id": "vip_monthly", "planId": "vip_monthly",
"plan_name": "月度会员", "planName": "VIP Monthly",
"order_type": "vip_monthly", "orderType": "vip_monthly",
"amount_cents": 1999, "vipDays": 30,
"original_amount_cents": 2499, "dolAmount": null,
"daily_price_cents": 66, "creditBalance": 3000,
"currency": "USD", "amountCents": 1999,
"pricing_tier": "T1", "originalAmountCents": 2499,
"vip_days": 30, "dailyPriceCents": 66,
"dol_amount": null "currency": "USD"
}, },
{ {
"plan_id": "vip_quarterly", "planId": "vip_quarterly",
"plan_name": "季度会员", "planName": "VIP Quarterly",
"order_type": "vip_quarterly", "orderType": "vip_quarterly",
"amount_cents": 4999, "vipDays": 90,
"original_amount_cents": 5999, "dolAmount": null,
"daily_price_cents": 55, "creditBalance": 9000,
"currency": "USD", "amountCents": 4999,
"pricing_tier": "T1", "originalAmountCents": 5999,
"vip_days": 90, "dailyPriceCents": 55,
"dol_amount": null "currency": "USD"
}, },
{ {
"plan_id": "vip_yearly", "planId": "vip_annual",
"plan_name": "年度会员", "planName": "VIP Annual",
"order_type": "vip_yearly", "orderType": "vip_annual",
"amount_cents": 17999, "vipDays": 365,
"original_amount_cents": 19999, "dolAmount": null,
"daily_price_cents": 49, "creditBalance": 36000,
"currency": "USD", "amountCents": 17999,
"pricing_tier": "T1", "originalAmountCents": 19999,
"vip_days": 365, "dailyPriceCents": 49,
"dol_amount": null "currency": "USD"
} }
] ]
} }
@@ -17,6 +17,9 @@ export interface IPaymentRepository {
/** 获取套餐列表。 */ /** 获取套餐列表。 */
getPlans(): Promise<Result<PaymentPlansResponse>>; getPlans(): Promise<Result<PaymentPlansResponse>>;
/** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
/** 获取会员套餐列表。 */ /** 获取会员套餐列表。 */
getVipPlans(): Promise<Result<PaymentPlan[]>>; getVipPlans(): Promise<Result<PaymentPlan[]>>;
+17 -15
View File
@@ -15,6 +15,7 @@ import {
PaymentVipStatusResponse, PaymentVipStatusResponse,
} from "@/data/dto/payment"; } from "@/data/dto/payment";
import { PaymentApi, paymentApi } from "@/data/services/api"; import { PaymentApi, paymentApi } from "@/data/services/api";
import { PaymentPlansStorage } from "@/data/storage/payment";
import type { IPaymentRepository } from "@/data/repositories/interfaces"; import type { IPaymentRepository } from "@/data/repositories/interfaces";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
@@ -23,7 +24,20 @@ export class PaymentRepository implements IPaymentRepository {
/** 获取套餐列表。 */ /** 获取套餐列表。 */
async getPlans(): Promise<Result<PaymentPlansResponse>> { async getPlans(): Promise<Result<PaymentPlansResponse>> {
return Result.wrap(() => this.api.getPlans()); return Result.wrap(async () => {
const response = await this.api.getPlans();
await PaymentPlansStorage.setPlans(response.toJson());
return response;
});
}
/** 获取本地缓存套餐列表。 */
async getCachedPlans(): Promise<Result<PaymentPlansResponse | null>> {
return Result.wrap(async () => {
const result = await PaymentPlansStorage.getPlans();
if (Result.isErr(result)) throw result.error;
return result.data ? PaymentPlansResponse.from(result.data) : null;
});
} }
/** 获取会员套餐列表。 */ /** 获取会员套餐列表。 */
@@ -73,21 +87,9 @@ export class PaymentRepository implements IPaymentRepository {
export const paymentRepository = new PaymentRepository(paymentApi); export const paymentRepository = new PaymentRepository(paymentApi);
export function isVipPaymentPlan(plan: PaymentPlan): boolean { export function isVipPaymentPlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase(); return plan.vipDays !== null;
const planId = plan.planId.toLowerCase();
return orderType.startsWith("vip_") || planId.startsWith("vip_");
} }
export function isCreditPaymentPlan(plan: PaymentPlan): boolean { export function isCreditPaymentPlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase(); return plan.dolAmount !== null;
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")
);
} }
+10 -43
View File
@@ -3,51 +3,18 @@
*/ */
import { z } from "zod"; import { z } from "zod";
const PaymentPlanWireSchema = z.object({ export const PaymentPlanSchema = z.object({
plan_id: z.string(), planId: z.string(),
plan_name: z.string(), planName: z.string(),
order_type: z.string(), orderType: z.string(),
amount_cents: z.number(), vipDays: z.number().nullable(),
original_amount_cents: z.number().nullable().default(null), dolAmount: z.number().nullable(),
daily_price_cents: z.number().nullable().default(null), creditBalance: z.number(),
amountCents: z.number(),
originalAmountCents: z.number().nullable().default(null),
dailyPriceCents: z.number().nullable().default(null),
currency: z.string(), currency: z.string(),
pricing_tier: z.string(),
vip_days: z.number().nullable(),
dol_amount: z.number().nullable(),
}); });
export const PaymentPlanSchema = z
.preprocess((value) => {
if (!value || typeof value !== "object") return value;
const data = value as Record<string, unknown>;
if ("planId" in data) {
return {
plan_id: data.planId,
plan_name: data.planName,
order_type: data.orderType,
amount_cents: data.amountCents,
original_amount_cents: data.originalAmountCents,
daily_price_cents: data.dailyPriceCents,
currency: data.currency,
pricing_tier: data.pricingTier,
vip_days: data.vipDays,
dol_amount: data.dolAmount,
};
}
return value;
}, PaymentPlanWireSchema)
.transform((data) => ({
planId: data.plan_id,
planName: data.plan_name,
orderType: data.order_type,
amountCents: data.amount_cents,
originalAmountCents: data.original_amount_cents,
dailyPriceCents: data.daily_price_cents,
currency: data.currency,
pricingTier: data.pricing_tier,
vipDays: data.vip_days,
dolAmount: data.dol_amount,
}));
export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>; export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>;
export type PaymentPlanData = z.output<typeof PaymentPlanSchema>; export type PaymentPlanData = z.output<typeof PaymentPlanSchema>;
+1
View File
@@ -3,3 +3,4 @@
*/ */
export * from "./pending_payment_order_storage"; export * from "./pending_payment_order_storage";
export * from "./payment_plans_storage";
@@ -0,0 +1,32 @@
"use client";
import {
PaymentPlansResponseSchema,
type PaymentPlansResponseData,
} from "@/data/schemas/payment";
import { type Result as ResultT, SpAsyncUtil } from "@/utils";
import { StorageKeys } from "../storage_keys";
export class PaymentPlansStorage {
private constructor() {}
static getPlans(): Promise<ResultT<PaymentPlansResponseData | null>> {
return SpAsyncUtil.getJson(
StorageKeys.paymentPlans,
PaymentPlansResponseSchema,
);
}
static setPlans(plans: PaymentPlansResponseData): Promise<ResultT<void>> {
return SpAsyncUtil.setJson(
StorageKeys.paymentPlans,
plans,
PaymentPlansResponseSchema,
);
}
static clearPlans(): Promise<ResultT<void>> {
return SpAsyncUtil.remove(StorageKeys.paymentPlans);
}
}
+1
View File
@@ -31,6 +31,7 @@ export const StorageKeys = {
// payment // payment
pendingPaymentOrder: "pending_payment_order", pendingPaymentOrder: "pending_payment_order",
paymentPlans: "payment_plans",
} as const; } as const;
export type StorageKey = (typeof StorageKeys)[keyof typeof StorageKeys]; export type StorageKey = (typeof StorageKeys)[keyof typeof StorageKeys];
+3
View File
@@ -22,6 +22,7 @@ export const ROUTES = {
auth: "/auth", auth: "/auth",
sidebar: "/sidebar", sidebar: "/sidebar",
subscription: "/subscription", subscription: "/subscription",
coinsRules: "/coins-rules",
} as const; } as const;
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES]; export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
@@ -59,6 +60,7 @@ export const PROTECTED_ROUTES: readonly StaticRoute[] = [
export const PUBLIC_ROUTES: readonly StaticRoute[] = [ export const PUBLIC_ROUTES: readonly StaticRoute[] = [
ROUTES.chat, ROUTES.chat,
ROUTES.sidebar, ROUTES.sidebar,
ROUTES.coinsRules,
] as const; ] as const;
/** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */ /** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */
@@ -67,6 +69,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
ROUTES.chat, ROUTES.chat,
ROUTES.auth, ROUTES.auth,
ROUTES.sidebar, ROUTES.sidebar,
ROUTES.coinsRules,
] as const; ] as const;
/** 联合路由类型,包含静态路由与已知动态路由 */ /** 联合路由类型,包含静态路由与已知动态路由 */
@@ -18,42 +18,55 @@ interface CreateOrderInput {
type CreateOrderSpy = (input: CreateOrderInput) => void; type CreateOrderSpy = (input: CreateOrderInput) => void;
const monthlyPlan = { const monthlyPlan = {
plan_id: "vip_monthly", planId: "vip_monthly",
plan_name: "VIP Monthly", planName: "VIP Monthly",
order_type: "vip_monthly", orderType: "vip_monthly",
amount_cents: 999, vipDays: 30,
original_amount_cents: null, dolAmount: null,
daily_price_cents: 33, creditBalance: 3000,
amountCents: 999,
originalAmountCents: null,
dailyPriceCents: 33,
currency: "usd", currency: "usd",
pricing_tier: "T1",
vip_days: 30,
dol_amount: null,
}; };
const lifetimePlan = { const lifetimePlan = {
plan_id: "vip_lifetime", planId: "vip_lifetime",
plan_name: "VIP Lifetime", planName: "VIP Lifetime",
order_type: "vip_lifetime", orderType: "vip_lifetime",
amount_cents: 4999, vipDays: 3650,
original_amount_cents: null, dolAmount: null,
daily_price_cents: null, creditBalance: 0,
amountCents: 4999,
originalAmountCents: null,
dailyPriceCents: null,
currency: "usd", currency: "usd",
pricing_tier: "T1",
vip_days: null,
dol_amount: null,
}; };
const creditPlan = { const creditPlan = {
plan_id: "credit_1000", planId: "dol_1000",
plan_name: "1000 Credits", planName: "1000 Credits",
order_type: "credit_recharge", orderType: "dol",
amount_cents: 990, vipDays: null,
original_amount_cents: null, dolAmount: 1000,
daily_price_cents: null, creditBalance: 1000,
amountCents: 990,
originalAmountCents: null,
dailyPriceCents: null,
currency: "usd",
};
const quarterlyPlan = {
planId: "vip_quarterly",
planName: "VIP Quarterly",
orderType: "vip_quarterly",
vipDays: 90,
dolAmount: null,
creditBalance: 9000,
amountCents: 2499,
originalAmountCents: null,
dailyPriceCents: 28,
currency: "usd", currency: "usd",
pricing_tier: "T1",
vip_days: null,
dol_amount: 1000,
}; };
function createTestPaymentMachine( function createTestPaymentMachine(
@@ -67,7 +80,10 @@ function createTestPaymentMachine(
return paymentMachine.provide({ return paymentMachine.provide({
actors: { actors: {
loadPlans: fromPromise(async () => loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
async () => null,
),
refreshPlans: fromPromise(async () =>
PaymentPlansResponse.from({ PaymentPlansResponse.from({
plans: [monthlyPlan, lifetimePlan], plans: [monthlyPlan, lifetimePlan],
}), }),
@@ -113,6 +129,53 @@ describe("paymentMachine", () => {
actor.stop(); actor.stop();
}); });
it("hydrates cached plans before refreshing with network plans", async () => {
let resolveRefresh!: (value: PaymentPlansResponse) => void;
const refreshPlansPromise = new Promise<PaymentPlansResponse>((resolve) => {
resolveRefresh = resolve;
});
const actor = createActor(
paymentMachine.provide({
actors: {
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
async () =>
PaymentPlansResponse.from({
plans: [quarterlyPlan],
}),
),
refreshPlans: fromPromise<PaymentPlansResponse>(
async () => refreshPlansPromise,
),
},
}),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) =>
snapshot.matches("refreshingPlans"),
);
expect(actor.getSnapshot().context.plans[0]?.planId).toBe(
"vip_quarterly",
);
resolveRefresh(
PaymentPlansResponse.from({
plans: [monthlyPlan, creditPlan],
}),
);
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.plans.map((plan) => plan.planId)).toEqual([
"vip_monthly",
"dol_1000",
]);
expect(context.selectedPlanId).toBe("vip_monthly");
actor.stop();
});
it("does not create an order before the agreement is accepted", async () => { it("does not create an order before the agreement is accepted", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>(); const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor( const actor = createActor(
@@ -232,10 +295,10 @@ describe("paymentMachine", () => {
actor.send({ type: "PaymentInit" }); actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready")); await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentPlanSelected", planId: creditPlan.plan_id }); actor.send({ type: "PaymentPlanSelected", planId: creditPlan.planId });
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.selectedPlanId).toBe("credit_1000"); expect(context.selectedPlanId).toBe("dol_1000");
expect(context.autoRenew).toBe(false); expect(context.autoRenew).toBe(false);
actor.stop(); actor.stop();
+4 -1
View File
@@ -59,7 +59,10 @@ export function PaymentProvider({ children }: PaymentProviderProps) {
orderStatus: state.context.orderStatus, orderStatus: state.context.orderStatus,
errorMessage: state.context.errorMessage, errorMessage: state.context.errorMessage,
launchNonce: state.context.launchNonce, launchNonce: state.context.launchNonce,
isLoadingPlans: state.matches("loadingPlans"), isLoadingPlans:
state.matches("loadingCachedPlans") ||
state.matches("loadingPlans") ||
state.matches("refreshingPlans"),
isCreatingOrder: state.matches("creatingOrder"), isCreatingOrder: state.matches("creatingOrder"),
isPollingOrder: isPollingOrder:
state.matches("pollingOrder") || state.matches("waitingForPayment"), state.matches("pollingOrder") || state.matches("waitingForPayment"),
+8 -1
View File
@@ -15,7 +15,14 @@ import { Result } from "@/utils";
const paymentRepo: IPaymentRepository = paymentRepository; const paymentRepo: IPaymentRepository = paymentRepository;
export const loadPaymentPlansActor = fromPromise<PaymentPlansResponse>( export const loadCachedPaymentPlansActor =
fromPromise<PaymentPlansResponse | null>(async () => {
const result = await paymentRepo.getCachedPlans();
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const refreshPaymentPlansActor = fromPromise<PaymentPlansResponse>(
async () => { async () => {
const result = await paymentRepo.getPlans(); const result = await paymentRepo.getPlans();
if (Result.isErr(result)) throw result.error; if (Result.isErr(result)) throw result.error;
@@ -6,7 +6,12 @@ export function toPaymentErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
export function defaultAutoRenewForPlan(planId: string): boolean { export function defaultAutoRenewForPlan(
planId: string,
plans: readonly PaymentPlan[] = [],
): boolean {
const plan = plans.find((item) => item.planId === planId);
if (plan?.dolAmount !== null && plan?.dolAmount !== undefined) return false;
return ( return (
!planId.includes("lifetime") && !planId.includes("lifetime") &&
!planId.startsWith("dol_") && !planId.startsWith("dol_") &&
+69 -10
View File
@@ -7,8 +7,9 @@ import type { PaymentEvent } from "./payment-events";
import { initialState, type PaymentState } from "./payment-state"; import { initialState, type PaymentState } from "./payment-state";
import { import {
createPaymentOrderActor, createPaymentOrderActor,
loadPaymentPlansActor, loadCachedPaymentPlansActor,
pollPaymentOrderStatusActor, pollPaymentOrderStatusActor,
refreshPaymentPlansActor,
} from "./payment-machine.actors"; } from "./payment-machine.actors";
import { import {
defaultAutoRenewForPlan, defaultAutoRenewForPlan,
@@ -29,7 +30,8 @@ export const paymentMachine = setup({
events: {} as PaymentEvent, events: {} as PaymentEvent,
}, },
actors: { actors: {
loadPlans: loadPaymentPlansActor, loadCachedPlans: loadCachedPaymentPlansActor,
refreshPlans: refreshPaymentPlansActor,
createOrder: createPaymentOrderActor, createOrder: createPaymentOrderActor,
pollOrderStatus: pollPaymentOrderStatusActor, pollOrderStatus: pollPaymentOrderStatusActor,
}, },
@@ -41,13 +43,42 @@ export const paymentMachine = setup({
states: { states: {
idle: { idle: {
on: { on: {
PaymentInit: "loadingPlans", PaymentInit: "loadingCachedPlans",
},
},
loadingCachedPlans: {
invoke: {
src: "loadCachedPlans",
onDone: [
{
guard: ({ event }) => (event.output?.plans.length ?? 0) > 0,
target: "refreshingPlans",
actions: assign(({ context, event }) => {
const plans = event.output?.plans ?? [];
const selectedPlanId =
context.selectedPlanId || getDefaultPlanId(plans);
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null,
};
}),
},
{
target: "loadingPlans",
},
],
onError: {
target: "loadingPlans",
},
}, },
}, },
loadingPlans: { loadingPlans: {
invoke: { invoke: {
src: "loadPlans", src: "refreshPlans",
onDone: { onDone: {
target: "ready", target: "ready",
actions: assign(({ context, event }) => { actions: assign(({ context, event }) => {
@@ -57,7 +88,35 @@ export const paymentMachine = setup({
return { return {
plans, plans,
selectedPlanId, selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId), autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null,
};
}),
},
onError: {
target: "ready",
actions: assign(({ event }) => ({
errorMessage: toPaymentErrorMessage(event.error),
})),
},
},
},
refreshingPlans: {
invoke: {
src: "refreshPlans",
onDone: {
target: "ready",
actions: assign(({ context, event }) => {
const plans = event.output.plans;
const selectedPlanId =
plans.some((plan) => plan.planId === context.selectedPlanId)
? context.selectedPlanId
: getDefaultPlanId(plans);
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null, errorMessage: null,
}; };
}), }),
@@ -73,12 +132,12 @@ export const paymentMachine = setup({
ready: { ready: {
on: { on: {
PaymentInit: "loadingPlans", PaymentInit: "refreshingPlans",
PaymentPlanSelected: { PaymentPlanSelected: {
actions: assign(({ event }) => ({ actions: assign(({ context, event }) => ({
...resetOrderState(), ...resetOrderState(),
selectedPlanId: event.planId, selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId), autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
})), })),
}, },
PaymentPayChannelChanged: { PaymentPayChannelChanged: {
@@ -208,10 +267,10 @@ export const paymentMachine = setup({
on: { on: {
PaymentPlanSelected: { PaymentPlanSelected: {
target: "ready", target: "ready",
actions: assign(({ event }) => ({ actions: assign(({ context, event }) => ({
...resetOrderState(), ...resetOrderState(),
selectedPlanId: event.planId, selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId), autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
})), })),
}, },
PaymentPayChannelChanged: { PaymentPayChannelChanged: {