From cdb8d522ddc23327c99d1b85895bffbbde106e36 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 16:28:59 +0330 Subject: [PATCH] Economy: free vs paid games + buy-coins page; friends remove confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Coins only matter for ranked: free games (vs computer / private friend rooms) cost nothing; random ranked requires an entry (stake), gated by balance → routes to buy-coins when short - Buy Coins page (CoinPack/getCoinPacks/buyCoins; mock credits now, real Zarinpal/IDPay TODO); TopBar coins → buy; lobby create-room is Free - Friends: removed instant red ✕ delete; UserMinus → inline confirm before remove Co-Authored-By: Claude Opus 4.8 --- src/app/page.tsx | 3 + src/components/online/TopBar.tsx | 9 +- src/components/screens/BuyCoinsScreen.tsx | 92 ++++++++++++++++++++ src/components/screens/FriendsScreen.tsx | 62 +++++++++---- src/components/screens/OnlineLobbyScreen.tsx | 51 ++++++++--- src/lib/i18n.tsx | 28 ++++++ src/lib/online/gamification.ts | 7 +- src/lib/online/mock-service.ts | 21 +++++ src/lib/online/service.ts | 5 ++ src/lib/online/signalr-service.ts | 2 + src/lib/online/types.ts | 10 +++ src/lib/ui-store.ts | 5 +- 12 files changed, 257 insertions(+), 38 deletions(-) create mode 100644 src/components/screens/BuyCoinsScreen.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 2370b5a..5dee243 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,6 +10,7 @@ import { RoomScreen } from "@/components/screens/RoomScreen"; import { MatchmakingScreen } from "@/components/screens/MatchmakingScreen"; import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen"; import { ShopScreen } from "@/components/screens/ShopScreen"; +import { BuyCoinsScreen } from "@/components/screens/BuyCoinsScreen"; import { ChatScreen } from "@/components/screens/ChatScreen"; import { NotificationsScreen } from "@/components/screens/NotificationsScreen"; import { AuthScreen } from "@/components/screens/AuthScreen"; @@ -101,6 +102,8 @@ function renderScreen(screen: string) { return ; case "shop": return ; + case "buycoins": + return ; case "chat": return ; case "notifications": diff --git a/src/components/online/TopBar.tsx b/src/components/online/TopBar.tsx index f831acc..798dc73 100644 --- a/src/components/online/TopBar.tsx +++ b/src/components/online/TopBar.tsx @@ -55,12 +55,17 @@ export function TopBar() { > -
+
+ + + ); diff --git a/src/components/screens/BuyCoinsScreen.tsx b/src/components/screens/BuyCoinsScreen.tsx new file mode 100644 index 0000000..859edea --- /dev/null +++ b/src/components/screens/BuyCoinsScreen.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Coins } from "lucide-react"; +import { useEffect, useState } from "react"; +import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; +import { useSessionStore } from "@/lib/session-store"; +import { useI18n } from "@/lib/i18n"; +import { getService } from "@/lib/online/service"; +import { sound } from "@/lib/sound"; +import { CoinPack } from "@/lib/online/types"; +import { cn } from "@/lib/cn"; + +export function BuyCoinsScreen() { + const { t, locale } = useI18n(); + const profile = useSessionStore((s) => s.profile); + const setProfile = useSessionStore((s) => s.setProfile); + const [packs, setPacks] = useState([]); + const [busy, setBusy] = useState(null); + const [gained, setGained] = useState(null); + + useEffect(() => { + getService().getCoinPacks().then(setPacks); + }, []); + + const fmt = (n: number) => + new Intl.NumberFormat(locale === "fa" ? "fa-IR" : "en-US").format(n); + + const buy = async (p: CoinPack) => { + setBusy(p.id); + const res = await getService().buyCoins(p.id); + if (res.ok && res.profile) { + setProfile(res.profile); + sound.play("purchase"); + setGained(res.coins); + setTimeout(() => setGained(null), 2500); + } + setBusy(null); + }; + + return ( + + + + {fmt(profile.coins)} + + ) + } + /> +

{t("buy.note")}

