From e372d58b4cd9c62c2e62b458f7fff3f7709ebe89 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 29 Jun 2026 14:11:50 +0800 Subject: [PATCH] feat(payment): sync plans and add coins rules --- plans.md | 34 +++ .../coins-rules/coins-rules-screen.module.css | 259 ++++++++++++++++++ src/app/coins-rules/coins-rules-screen.tsx | 130 +++++++++ src/app/coins-rules/page.tsx | 5 + .../components/sidebar-screen.module.css | 224 ++++++++++++++- .../components/sidebar-wallet-card.tsx | 8 +- src/app/sidebar/sidebar-screen.tsx | 59 ++-- .../subscription-screen.helpers.ts | 14 +- .../payment/__tests__/payment_plan.test.ts | 76 +++++ src/data/dto/payment/payment_plan.ts | 6 +- .../responses/payment-plans-credits.json | 100 +++---- .../payment/responses/payment-plans-vip.json | 60 ++-- .../interfaces/ipayment_repository.ts | 3 + src/data/repositories/payment_repository.ts | 32 ++- src/data/schemas/payment/payment_plan.ts | 53 +--- src/data/storage/payment/index.ts | 1 + .../storage/payment/payment_plans_storage.ts | 32 +++ src/data/storage/storage_keys.ts | 1 + src/router/routes.ts | 3 + .../payment/__tests__/payment-machine.test.ts | 123 +++++++-- src/stores/payment/payment-context.tsx | 5 +- src/stores/payment/payment-machine.actors.ts | 9 +- src/stores/payment/payment-machine.helpers.ts | 7 +- src/stores/payment/payment-machine.ts | 79 +++++- 24 files changed, 1099 insertions(+), 224 deletions(-) create mode 100644 plans.md create mode 100644 src/app/coins-rules/coins-rules-screen.module.css create mode 100644 src/app/coins-rules/coins-rules-screen.tsx create mode 100644 src/app/coins-rules/page.tsx create mode 100644 src/data/dto/payment/__tests__/payment_plan.test.ts create mode 100644 src/data/storage/payment/payment_plans_storage.ts diff --git a/plans.md b/plans.md new file mode 100644 index 00000000..ceff63b3 --- /dev/null +++ b/plans.md @@ -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 判的国家币种。 \ No newline at end of file diff --git a/src/app/coins-rules/coins-rules-screen.module.css b/src/app/coins-rules/coins-rules-screen.module.css new file mode 100644 index 00000000..eb8dc166 --- /dev/null +++ b/src/app/coins-rules/coins-rules-screen.module.css @@ -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; + } +} diff --git a/src/app/coins-rules/coins-rules-screen.tsx b/src/app/coins-rules/coins-rules-screen.tsx new file mode 100644 index 00000000..53e75ab8 --- /dev/null +++ b/src/app/coins-rules/coins-rules-screen.tsx @@ -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 ( + +
+
+ +
+ +
+ +
+

免费额度

+

+ 普通聊天每周免费 50 条,私密聊天每天免费 1 条。 +

+
+
+ +
+ {COIN_RULES.map((rule, index) => { + const Icon = rule.icon; + return ( +
+ +
+

{rule.title}

+ {rule.free ? ( +

{rule.free}

+ ) : null} +
+

{rule.cost}

+
+ ); + })} +
+
+
+ ); +} diff --git a/src/app/coins-rules/page.tsx b/src/app/coins-rules/page.tsx new file mode 100644 index 00000000..dc6e6cd3 --- /dev/null +++ b/src/app/coins-rules/page.tsx @@ -0,0 +1,5 @@ +import { CoinsRulesScreen } from "./coins-rules-screen"; + +export default function CoinsRulesPage() { + return ; +} diff --git a/src/app/sidebar/components/sidebar-screen.module.css b/src/app/sidebar/components/sidebar-screen.module.css index f5d7d1b7..e989c7a5 100644 --- a/src/app/sidebar/components/sidebar-screen.module.css +++ b/src/app/sidebar/components/sidebar-screen.module.css @@ -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; + } } diff --git a/src/app/sidebar/components/sidebar-wallet-card.tsx b/src/app/sidebar/components/sidebar-wallet-card.tsx index 1a42be0a..a9395d41 100644 --- a/src/app/sidebar/components/sidebar-wallet-card.tsx +++ b/src/app/sidebar/components/sidebar-wallet-card.tsx @@ -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 (
@@ -31,7 +33,11 @@ export function SidebarWalletCard({ coins

- diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx index bd7f0e66..a9bd35cf 100644 --- a/src/app/sidebar/sidebar-screen.tsx +++ b/src/app/sidebar/sidebar-screen.tsx @@ -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 = { + 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 ( - +
- +