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
@@ -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 {
position: relative;
display: flex;
flex-direction: column;
background: var(--color-page-background, #ffffff);
color: var(--color-auth-text-primary, #333333);
gap: 14px;
overflow: hidden;
min-height: 100dvh;
padding: 0 var(--page-padding-x, 28px) var(--spacing-xxl, 24px);
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 {
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 {
padding: var(--sidebar-card-gap-y, 8px) 0;
padding: 0;
}
.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 {
creditBalance: number;
onTopUp: () => void;
onRulesClick: () => void;
}
const formatCoins = (value: number): string =>
@@ -15,6 +16,7 @@ const formatCoins = (value: number): string =>
export function SidebarWalletCard({
creditBalance,
onTopUp,
onRulesClick,
}: SidebarWalletCardProps) {
return (
<section className={styles.card} aria-label="My wallet">
@@ -31,7 +33,11 @@ export function SidebarWalletCard({
<span className={styles.balanceUnit}> coins</span>
</p>
<button type="button" className={styles.rulesButton}>
<button
type="button"
className={styles.rulesButton}
onClick={onRulesClick}
>
Coins
</button>
</div>
+41 -18
View File
@@ -1,9 +1,10 @@
"use client";
import { LogOut } from "lucide-react";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { MobileShell, SettingsSection } from "@/app/_components/core";
import { MobileShell } from "@/app/_components/core";
import { ROUTES } from "@/router/routes";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-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 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;
return (
<MobileShell>
<MobileShell background="#fcf3f4">
<div className={styles.shell}>
<BackBar />
<div className={styles.bgOrbOne} aria-hidden="true" />
<div className={styles.bgOrbTwo} aria-hidden="true" />
{/* 用户信息区:三种状态 */}
<section className={styles.userSlot}>
<div className={styles.topBar}>
<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
state={sidebarState}
name={name}
@@ -73,10 +90,11 @@ export function SidebarScreen() {
/>
</section>
<section className={styles.cardSlot}>
<section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<SidebarWalletCard
creditBalance={user.creditBalance}
onTopUp={() => router.push(VIP_SUBSCRIPTION_URL)}
onRulesClick={() => router.push(ROUTES.coinsRules)}
/>
</section>
@@ -91,18 +109,23 @@ export function SidebarScreen() {
*/}
{sidebarState !== "guest" ? (
<section className={styles.settingsSlot}>
<SettingsSection
title="Other"
items={[
{
id: "logout",
title: "Log out",
destructive: true,
onClick: handleLogoutClick,
},
]}
/>
<section className={`${styles.settingsSlot} ${styles.revealThree}`}>
<p className={styles.settingsLabel}>Other</p>
<button
type="button"
className={styles.logoutCard}
onClick={handleLogoutClick}
>
<span className={styles.logoutIcon} aria-hidden="true">
<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>
) : null}
</div>
@@ -12,21 +12,11 @@ export const SUBSCRIPTION_BANNER_TITLES: Record<SubscriptionType, string> = {
};
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 {
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")
);
return plan.dolAmount !== null;
}
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 planName: 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 originalAmountCents: number | null;
declare readonly dailyPriceCents: number | null;
declare readonly currency: string;
declare readonly pricingTier: string;
declare readonly vipDays: number | null;
declare readonly dolAmount: number | null;
private constructor(input: PaymentPlanInput) {
const data = PaymentPlanSchema.parse(input);
@@ -5,64 +5,64 @@
"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
"planId": "dol_1000",
"planName": "1000 Credits",
"orderType": "dol",
"vipDays": null,
"dolAmount": 1000,
"creditBalance": 1000,
"amountCents": 990,
"originalAmountCents": null,
"dailyPriceCents": null,
"currency": "USD"
},
{
"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
"planId": "dol_2000",
"planName": "2000 Credits",
"orderType": "dol",
"vipDays": null,
"dolAmount": 2000,
"creditBalance": 2000,
"amountCents": 1690,
"originalAmountCents": null,
"dailyPriceCents": null,
"currency": "USD"
},
{
"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
"planId": "dol_3000",
"planName": "3000 Credits",
"orderType": "dol",
"vipDays": null,
"dolAmount": 3000,
"creditBalance": 3000,
"amountCents": 2290,
"originalAmountCents": null,
"dailyPriceCents": null,
"currency": "USD"
},
{
"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
"planId": "dol_5000",
"planName": "5000 Credits",
"orderType": "dol",
"vipDays": null,
"dolAmount": 5000,
"creditBalance": 5000,
"amountCents": 3490,
"originalAmountCents": null,
"dailyPriceCents": null,
"currency": "USD"
},
{
"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
"planId": "dol_10000",
"planName": "10000 Credits",
"orderType": "dol",
"vipDays": null,
"dolAmount": 10000,
"creditBalance": 10000,
"amountCents": 6490,
"originalAmountCents": null,
"dailyPriceCents": null,
"currency": "USD"
}
]
}
@@ -5,40 +5,40 @@
"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
"planId": "vip_monthly",
"planName": "VIP Monthly",
"orderType": "vip_monthly",
"vipDays": 30,
"dolAmount": null,
"creditBalance": 3000,
"amountCents": 1999,
"originalAmountCents": 2499,
"dailyPriceCents": 66,
"currency": "USD"
},
{
"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
"planId": "vip_quarterly",
"planName": "VIP Quarterly",
"orderType": "vip_quarterly",
"vipDays": 90,
"dolAmount": null,
"creditBalance": 9000,
"amountCents": 4999,
"originalAmountCents": 5999,
"dailyPriceCents": 55,
"currency": "USD"
},
{
"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
"planId": "vip_annual",
"planName": "VIP Annual",
"orderType": "vip_annual",
"vipDays": 365,
"dolAmount": null,
"creditBalance": 36000,
"amountCents": 17999,
"originalAmountCents": 19999,
"dailyPriceCents": 49,
"currency": "USD"
}
]
}
@@ -17,6 +17,9 @@ export interface IPaymentRepository {
/** 获取套餐列表。 */
getPlans(): Promise<Result<PaymentPlansResponse>>;
/** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
/** 获取会员套餐列表。 */
getVipPlans(): Promise<Result<PaymentPlan[]>>;
+17 -15
View File
@@ -15,6 +15,7 @@ import {
PaymentVipStatusResponse,
} from "@/data/dto/payment";
import { PaymentApi, paymentApi } from "@/data/services/api";
import { PaymentPlansStorage } from "@/data/storage/payment";
import type { IPaymentRepository } from "@/data/repositories/interfaces";
import { Result } from "@/utils/result";
@@ -23,7 +24,20 @@ export class PaymentRepository implements IPaymentRepository {
/** 获取套餐列表。 */
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 function isVipPaymentPlan(plan: PaymentPlan): boolean {
const orderType = plan.orderType.toLowerCase();
const planId = plan.planId.toLowerCase();
return orderType.startsWith("vip_") || planId.startsWith("vip_");
return plan.vipDays !== null;
}
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")
);
return plan.dolAmount !== null;
}
+10 -43
View File
@@ -3,51 +3,18 @@
*/
import { z } from "zod";
const PaymentPlanWireSchema = z.object({
plan_id: z.string(),
plan_name: z.string(),
order_type: z.string(),
amount_cents: z.number(),
original_amount_cents: z.number().nullable().default(null),
daily_price_cents: z.number().nullable().default(null),
export const PaymentPlanSchema = z.object({
planId: z.string(),
planName: z.string(),
orderType: z.string(),
vipDays: z.number().nullable(),
dolAmount: z.number().nullable(),
creditBalance: z.number(),
amountCents: z.number(),
originalAmountCents: z.number().nullable().default(null),
dailyPriceCents: z.number().nullable().default(null),
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 PaymentPlanData = z.output<typeof PaymentPlanSchema>;
+1
View File
@@ -3,3 +3,4 @@
*/
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
pendingPaymentOrder: "pending_payment_order",
paymentPlans: "payment_plans",
} as const;
export type StorageKey = (typeof StorageKeys)[keyof typeof StorageKeys];
+3
View File
@@ -22,6 +22,7 @@ export const ROUTES = {
auth: "/auth",
sidebar: "/sidebar",
subscription: "/subscription",
coinsRules: "/coins-rules",
} as const;
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
@@ -59,6 +60,7 @@ export const PROTECTED_ROUTES: readonly StaticRoute[] = [
export const PUBLIC_ROUTES: readonly StaticRoute[] = [
ROUTES.chat,
ROUTES.sidebar,
ROUTES.coinsRules,
] as const;
/** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */
@@ -67,6 +69,7 @@ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
ROUTES.chat,
ROUTES.auth,
ROUTES.sidebar,
ROUTES.coinsRules,
] as const;
/** 联合路由类型,包含静态路由与已知动态路由 */
@@ -18,42 +18,55 @@ interface CreateOrderInput {
type CreateOrderSpy = (input: CreateOrderInput) => void;
const monthlyPlan = {
plan_id: "vip_monthly",
plan_name: "VIP Monthly",
order_type: "vip_monthly",
amount_cents: 999,
original_amount_cents: null,
daily_price_cents: 33,
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 999,
originalAmountCents: null,
dailyPriceCents: 33,
currency: "usd",
pricing_tier: "T1",
vip_days: 30,
dol_amount: null,
};
const lifetimePlan = {
plan_id: "vip_lifetime",
plan_name: "VIP Lifetime",
order_type: "vip_lifetime",
amount_cents: 4999,
original_amount_cents: null,
daily_price_cents: null,
planId: "vip_lifetime",
planName: "VIP Lifetime",
orderType: "vip_lifetime",
vipDays: 3650,
dolAmount: null,
creditBalance: 0,
amountCents: 4999,
originalAmountCents: null,
dailyPriceCents: null,
currency: "usd",
pricing_tier: "T1",
vip_days: 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,
planId: "dol_1000",
planName: "1000 Credits",
orderType: "dol",
vipDays: null,
dolAmount: 1000,
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",
pricing_tier: "T1",
vip_days: null,
dol_amount: 1000,
};
function createTestPaymentMachine(
@@ -67,7 +80,10 @@ function createTestPaymentMachine(
return paymentMachine.provide({
actors: {
loadPlans: fromPromise(async () =>
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
async () => null,
),
refreshPlans: fromPromise(async () =>
PaymentPlansResponse.from({
plans: [monthlyPlan, lifetimePlan],
}),
@@ -113,6 +129,53 @@ describe("paymentMachine", () => {
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 () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
@@ -232,10 +295,10 @@ describe("paymentMachine", () => {
actor.send({ type: "PaymentInit" });
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;
expect(context.selectedPlanId).toBe("credit_1000");
expect(context.selectedPlanId).toBe("dol_1000");
expect(context.autoRenew).toBe(false);
actor.stop();
+4 -1
View File
@@ -59,7 +59,10 @@ export function PaymentProvider({ children }: PaymentProviderProps) {
orderStatus: state.context.orderStatus,
errorMessage: state.context.errorMessage,
launchNonce: state.context.launchNonce,
isLoadingPlans: state.matches("loadingPlans"),
isLoadingPlans:
state.matches("loadingCachedPlans") ||
state.matches("loadingPlans") ||
state.matches("refreshingPlans"),
isCreatingOrder: state.matches("creatingOrder"),
isPollingOrder:
state.matches("pollingOrder") || state.matches("waitingForPayment"),
+8 -1
View File
@@ -15,7 +15,14 @@ import { Result } from "@/utils";
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 () => {
const result = await paymentRepo.getPlans();
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);
}
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 (
!planId.includes("lifetime") &&
!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 {
createPaymentOrderActor,
loadPaymentPlansActor,
loadCachedPaymentPlansActor,
pollPaymentOrderStatusActor,
refreshPaymentPlansActor,
} from "./payment-machine.actors";
import {
defaultAutoRenewForPlan,
@@ -29,7 +30,8 @@ export const paymentMachine = setup({
events: {} as PaymentEvent,
},
actors: {
loadPlans: loadPaymentPlansActor,
loadCachedPlans: loadCachedPaymentPlansActor,
refreshPlans: refreshPaymentPlansActor,
createOrder: createPaymentOrderActor,
pollOrderStatus: pollPaymentOrderStatusActor,
},
@@ -41,13 +43,42 @@ export const paymentMachine = setup({
states: {
idle: {
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: {
invoke: {
src: "loadPlans",
src: "refreshPlans",
onDone: {
target: "ready",
actions: assign(({ context, event }) => {
@@ -57,7 +88,35 @@ export const paymentMachine = setup({
return {
plans,
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,
};
}),
@@ -73,12 +132,12 @@ export const paymentMachine = setup({
ready: {
on: {
PaymentInit: "loadingPlans",
PaymentInit: "refreshingPlans",
PaymentPlanSelected: {
actions: assign(({ event }) => ({
actions: assign(({ context, event }) => ({
...resetOrderState(),
selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId),
autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
})),
},
PaymentPayChannelChanged: {
@@ -208,10 +267,10 @@ export const paymentMachine = setup({
on: {
PaymentPlanSelected: {
target: "ready",
actions: assign(({ event }) => ({
actions: assign(({ context, event }) => ({
...resetOrderState(),
selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId),
autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
})),
},
PaymentPayChannelChanged: {