+ + {gained != null && ( +
+ +{fmt(gained)} +
+ )} + +
+ {packs.map((p) => ( + + ))} +
+
+ ); +} diff --git a/src/components/screens/FriendsScreen.tsx b/src/components/screens/FriendsScreen.tsx index 79167f3..4cdb35e 100644 --- a/src/components/screens/FriendsScreen.tsx +++ b/src/components/screens/FriendsScreen.tsx @@ -1,6 +1,6 @@ "use client"; -import { Check, MessageCircle, UserPlus, X } from "lucide-react"; +import { Check, MessageCircle, UserMinus, UserPlus, X } from "lucide-react"; import { useEffect, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { useOnlineStore } from "@/lib/online-store"; @@ -28,6 +28,7 @@ export function FriendsScreen() { const go = useUIStore((s) => s.go); const [query, setQuery] = useState(""); + const [confirmId, setConfirmId] = useState(null); useEffect(() => { load(); @@ -113,23 +114,48 @@ export function FriendsScreen() { {Math.round(f.rating)} - - + {confirmId === f.id ? ( + <> + {t("friends.removeQ")} + + + + ) : ( + <> + + + + )} ))} diff --git a/src/components/screens/OnlineLobbyScreen.tsx b/src/components/screens/OnlineLobbyScreen.tsx index 4c6e9c0..fb98437 100644 --- a/src/components/screens/OnlineLobbyScreen.tsx +++ b/src/components/screens/OnlineLobbyScreen.tsx @@ -5,52 +5,72 @@ import { Coins, Trophy, Users } from "lucide-react"; import { useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { useOnlineStore } from "@/lib/online-store"; +import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { cn } from "@/lib/cn"; -const STAKES = [0, 100, 500, 1000]; +const ENTRIES = [100, 500, 1000]; export function OnlineLobbyScreen() { const { t } = useI18n(); const createRoom = useOnlineStore((s) => s.createRoom); const startMatchmaking = useOnlineStore((s) => s.startMatchmaking); const go = useUIStore((s) => s.go); - const [stake, setStake] = useState(100); + const coins = useSessionStore((s) => s.profile?.coins ?? 0); + const [entry, setEntry] = useState(100); + // Private rooms with friends are free. const onCreate = async () => { - await createRoom({ targetScore: 7, stake, ranked: false }); + await createRoom({ targetScore: 7, stake: 0, ranked: false }); go("room"); }; + + // Ranked random always costs the entry (you stake it). const onRandom = async () => { - await startMatchmaking({ ranked: true, stake }); + if (coins < entry) { + go("buycoins"); + return; + } + await startMatchmaking({ ranked: true, stake: entry }); go("matchmaking"); }; return ( - + + + {coins.toLocaleString()} + + } + /> - {/* stake */} + {/* entry (only for ranked) */}
- {t("room.stake")} + {t("lobby.entry")}
- {STAKES.map((s) => ( + {ENTRIES.map((s) => ( ))}
+ {coins < entry && ( +

{t("lobby.needCoins")}

+ )}
@@ -63,10 +83,14 @@ export function OnlineLobbyScreen() { - + {t("lobby.random")} {t("lobby.randomDesc")} + + {entry} + + - + {t("lobby.createRoom")} {t("lobby.createDesc")} + {t("lobby.free")}
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 556732f..6b6b885 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -89,6 +89,20 @@ const fa: Dict = { "common.copy": "کپی", "common.copied": "کپی شد", "common.free": "رایگان", + "common.yes": "بله", + "common.no": "خیر", + + "buy.title": "خرید سکه", + "buy.note": "پرداخت امن — درگاه پرداخت ایرانی به‌زودی اضافه می‌شود", + "buy.toman": "تومان", + "buy.bonus": "هدیه", + "buy.popular": "محبوب", + "buy.best": "بهترین", + + "lobby.entry": "ورودی", + "lobby.free": "رایگان", + "lobby.needCoins": "سکه کافی نیست — شارژ کنید", + "friends.removeQ": "این دوست حذف شود؟", "chat.title": "گفتگو", "chat.placeholder": "پیام بنویسید…", @@ -316,6 +330,20 @@ const en: Dict = { "common.copy": "Copy", "common.copied": "Copied", "common.free": "Free", + "common.yes": "Yes", + "common.no": "No", + + "buy.title": "Buy Coins", + "buy.note": "Secure payment — Iranian gateway coming soon", + "buy.toman": "Toman", + "buy.bonus": "bonus", + "buy.popular": "Popular", + "buy.best": "Best value", + + "lobby.entry": "Entry", + "lobby.free": "Free", + "lobby.needCoins": "Not enough coins — top up", + "friends.removeQ": "Remove this friend?", "chat.title": "Chat", "chat.placeholder": "Type a message…", diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index d6bf331..e134bbe 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -104,10 +104,11 @@ export function ratingDelta( /* ------------------------------- Coins ------------------------------- */ export function coinDelta(summary: MatchSummary): number { - const base = summary.won ? (summary.ranked ? 50 : 25) : 10; - const stakeNet = summary.won ? summary.stake : -summary.stake; + // Free games (vs computer / private friend rooms) never touch coins. + if (!summary.ranked) return 0; + // Ranked: win the stake (+kot bonus), lose the stake. const kotBonus = summary.won && summary.kotFor ? 40 : 0; - return base + stakeNet + kotBonus; + return (summary.won ? summary.stake : -summary.stake) + kotBonus; } /* ------------------------------- XP ---------------------------------- */ diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 33db267..caf1f43 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -21,6 +21,7 @@ import { AppNotification, AuthSession, ChatMessage, + CoinPack, Conversation, DailyRewardState, Friend, @@ -772,6 +773,26 @@ export class MockOnlineService implements OnlineService { /* --------------------- leaderboard / shop / daily ------------------ */ + async getCoinPacks(): Promise { + return [ + { id: "p1", coins: 1000, bonus: 0, priceToman: 19000 }, + { id: "p2", coins: 5000, bonus: 500, priceToman: 89000, tag: "popular" }, + { id: "p3", coins: 12000, bonus: 2000, priceToman: 179000, tag: "best" }, + { id: "p4", coins: 30000, bonus: 7000, priceToman: 399000 }, + ]; + } + + async buyCoins(packId: string) { + const p = await this.getProfile(); + const pack = (await this.getCoinPacks()).find((x) => x.id === packId); + if (!pack) return { ok: false, coins: 0 }; + // NOTE: real payment (Zarinpal/IDPay) goes here. For now we credit instantly. + const added = pack.coins + pack.bonus; + this.profile = { ...p, coins: p.coins + added }; + this.saveProfile(); + return { ok: true, profile: this.profile, coins: added }; + } + private onlineCount = 600 + Math.floor(Math.random() * 900); async getOnlineCount(): Promise { // gentle random walk so the badge feels alive diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index 4244f0e..c797445 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -7,6 +7,7 @@ import { AppNotification, AuthSession, ChatMessage, + CoinPack, Conversation, DailyRewardState, Friend, @@ -111,6 +112,10 @@ export interface OnlineService { buyItem(id: string): Promise<{ ok: boolean; profile?: UserProfile; messageFa: string; messageEn: string }>; getDailyState(): Promise; claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }>; + + /* ----- coin purchases (real payment gateway: TODO Zarinpal/IDPay) ----- */ + getCoinPacks(): Promise; + buyCoins(packId: string): Promise<{ ok: boolean; profile?: UserProfile; coins: number }>; } import { MockOnlineService } from "./mock-service"; diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts index 5a0078e..05c3804 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -293,4 +293,6 @@ export class SignalrService implements OnlineService { buyItem(id: string) { return this.mock.buyItem(id); } getDailyState(): Promise { return this.mock.getDailyState(); } claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }> { return this.mock.claimDaily(); } + getCoinPacks() { return this.mock.getCoinPacks(); } + buyCoins(id: string) { return this.mock.buyCoins(id); } } diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 1e40fa6..50311e3 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -327,6 +327,16 @@ export interface ShopItem { preview: string; // emoji/avatar id/color } +/* ------------------------------ Coin packs --------------------------- */ + +export interface CoinPack { + id: string; + coins: number; + bonus: number; // extra coins + priceToman: number; + tag?: "popular" | "best"; +} + /* --------------------------- Daily reward ---------------------------- */ export interface DailyRewardState { diff --git a/src/lib/ui-store.ts b/src/lib/ui-store.ts index 67e866a..35ac767 100644 --- a/src/lib/ui-store.ts +++ b/src/lib/ui-store.ts @@ -12,18 +12,19 @@ export type Screen = | "matchmaking" | "leaderboard" | "shop" + | "buycoins" | "chat" | "notifications" | "game"; // the table (used for both ai + online) const ALL_SCREENS: Screen[] = [ "home", "auth", "profile", "friends", "online", - "room", "matchmaking", "leaderboard", "shop", "chat", "notifications", "game", + "room", "matchmaking", "leaderboard", "shop", "buycoins", "chat", "notifications", "game", ]; /** Screens safe to restore from a URL on a cold load (no transient state needed). */ export const STATIC_SCREENS: Screen[] = [ - "home", "auth", "profile", "friends", "online", "leaderboard", "shop", "notifications", + "home", "auth", "profile", "friends", "online", "leaderboard", "shop", "buycoins", "notifications", ]; export function screenFromHash(): Screen {