feat(profile): surface daily free quotas

This commit is contained in:
2026-07-03 16:56:06 +08:00
parent 1fcad49baa
commit d17ab6c23d
11 changed files with 152 additions and 20 deletions
+46 -8
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect } from "react";
import { import {
Gift, Gift,
ImageIcon, ImageIcon,
@@ -12,10 +13,12 @@ import { FaCoins } from "react-icons/fa6";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import { import {
FREE_PRIVATE_MESSAGE_LABEL, FREE_PRIVATE_MESSAGE_DAILY_COUNT,
FREE_STANDARD_CHAT_LABEL, FREE_STANDARD_CHAT_DAILY_COUNT,
} from "./coins-rules.constants"; } from "./coins-rules.constants";
import styles from "./coins-rules-screen.module.css"; import styles from "./coins-rules-screen.module.css";
@@ -26,17 +29,23 @@ interface CoinRuleItem {
icon: typeof MessageCircle; 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", title: "Standard Chat",
cost: "2 coins / message", cost: "2 coins / message",
free: FREE_STANDARD_CHAT_LABEL, free: formatDailyFreeMessages(dailyFreeChatLimit, "messages"),
icon: MessageCircle, icon: MessageCircle,
}, },
{ {
title: "Private Chat", title: "Private Chat",
cost: "10 coins / message", cost: "10 coins / message",
free: FREE_PRIVATE_MESSAGE_LABEL, free: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"),
icon: Sparkles, icon: Sparkles,
}, },
{ {
@@ -67,6 +76,35 @@ const COIN_RULES: readonly CoinRuleItem[] = [
] as const; ] as const;
export function CoinsRulesScreen() { 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 ( return (
<MobileShell background="#fcf3f4"> <MobileShell background="#fcf3f4">
<main className={styles.screen}> <main className={styles.screen}>
@@ -96,14 +134,14 @@ export function CoinsRulesScreen() {
<div> <div>
<h2 className={styles.freeTitle}>Free Allowance</h2> <h2 className={styles.freeTitle}>Free Allowance</h2>
<p className={styles.freeText}> <p className={styles.freeText}>
Standard chat includes {FREE_STANDARD_CHAT_LABEL}. Private chat Standard chat includes {standardChatLabel}. Private chat includes{" "}
includes {FREE_PRIVATE_MESSAGE_LABEL}. {privateChatLabel}.
</p> </p>
</div> </div>
</section> </section>
<section className={styles.ruleList} aria-label="Coins usage rules"> <section className={styles.ruleList} aria-label="Coins usage rules">
{COIN_RULES.map((rule, index) => { {coinRules.map((rule, index) => {
const Icon = rule.icon; const Icon = rule.icon;
return ( return (
<article <article
@@ -1,5 +1,2 @@
export const FREE_STANDARD_CHAT_DAILY_COUNT = 30; export const FREE_STANDARD_CHAT_DAILY_COUNT = 30;
export const FREE_PRIVATE_MESSAGE_DAILY_COUNT = 2; export const FREE_PRIVATE_MESSAGE_DAILY_COUNT = 2;
export const FREE_STANDARD_CHAT_LABEL = `${FREE_STANDARD_CHAT_DAILY_COUNT} free messages daily`;
export const FREE_PRIVATE_MESSAGE_LABEL = `${FREE_PRIVATE_MESSAGE_DAILY_COUNT} free private messages daily`;
@@ -2,15 +2,14 @@
import { FaCoins } from "react-icons/fa6"; import { FaCoins } from "react-icons/fa6";
import {
FREE_PRIVATE_MESSAGE_DAILY_COUNT,
FREE_STANDARD_CHAT_DAILY_COUNT,
} from "@/app/coins-rules/coins-rules.constants";
import styles from "./sidebar-wallet-card.module.css"; import styles from "./sidebar-wallet-card.module.css";
export interface SidebarWalletCardProps { export interface SidebarWalletCardProps {
creditBalance: number; creditBalance: number;
dailyFreeChatLimit?: number;
dailyFreeChatRemaining?: number;
dailyFreePrivateLimit?: number;
dailyFreePrivateRemaining?: number;
onActivateVip?: () => void; onActivateVip?: () => void;
onTopUp: () => void; onTopUp: () => void;
onRulesClick: () => void; onRulesClick: () => void;
@@ -21,10 +20,18 @@ const formatCoins = (value: number): string =>
export function SidebarWalletCard({ export function SidebarWalletCard({
creditBalance, creditBalance,
dailyFreeChatLimit = 0,
dailyFreeChatRemaining,
dailyFreePrivateLimit = 0,
dailyFreePrivateRemaining,
onActivateVip, onActivateVip,
onTopUp, onTopUp,
onRulesClick, onRulesClick,
}: SidebarWalletCardProps) { }: SidebarWalletCardProps) {
const chatRemaining = dailyFreeChatRemaining ?? dailyFreeChatLimit;
const privateRemaining =
dailyFreePrivateRemaining ?? dailyFreePrivateLimit;
return ( return (
<section className={styles.card} aria-label="My wallet"> <section className={styles.card} aria-label="My wallet">
<div className={styles.leftColumn}> <div className={styles.leftColumn}>
@@ -68,12 +75,12 @@ export function SidebarWalletCard({
<p className={styles.freeAllowanceTitle}>Free Allowance</p> <p className={styles.freeAllowanceTitle}>Free Allowance</p>
<div className={styles.freeAllowanceItems}> <div className={styles.freeAllowanceItems}>
<span className={styles.freeAllowanceItem}> <span className={styles.freeAllowanceItem}>
<strong>{FREE_STANDARD_CHAT_DAILY_COUNT}</strong> <strong>{chatRemaining}</strong>
<span> chats / day</span> <span> / {dailyFreeChatLimit} chats left</span>
</span> </span>
<span className={styles.freeAllowanceItem}> <span className={styles.freeAllowanceItem}>
<strong>{FREE_PRIVATE_MESSAGE_DAILY_COUNT}</strong> <strong>{privateRemaining}</strong>
<span> private / day</span> <span> / {dailyFreePrivateLimit} private left</span>
</span> </span>
</div> </div>
</div> </div>
+6
View File
@@ -83,6 +83,12 @@ export function SidebarScreen() {
<section className={`${styles.cardSlot} ${styles.revealTwo}`}> <section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<SidebarWalletCard <SidebarWalletCard
creditBalance={user.creditBalance} creditBalance={user.creditBalance}
dailyFreeChatLimit={user.currentUser?.dailyFreeChatLimit}
dailyFreeChatRemaining={user.currentUser?.dailyFreeChatRemaining}
dailyFreePrivateLimit={user.currentUser?.dailyFreePrivateLimit}
dailyFreePrivateRemaining={
user.currentUser?.dailyFreePrivateRemaining
}
onActivateVip={ onActivateVip={
sidebarState === "vip" sidebarState === "vip"
? undefined ? undefined
+28
View File
@@ -14,6 +14,25 @@ describe("User", () => {
countryCode: "HK", countryCode: "HK",
intimacy: 0, intimacy: 0,
dolBalance: 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: { personalityTraits: {
caring: 0.5, caring: 0.5,
playful: 0.5, playful: 0.5,
@@ -35,6 +54,13 @@ describe("User", () => {
countryCode: "HK", countryCode: "HK",
intimacy: 0, intimacy: 0,
dolBalance: 0, dolBalance: 0,
creditBalance: 0,
dailyFreeChatLimit: 30,
dailyFreeChatUsed: 1,
dailyFreeChatRemaining: 29,
dailyFreePrivateLimit: 2,
dailyFreePrivateUsed: 0,
dailyFreePrivateRemaining: 2,
personalityTraits: { personalityTraits: {
caring: 0.5, caring: 0.5,
playful: 0.5, playful: 0.5,
@@ -46,6 +72,8 @@ describe("User", () => {
createdAt: "2026-06-12T08:08:27.378706+00:00", createdAt: "2026-06-12T08:08:27.378706+00:00",
lastMessageAt: "2026-06-24T11:00:23.156561+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", () => { it("parses the latest backend entitlements payload", () => {
+7
View File
@@ -17,6 +17,13 @@ export class User {
declare readonly countryCode: string; declare readonly countryCode: string;
declare readonly intimacy: number; declare readonly intimacy: number;
declare readonly dolBalance: 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 personalityTraits: PersonalityTraits;
declare readonly preferredLanguage: string; declare readonly preferredLanguage: string;
declare readonly createdAt: string; declare readonly createdAt: string;
+6
View File
@@ -15,6 +15,12 @@ export const UserViewSchema = z.object({
intimacy: z.number(), intimacy: z.number(),
dolBalance: z.number(), dolBalance: z.number(),
creditBalance: 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(), vipExpiresAt: z.string().nullable(),
relationshipStage: z.string(), relationshipStage: z.string(),
currentMood: z.string(), currentMood: z.string(),
+7
View File
@@ -18,6 +18,13 @@ export const UserSchema = z.object({
countryCode: stringOrEmpty, countryCode: stringOrEmpty,
intimacy: numberOrZero, intimacy: numberOrZero,
dolBalance: numberOrZero, dolBalance: numberOrZero,
creditBalance: numberOrZero,
dailyFreeChatLimit: numberOrZero,
dailyFreeChatUsed: numberOrZero,
dailyFreeChatRemaining: numberOrZero,
dailyFreePrivateLimit: numberOrZero,
dailyFreePrivateUsed: numberOrZero,
dailyFreePrivateRemaining: numberOrZero,
personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS), personalityTraits: PersonalityTraitsSchema.default(PERSONALITY_TRAITS_DEFAULTS),
preferredLanguage: stringOrEmpty, preferredLanguage: stringOrEmpty,
createdAt: stringOrEmpty, createdAt: stringOrEmpty,
@@ -17,6 +17,12 @@ describe("toView", () => {
intimacy: 0, intimacy: 0,
dolBalance: 0, dolBalance: 0,
creditBalance: 0, creditBalance: 0,
dailyFreeChatLimit: 0,
dailyFreeChatUsed: 0,
dailyFreeChatRemaining: 0,
dailyFreePrivateLimit: 0,
dailyFreePrivateUsed: 0,
dailyFreePrivateRemaining: 0,
vipExpiresAt: null, vipExpiresAt: null,
relationshipStage: "", relationshipStage: "",
currentMood: "", currentMood: "",
@@ -38,6 +44,12 @@ describe("toView", () => {
intimacy: 42, intimacy: 42,
dolBalance: 12, dolBalance: 12,
creditBalance: 120, creditBalance: 120,
dailyFreeChatLimit: 30,
dailyFreeChatUsed: 4,
dailyFreeChatRemaining: 26,
dailyFreePrivateLimit: 2,
dailyFreePrivateUsed: 1,
dailyFreePrivateRemaining: 1,
vipExpiresAt: "2026-07-26T00:00:00+00:00", vipExpiresAt: "2026-07-26T00:00:00+00:00",
relationshipStage: "close_friend", relationshipStage: "close_friend",
currentMood: "playful", currentMood: "playful",
@@ -55,6 +67,12 @@ describe("toView", () => {
intimacy: 42, intimacy: 42,
dolBalance: 12, dolBalance: 12,
creditBalance: 120, creditBalance: 120,
dailyFreeChatLimit: 30,
dailyFreeChatUsed: 4,
dailyFreeChatRemaining: 26,
dailyFreePrivateLimit: 2,
dailyFreePrivateUsed: 1,
dailyFreePrivateRemaining: 1,
vipExpiresAt: "2026-07-26T00:00:00+00:00", vipExpiresAt: "2026-07-26T00:00:00+00:00",
relationshipStage: "close_friend", relationshipStage: "close_friend",
currentMood: "playful", currentMood: "playful",
@@ -19,6 +19,12 @@ function makeUser(overrides: Partial<UserView> = {}): UserView {
intimacy: 42, intimacy: 42,
dolBalance: 7, dolBalance: 7,
creditBalance: 7, creditBalance: 7,
dailyFreeChatLimit: 30,
dailyFreeChatUsed: 0,
dailyFreeChatRemaining: 30,
dailyFreePrivateLimit: 2,
dailyFreePrivateUsed: 0,
dailyFreePrivateRemaining: 2,
vipExpiresAt: null, vipExpiresAt: null,
relationshipStage: "close_friend", relationshipStage: "close_friend",
currentMood: "happy", currentMood: "happy",
+12
View File
@@ -63,6 +63,12 @@ export function toView(u: {
intimacy?: number; intimacy?: number;
dolBalance?: number; dolBalance?: number;
creditBalance?: number; creditBalance?: number;
dailyFreeChatLimit?: number;
dailyFreeChatUsed?: number;
dailyFreeChatRemaining?: number;
dailyFreePrivateLimit?: number;
dailyFreePrivateUsed?: number;
dailyFreePrivateRemaining?: number;
vipExpiresAt?: string | null; vipExpiresAt?: string | null;
relationshipStage?: string; relationshipStage?: string;
currentMood?: string; currentMood?: string;
@@ -82,6 +88,12 @@ export function toView(u: {
intimacy: u.intimacy ?? 0, intimacy: u.intimacy ?? 0,
dolBalance: u.dolBalance ?? creditBalance, dolBalance: u.dolBalance ?? creditBalance,
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, vipExpiresAt: u.vipExpiresAt ?? null,
relationshipStage: u.relationshipStage ?? "", relationshipStage: u.relationshipStage ?? "",
currentMood: u.currentMood ?? "", currentMood: u.currentMood ?? "",