From d17ab6c23d79ca507bac7e1621a643a4f0fa2155 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 3 Jul 2026 16:56:06 +0800 Subject: [PATCH] feat(profile): surface daily free quotas --- src/app/coins-rules/coins-rules-screen.tsx | 54 ++++++++++++++++--- src/app/coins-rules/coins-rules.constants.ts | 3 -- .../components/sidebar-wallet-card.tsx | 25 +++++---- src/app/sidebar/sidebar-screen.tsx | 6 +++ src/data/dto/user/__tests__/user.test.ts | 28 ++++++++++ src/data/dto/user/user.ts | 7 +++ src/data/dto/user/user_view.ts | 6 +++ src/data/schemas/user/user.ts | 7 +++ .../__tests__/user-machine.helpers.test.ts | 18 +++++++ .../user/__tests__/user-machine.test.ts | 6 +++ src/stores/user/user-machine.helpers.ts | 12 +++++ 11 files changed, 152 insertions(+), 20 deletions(-) diff --git a/src/app/coins-rules/coins-rules-screen.tsx b/src/app/coins-rules/coins-rules-screen.tsx index f6708312..777dc69f 100644 --- a/src/app/coins-rules/coins-rules-screen.tsx +++ b/src/app/coins-rules/coins-rules-screen.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import { Gift, ImageIcon, @@ -12,10 +13,12 @@ import { FaCoins } from "react-icons/fa6"; import { BackButton } from "@/app/_components"; import { MobileShell } from "@/app/_components/core"; import { ROUTES } from "@/router/routes"; +import { useAuthState } from "@/stores/auth/auth-context"; +import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { - FREE_PRIVATE_MESSAGE_LABEL, - FREE_STANDARD_CHAT_LABEL, + FREE_PRIVATE_MESSAGE_DAILY_COUNT, + FREE_STANDARD_CHAT_DAILY_COUNT, } from "./coins-rules.constants"; import styles from "./coins-rules-screen.module.css"; @@ -26,17 +29,23 @@ interface CoinRuleItem { icon: typeof MessageCircle; } -const COIN_RULES: readonly CoinRuleItem[] = [ +const formatDailyFreeMessages = (count: number, label: string): string => + `${Math.max(0, Math.trunc(count))} free ${label} daily`; + +const createCoinRules = ( + dailyFreeChatLimit: number, + dailyFreePrivateLimit: number, +): readonly CoinRuleItem[] => [ { title: "Standard Chat", cost: "2 coins / message", - free: FREE_STANDARD_CHAT_LABEL, + free: formatDailyFreeMessages(dailyFreeChatLimit, "messages"), icon: MessageCircle, }, { title: "Private Chat", cost: "10 coins / message", - free: FREE_PRIVATE_MESSAGE_LABEL, + free: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"), icon: Sparkles, }, { @@ -67,6 +76,35 @@ const COIN_RULES: readonly CoinRuleItem[] = [ ] as const; export function CoinsRulesScreen() { + const authState = useAuthState(); + const userState = useUserState(); + const userDispatch = useUserDispatch(); + const dailyFreeChatLimit = + userState.currentUser?.dailyFreeChatLimit ?? FREE_STANDARD_CHAT_DAILY_COUNT; + const dailyFreePrivateLimit = + userState.currentUser?.dailyFreePrivateLimit ?? + FREE_PRIVATE_MESSAGE_DAILY_COUNT; + const standardChatLabel = formatDailyFreeMessages( + dailyFreeChatLimit, + "messages", + ); + const privateChatLabel = formatDailyFreeMessages( + dailyFreePrivateLimit, + "private messages", + ); + const coinRules = createCoinRules(dailyFreeChatLimit, dailyFreePrivateLimit); + + useEffect(() => { + if (!authState.hasInitialized || authState.isLoading) return; + if (authState.loginStatus === "notLoggedIn") return; + userDispatch({ type: "UserFetch" }); + }, [ + authState.hasInitialized, + authState.isLoading, + authState.loginStatus, + userDispatch, + ]); + return (
@@ -96,14 +134,14 @@ export function CoinsRulesScreen() {

Free Allowance

- Standard chat includes {FREE_STANDARD_CHAT_LABEL}. Private chat - includes {FREE_PRIVATE_MESSAGE_LABEL}. + Standard chat includes {standardChatLabel}. Private chat includes{" "} + {privateChatLabel}.

- {COIN_RULES.map((rule, index) => { + {coinRules.map((rule, index) => { const Icon = rule.icon; return (
void; onTopUp: () => void; onRulesClick: () => void; @@ -21,10 +20,18 @@ const formatCoins = (value: number): string => export function SidebarWalletCard({ creditBalance, + dailyFreeChatLimit = 0, + dailyFreeChatRemaining, + dailyFreePrivateLimit = 0, + dailyFreePrivateRemaining, onActivateVip, onTopUp, onRulesClick, }: SidebarWalletCardProps) { + const chatRemaining = dailyFreeChatRemaining ?? dailyFreeChatLimit; + const privateRemaining = + dailyFreePrivateRemaining ?? dailyFreePrivateLimit; + return (
@@ -68,12 +75,12 @@ export function SidebarWalletCard({

Free Allowance

- {FREE_STANDARD_CHAT_DAILY_COUNT} - chats / day + {chatRemaining} + / {dailyFreeChatLimit} chats left - {FREE_PRIVATE_MESSAGE_DAILY_COUNT} - private / day + {privateRemaining} + / {dailyFreePrivateLimit} private left
diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx index 0b08eee4..96ce2253 100644 --- a/src/app/sidebar/sidebar-screen.tsx +++ b/src/app/sidebar/sidebar-screen.tsx @@ -83,6 +83,12 @@ export function SidebarScreen() {
{ countryCode: "HK", intimacy: 0, dolBalance: 0, + creditBalance: 0, + dailyFreeChatLimit: 30, + dailyFreeChatUsed: 1, + dailyFreeChatRemaining: 29, + dailyFreePrivateLimit: 2, + dailyFreePrivateUsed: 0, + dailyFreePrivateRemaining: 2, + dailyQuotas: { + chat: { + limit: 30, + used: 1, + remaining: 29, + }, + privateMessage: { + limit: 2, + used: 0, + remaining: 2, + }, + }, personalityTraits: { caring: 0.5, playful: 0.5, @@ -35,6 +54,13 @@ describe("User", () => { countryCode: "HK", intimacy: 0, dolBalance: 0, + creditBalance: 0, + dailyFreeChatLimit: 30, + dailyFreeChatUsed: 1, + dailyFreeChatRemaining: 29, + dailyFreePrivateLimit: 2, + dailyFreePrivateUsed: 0, + dailyFreePrivateRemaining: 2, personalityTraits: { caring: 0.5, playful: 0.5, @@ -46,6 +72,8 @@ describe("User", () => { createdAt: "2026-06-12T08:08:27.378706+00:00", lastMessageAt: "2026-06-24T11:00:23.156561+00:00", }); + + expect("dailyQuotas" in user.toJson()).toBe(false); }); it("parses the latest backend entitlements payload", () => { diff --git a/src/data/dto/user/user.ts b/src/data/dto/user/user.ts index e01f2f37..40369902 100644 --- a/src/data/dto/user/user.ts +++ b/src/data/dto/user/user.ts @@ -17,6 +17,13 @@ export class User { declare readonly countryCode: string; declare readonly intimacy: number; declare readonly dolBalance: number; + declare readonly creditBalance: number; + declare readonly dailyFreeChatLimit: number; + declare readonly dailyFreeChatUsed: number; + declare readonly dailyFreeChatRemaining: number; + declare readonly dailyFreePrivateLimit: number; + declare readonly dailyFreePrivateUsed: number; + declare readonly dailyFreePrivateRemaining: number; declare readonly personalityTraits: PersonalityTraits; declare readonly preferredLanguage: string; declare readonly createdAt: string; diff --git a/src/data/dto/user/user_view.ts b/src/data/dto/user/user_view.ts index 59b92568..93c7146f 100644 --- a/src/data/dto/user/user_view.ts +++ b/src/data/dto/user/user_view.ts @@ -15,6 +15,12 @@ export const UserViewSchema = z.object({ intimacy: z.number(), dolBalance: z.number(), creditBalance: z.number(), + dailyFreeChatLimit: z.number(), + dailyFreeChatUsed: z.number(), + dailyFreeChatRemaining: z.number(), + dailyFreePrivateLimit: z.number(), + dailyFreePrivateUsed: z.number(), + dailyFreePrivateRemaining: z.number(), vipExpiresAt: z.string().nullable(), relationshipStage: z.string(), currentMood: z.string(), diff --git a/src/data/schemas/user/user.ts b/src/data/schemas/user/user.ts index 81abd764..bcd5b894 100644 --- a/src/data/schemas/user/user.ts +++ b/src/data/schemas/user/user.ts @@ -18,6 +18,13 @@ export const UserSchema = z.object({ countryCode: stringOrEmpty, intimacy: numberOrZero, dolBalance: numberOrZero, + creditBalance: numberOrZero, + dailyFreeChatLimit: numberOrZero, + dailyFreeChatUsed: numberOrZero, + dailyFreeChatRemaining: numberOrZero, + dailyFreePrivateLimit: numberOrZero, + dailyFreePrivateUsed: numberOrZero, + dailyFreePrivateRemaining: numberOrZero, personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS), preferredLanguage: stringOrEmpty, createdAt: stringOrEmpty, diff --git a/src/stores/user/__tests__/user-machine.helpers.test.ts b/src/stores/user/__tests__/user-machine.helpers.test.ts index 97571ed2..fe69be9a 100644 --- a/src/stores/user/__tests__/user-machine.helpers.test.ts +++ b/src/stores/user/__tests__/user-machine.helpers.test.ts @@ -17,6 +17,12 @@ describe("toView", () => { intimacy: 0, dolBalance: 0, creditBalance: 0, + dailyFreeChatLimit: 0, + dailyFreeChatUsed: 0, + dailyFreeChatRemaining: 0, + dailyFreePrivateLimit: 0, + dailyFreePrivateUsed: 0, + dailyFreePrivateRemaining: 0, vipExpiresAt: null, relationshipStage: "", currentMood: "", @@ -38,6 +44,12 @@ describe("toView", () => { intimacy: 42, dolBalance: 12, creditBalance: 120, + dailyFreeChatLimit: 30, + dailyFreeChatUsed: 4, + dailyFreeChatRemaining: 26, + dailyFreePrivateLimit: 2, + dailyFreePrivateUsed: 1, + dailyFreePrivateRemaining: 1, vipExpiresAt: "2026-07-26T00:00:00+00:00", relationshipStage: "close_friend", currentMood: "playful", @@ -55,6 +67,12 @@ describe("toView", () => { intimacy: 42, dolBalance: 12, creditBalance: 120, + dailyFreeChatLimit: 30, + dailyFreeChatUsed: 4, + dailyFreeChatRemaining: 26, + dailyFreePrivateLimit: 2, + dailyFreePrivateUsed: 1, + dailyFreePrivateRemaining: 1, vipExpiresAt: "2026-07-26T00:00:00+00:00", relationshipStage: "close_friend", currentMood: "playful", diff --git a/src/stores/user/__tests__/user-machine.test.ts b/src/stores/user/__tests__/user-machine.test.ts index b3710c82..5c8fed55 100644 --- a/src/stores/user/__tests__/user-machine.test.ts +++ b/src/stores/user/__tests__/user-machine.test.ts @@ -19,6 +19,12 @@ function makeUser(overrides: Partial = {}): UserView { intimacy: 42, dolBalance: 7, creditBalance: 7, + dailyFreeChatLimit: 30, + dailyFreeChatUsed: 0, + dailyFreeChatRemaining: 30, + dailyFreePrivateLimit: 2, + dailyFreePrivateUsed: 0, + dailyFreePrivateRemaining: 2, vipExpiresAt: null, relationshipStage: "close_friend", currentMood: "happy", diff --git a/src/stores/user/user-machine.helpers.ts b/src/stores/user/user-machine.helpers.ts index 62338048..fbc0f82e 100644 --- a/src/stores/user/user-machine.helpers.ts +++ b/src/stores/user/user-machine.helpers.ts @@ -63,6 +63,12 @@ export function toView(u: { intimacy?: number; dolBalance?: number; creditBalance?: number; + dailyFreeChatLimit?: number; + dailyFreeChatUsed?: number; + dailyFreeChatRemaining?: number; + dailyFreePrivateLimit?: number; + dailyFreePrivateUsed?: number; + dailyFreePrivateRemaining?: number; vipExpiresAt?: string | null; relationshipStage?: string; currentMood?: string; @@ -82,6 +88,12 @@ export function toView(u: { intimacy: u.intimacy ?? 0, dolBalance: u.dolBalance ?? creditBalance, creditBalance, + dailyFreeChatLimit: u.dailyFreeChatLimit ?? 0, + dailyFreeChatUsed: u.dailyFreeChatUsed ?? 0, + dailyFreeChatRemaining: u.dailyFreeChatRemaining ?? 0, + dailyFreePrivateLimit: u.dailyFreePrivateLimit ?? 0, + dailyFreePrivateUsed: u.dailyFreePrivateUsed ?? 0, + dailyFreePrivateRemaining: u.dailyFreePrivateRemaining ?? 0, vipExpiresAt: u.vipExpiresAt ?? null, relationshipStage: u.relationshipStage ?? "", currentMood: u.currentMood ?? "",