feat(profile): surface daily free quotas
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
|
|||||||
Reference in New Issue
Block a user