feat(payment): sync plans and add coins rules
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[]>>;
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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_") &&
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user