diff --git a/HANDOFF.md b/HANDOFF.md index f9614d6..dbacab1 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -60,16 +60,27 @@ npm run build # next static export ## 3. Feature status (DONE) - Full offline vs-AI game (engine, AI, turn timer + auto-play, disconnect/reconnect sim). -- Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards. +- Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards. **Matchmaking waits ~15s (randomized 12–18s) for humans, then bots fill** (`GameManager.NextQueueWaitMs`; mock mirrors it in `beginSearch`). MatchmakingScreen shows the elapsed timer + a bot-fill hint. +- **Per-league turn time** (think faster in higher leagues): Starter/vs-AI/private → **15s**, Pro (stake ≥500) → **10s**, Expert (stake ≥1000) → **7s**. Single source: `turnMsForStake(stake, speed?)` in `gamification.ts`; the live server mirrors it in `GameRoom.TurnMs`. The turn-timer bar reads it from `matchMeta.stake`. +- **Speed (Blitz) mode** — CLIENT-ONLY (vs-AI + private rooms; ranked stays standard). Flat **5s** turn clock (`SPEED_TURN_MS`), races to **5** points (`SPEED_TARGET_SCORE`), and ~½ pacing on animations/pauses (the `fast()` scaler in `game-store.scheduleAuto`). Threaded via `matchMeta.speed` + `GameSettings.speed`/`OnlineMatchConfig.speed`. Toggle on Home's vs-Computer card; a `SpeedBadge` (⚡) shows on the table HUD. No server change needed — private rooms are client-driven even in live mode, and ranked is intentionally excluded. - **Economy:** coins; ranked entry = stake (win +stake [+kot 40], lose −stake); free vs-computer/private rooms. Buy-coins via **ZarinPal sandbox** (merchant `299685fb-cadf-4dfc-98e2-d4af5d81528d`, config-driven). Coin packs: starter 50k/95,000﷼, … Stores (Bazaar/Myket) must use their **IAB** (`/api/coins/iab/verify` scaffolded; token verification TODO). - **XP/levels:** every game grants XP, **winner ×2**; **premium (pro) ×1.5**; max level 100; curve `100*lvl + 15*lvl²`. **Store sells XP packs** (xp1 +200/5k, xp2 +600/12k, xp3 +1500/25k coins; consumable; unlocks level achievements). - **Achievements:** ~100, metric-driven generator (categories: victory/kot/streak/hakem/level/rank/veteran), incl. "7× hakem", "7–0 sweep". Dedicated **AchievementsScreen** (tabbed) + Profile summary. Some unlock **sticker packs**. -- **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, sticker packs (custom SVG art incl. crown/seven-zip/streak-fire). Profile **photo upload gated at level ≥ 25**. -- **Social:** friends + chat **server-persisted** (`Social/SocialService`, REST + hub `friendRequest`/`social`/`chat`); friend remove needs confirm. **Premium chat = animated gold bubbles**. +- **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, **16 sticker packs** (all custom inline-SVG art in `Sticker.tsx`). **✨ Luxury tier** (premium giftable items): luxury avatars (🦢🎩💎💰🏆 + 💠 rank-gated), luxury card backs (Diamond/Black Gold/Platinum/Peacock/Rose-Gold) + fronts (Diamond/Black Gold), luxury titles (Hokm Sultan/Emperor/Grandmaster). Shop tags items priced ≥2000 with a gold **«ویژه/Luxury»** badge + ring. +- **Card backs are pattern-distinct** (not just recoloured): each `CardBackDef` carries a `pattern` (`stripes/argyle/grid/dots/rays/scales/crosshatch/royal/filigree/gem`) + optional `motif` glyph. Rendering lives in `src/lib/cardBack.ts` (`cardBackVisual`/`cardBackMotif`/`backVisualFromDef`), used by `PlayingCard`, the shop preview, and the profile picker so all three match. +- **Purchasable titles:** `TitleDef.price` makes a title buyable; shop **Titles** section (`ShopItemKind` now includes `"title"`, server `ShopBuy` handles `title → OwnedTitles`, mock mirrors it). The equipped title shows **under your name on the table** (`SeatPlayer.title` → `SeatAvatar`, seat 0 from your profile in every mode incl. live via `applyServerState`) and in the **Discover/find list** (`PlayerSummary.title`, server `ToSummary`). Localize via `titleById(id)`. New themed packs: **کل‌کل/banter** (kolkol, tikeh, shakkak, raghib), **Persian trends/praise** (trends, tashvigh), **court cards** (khanevadeh: تک‌خال/آس دل/شاه خشت/بی‌بی گشنیز), moods (ehsasat). Banter uses the `Stamp` helper (rounded badge + Persian phrase); court cards use `CourtCard`. Profile **photo upload gated at level ≥ 25**. New sticker packs are **client-only** (server `ShopBuy` is generic) — add art to `Sticker.tsx` + an entry in `STICKER_PACKS`. +- **Daily rewards** (boosted): `[300, 500, 750, 1000, 1500, 2500, 7500]` — **must stay in sync** between client `gamification.ts DAILY_REWARDS` and server `ProfileService.DailyRewards` (server is authoritative for the claim). Day 7 is the gold "special" tier. +- **Social:** friends + chat **server-persisted** (`Social/SocialService`, REST + hub `friendRequest`/`social`/`chat`); friend remove needs confirm. **Premium chat = animated gold bubbles**. **Friend-request rate limit = 10 / rolling hour** per user (server `SocialService.TryRecordRequest`, static in-memory; mirrored in the mock). Both `addFriend(query)` and `addFriendById(userId)` funnel through it. +- **Public profiles:** tap any player in the **leaderboard / friends list / end-of-game roster** → `PublicProfileModal` (global, `ui-store.viewProfile(id)`) shows their identity, stats, and **achievement board** + a rate-limited **Add-friend** button. Server `GET /api/profile/{id}/public` → `PublicProfileDto` (no coins/phone/email); mock synthesizes deterministic stats seeded from the id. Client `OnlineService.getPublicProfile(id)` / `addFriendById(id)`. +- **Social hub:** `FriendsScreen` is now tabbed — **Friends / Discover / Messages**. **Discover** = find-friends search (debounced) + suggested players, each row taps to the public profile and has a rate-limited Add button. **Messages** = conversation inbox (`listConversations`, unread badges, relative time) → opens `ChatScreen`. New: `OnlineService.searchPlayers(q)` / `suggestedPlayers()` → `PlayerSummary[]`; server `GET /api/players/search?q=` + `/api/players/suggested` (`SocialService.SearchPlayers`/`Suggested`, online-first, excludes friends/self). Mock synthesizes results. +- **Profile gender + social links:** `UserProfile.gender` (`""|male|female|other`, shown as ♂/♀/⚧ in Discover + public profile + edited in `ProfileScreen`'s `SocialSettings`), `socials` (instagram/telegram/x/youtube handles or URLs, rendered as tappable chips), and `socialsVisibility` (**public / friends / hidden**). Helpers in `src/lib/social.ts` (`GENDER_META`, `SOCIAL_PLATFORMS`, `socialUrl`, `hasSocials`). **Privacy is server-enforced:** `SocialService.GetPublicProfile` only includes `Socials` when `public`, or `friends` && the viewer is a friend, or it's you; `hidden` → never. `PlayerSummary.gender` carried in discovery. Server fields on `ProfileDto` (`Gender`/`Socials`/`SocialsVisibility`); `ProfileService.Update` parses them; `updateProfile` patch widened (client interface + session-store + mock + signalr). - **Forfeit:** request + teammate-confirm (server `GameRoom` forfeit flow); penalty = **lose 2× coins + 0 XP** (NO kot, and never mention kot); confirm dialog alerts the penalty. - **End-of-game roster:** `MatchPlayersList` on the final screen (reward modal + AI match-over) lists everyone; **Add-friend** button for real non-bot players (seat `userId` threaded from server). -- **Celebrations:** `celebration-store` + `CelebrationOverlay` — animated XP count-up, level-up pop, achievement unlock; fires from shop purchases (XP/cosmetic) and unlocks. Reusable via `celebrate({...})`. -- **UX/UI:** "Persian luxury" palette (navy/teal/gold, glass) + **UNO-style tactile UX** rolled out to Home (hero Play), Shop (detail sheets), Lobby, Matchmaking, Profile, Leaderboard. Primitives in `globals.css`: `.press-3d` (tactile press), `.safe-top/.safe-bottom/.safe-x` (notch), `.hud-shadow`, `.premium-chat`. Online count floored at **≥50**. Match stays alive on exit (minimize/resume + ResumeGameBar). **No fake/periodic notifications** (removed as spam). +- **Celebrations:** `celebration-store` + `CelebrationOverlay` — animated XP count-up, level-up pop, achievement unlock; fires from shop purchases (XP/cosmetic) and unlocks. Reusable via `celebrate({...})`. Achievement rows (here + `PostMatchRewardsModal`) now reveal the **sticker pack** an achievement unlocks (`stickerPackForAchievement`). +- **Persistent level + XP bar:** `LevelXpBar` (avatar + Lv + progress, taps to profile) shows on Home (`TopBar`) and atop every inner screen (`ScreenHeader`, `showXp` default on) so level/XP is always visible. +- **Buy-coins gateway** opens in a **new tab** (`window.open(_blank)`, same-tab fallback if popup-blocked) so a slow/blocked ZarinPal page can't dead-end the SPA; balance refreshes on window focus. (Fixes the old `window.location.href` "page couldn't load" crash.) +- **UX/UI:** "Persian luxury" palette (navy/teal/gold, glass) + **UNO-style tactile UX** rolled out to Home (hero Play), Shop (detail sheets), Lobby, Matchmaking, Profile, Leaderboard. Primitives in `globals.css`: `.press-3d` (tactile press), `.safe-top/.safe-bottom/.safe-x` (notch), `.hud-shadow`, `.premium-chat`, **`.tap`** (44px min hit area). Online count floored at **≥50**. Match stays alive on exit (minimize/resume + ResumeGameBar). **No fake/periodic notifications** (removed as spam). +- **Accessibility pass:** global **`:focus-visible`** gold ring (keyboard/controller/switch nav — no pointer required); **reduced-motion** honored app-wide via a `@media (prefers-reduced-motion)` block (kills decorative CSS loops) **and** `` in `page.tsx` (tames all Framer Motion). Cramped 32px icon buttons (Friends accept/decline/msg/remove, Chat back/send, Room invite/bot/clear) bumped to **44px**. Empty states (Friends/Chat/Notifications) and loading **skeletons** (Leaderboard/Shop) added. - **Capacitor Android APK** builds (Myket maven mirror at root `https://maven.myket.ir`; init script pins buildTools 36 + JDK17). See `ANDROID.md`. - **CI/CD** (Gitea Actions + Nexus mirror) + Docker stack. See `DEPLOY.md`. @@ -87,8 +98,8 @@ npm run build # next static export ## 5. TODO / next 1. **Generate real EF migrations** (`dotnet ef migrations add Init`, DesignTimeDbContextFactory targets Postgres) + point at live **Supabase**; today the server uses `EnsureCreated()` (auto-switches to `Migrate()` once migrations exist). -2. **Deeper game-table UNO restyle** (bigger tactile cards, clearer turn/HUD, punchier win/trick feedback) — the last UI surface not yet refreshed. -3. Store **IAB** token verification (Cafe Bazaar Poolakey / Myket) — `/api/coins/iab/verify` is a stub. +2. **Game-table UNO restyle — DONE** (bolder suit-aware cards + `xl` size, pulsing playable-card glow, big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round confetti, match coin-rain). +3. Store **IAB** — **scaffolded** (`server/.../Payments/IabService.cs`, config-driven via `Iab__*`). **Cafe Bazaar** path is end-to-end pure-web: client deep-links `bazaar://in_app?...&sku=...&redirect_url=...` (`src/lib/storeBilling.ts`), Bazaar returns `?purchaseToken=`, `page.tsx` captures it → `verifyIab` → server OAuth-refresh→validate→credit. **Myket** verification is wired server-side, but its purchase trigger needs a **native Capacitor bridge** (`window.MyketBilling.purchase(sku)` → `{purchaseToken,productId}`) — see §6. **Remaining:** fill `IAB_*` creds (Bazaar client id/secret/refresh token, Myket access token) in `ENV_FILE`, confirm the exact Bazaar/Myket validate endpoints against your panels, write the Myket native plugin, and set per-build `NEXT_PUBLIC_STORE`=`bazaar|myket` + `NEXT_PUBLIC_APP_PACKAGE`. SKU == coin-pack id (`p1`–`p4`). `IAB_ALLOW_UNVERIFIED=true` credits without verifying — **dev only**. 4. Iranian **push** provider for closed-app notifications (FCM/APNs blocked); in-app + real-time notifications already work. 5. Optional: colored-chat visibility to OTHER players (needs sender-plan on chat messages); route daily-reward through the celebration overlay. @@ -101,3 +112,6 @@ npm run build # next static export - After schema changes in SQLite dev with `EnsureCreated()`, delete `server/src/Hokm.Server/hokm.db*` to recreate. - Background `dotnet run &` from a Git-Bash shell dies when the shell exits; use a tracked background runner. - Commit messages end with `Co-Authored-By: Claude …`. Both `messages/`-style i18n strings live in `src/lib/i18n.tsx` (fa+en). +- **Myket native bridge contract** (for the Capacitor plugin to inject on `window`): + `window.MyketBilling = { available: true, purchase(sku): Promise<{purchaseToken, productId}>, consume?(token): Promise }`. + `storeBilling.getStore()` returns `"myket"` when `available` is true; the client then calls `purchase(sku)` and POSTs the token to `/api/coins/iab/verify`. Until the plugin exists, Myket purchases report "unavailable" and fall back to the web gateway. Cafe Bazaar needs **no** native code (deep-link only). diff --git a/deploy/ENV_FILE.example b/deploy/ENV_FILE.example index b059c42..c373f63 100644 --- a/deploy/ENV_FILE.example +++ b/deploy/ENV_FILE.example @@ -40,3 +40,18 @@ ZARINPAL_MERCHANT_ID=299685fb-cadf-4dfc-98e2-d4af5d81528d ZARINPAL_SANDBOX=true ZARINPAL_CALLBACK_URL=http://localhost:1505/api/coins/pay/callback ZARINPAL_CLIENT_RETURN_URL=http://localhost:1500 + +# Store in-app billing (Cafe Bazaar / Myket) — fill from the developer panels. +# SKU == coin-pack id (p1/p2/…). Coins are credited only after the purchase +# token verifies server-to-server. +IAB_PACKAGE_NAME=com.bargevasat.hokm +# Cafe Bazaar (pardakht dev API): create an OAuth client, do the one-time consent +# to obtain a refresh_token. https://pardakht.cafebazaar.ir/ +IAB_BAZAAR_CLIENT_ID= +IAB_BAZAAR_CLIENT_SECRET= +IAB_BAZAAR_REFRESH_TOKEN= +# Myket developer panel → API access token. +IAB_MYKET_ACCESS_TOKEN= +# DEV ONLY: credit purchases WITHOUT verifying (set true to test before you have +# store creds). NEVER true in production. +IAB_ALLOW_UNVERIFIED=false diff --git a/docker-compose.yml b/docker-compose.yml index e120544..9dc4421 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,13 @@ services: Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-true} Zarinpal__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback} Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500} + # Store in-app billing verification (Cafe Bazaar / Myket) — fill from panels. + Iab__PackageName: ${IAB_PACKAGE_NAME:-com.bargevasat.hokm} + Iab__BazaarClientId: ${IAB_BAZAAR_CLIENT_ID:-} + Iab__BazaarClientSecret: ${IAB_BAZAAR_CLIENT_SECRET:-} + Iab__BazaarRefreshToken: ${IAB_BAZAAR_REFRESH_TOKEN:-} + Iab__MyketAccessToken: ${IAB_MYKET_ACCESS_TOKEN:-} + Iab__AllowUnverified: ${IAB_ALLOW_UNVERIFIED:-false} ports: - "${API_PORT:-1505}:5005" healthcheck: diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs index 9c837b1..23d0b25 100644 --- a/server/src/Hokm.Server/Game/GameManager.cs +++ b/server/src/Hokm.Server/Game/GameManager.cs @@ -17,8 +17,12 @@ public sealed class Player /// In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.) public sealed class GameManager { - // Real players get priority: wait this long for humans before bots fill in. - private const int QueueWaitMs = 9000; + // Real players get priority: wait ~15s for humans before bots fill in. The + // exact wait is randomized per ticket (12–18s) so the queue doesn't feel + // robotically identical every time. + private const int QueueWaitMinMs = 12000; + private const int QueueWaitMaxMs = 18000; + private int NextQueueWaitMs() => _rng.Next(QueueWaitMinMs, QueueWaitMaxMs + 1); private static readonly string[] BotNames = { "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" }; @@ -53,7 +57,7 @@ public sealed class GameManager lock (_mmLock) { if (_waiting.Any(w => w.player.UserId == p.UserId)) return; - var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite); + var timer = new Timer(_ => FlushTicket(p.UserId), null, NextQueueWaitMs(), Timeout.Infinite); _waiting.Add((p, timer)); _ = _hub.Clients.User(p.UserId).SendAsync("matchmaking", new MatchmakingStateDto("searching", _waiting.Count, null)); diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs index dab30b1..632bd62 100644 --- a/server/src/Hokm.Server/Game/GameRoom.cs +++ b/server/src/Hokm.Server/Game/GameRoom.cs @@ -28,7 +28,10 @@ public sealed class GameRoom : IDisposable private const int AiPlayMs = 800; private const int TrickPauseMs = 1100; private const int RoundPauseMs = 2500; - public const int TurnMs = 20000; + // Higher leagues (bigger stake) give LESS time to act — players think faster. + // Starter/free → 15s, Pro (≥500) → 10s, Expert (≥1000) → 7s. Mirrors the + // client's turnMsForStake() so the turn clock matches in either mode. + private int TurnMs => Stake >= 1000 ? 7000 : Stake >= 500 ? 10000 : 15000; private readonly object _lock = new(); private readonly IHubContext _hub; diff --git a/server/src/Hokm.Server/Payments/IabService.cs b/server/src/Hokm.Server/Payments/IabService.cs new file mode 100644 index 0000000..8cfedfc --- /dev/null +++ b/server/src/Hokm.Server/Payments/IabService.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace Hokm.Server.Payments; + +/// +/// Config for store in-app billing verification. Fill these from the Cafe Bazaar +/// (pardakht) and Myket developer panels. Bound from the "Iab" config section / +/// Iab__* env vars. +/// +public sealed class IabOptions +{ + /// Android package name registered in the store panels. + public string PackageName { get; set; } = "com.bargevasat.hokm"; + + // ── Cafe Bazaar (pardakht dev API, OAuth refresh-token flow) ── + public string BazaarClientId { get; set; } = ""; + public string BazaarClientSecret { get; set; } = ""; + public string BazaarRefreshToken { get; set; } = ""; + + // ── Myket (developer validation API) ── + public string MyketAccessToken { get; set; } = ""; + + /// + /// DEV ONLY. When true, purchases are credited WITHOUT remote verification + /// (use for local testing before you have store credentials). NEVER enable in + /// production — it lets a forged token mint coins. + /// + public bool AllowUnverified { get; set; } = false; +} + +/// +/// Verifies a store purchase token (Cafe Bazaar / Myket) server-to-server before +/// coins are credited. Endpoints are config-driven; confirm the exact URLs against +/// your store panel — the request/response shapes mirror Google Play's IAB API. +/// +public sealed class IabService +{ + private static readonly HttpClient Http = new(); + private readonly IabOptions _opts; + private readonly ILogger _log; + + public IabService(IabOptions opts, ILogger log) + { + _opts = opts; + _log = log; + } + + public async Task Verify(string store, string productId, string token) + { + if (string.IsNullOrWhiteSpace(token)) return _opts.AllowUnverified; + store = (store ?? "").Trim().ToLowerInvariant(); + try + { + return store switch + { + "bazaar" or "cafebazaar" => await VerifyBazaar(productId, token), + "myket" => await VerifyMyket(productId, token), + _ => _opts.AllowUnverified, + }; + } + catch (Exception ex) + { + _log.LogWarning(ex, "IAB verify failed for store {Store} product {Product}", store, productId); + return _opts.AllowUnverified; + } + } + + /// + /// Cafe Bazaar: exchange the refresh token for an access token, then validate + /// the in-app purchase. See https://pardakht.cafebazaar.ir/devapi/v2/. + /// + private async Task VerifyBazaar(string productId, string token) + { + if (string.IsNullOrWhiteSpace(_opts.BazaarRefreshToken)) return _opts.AllowUnverified; + + // 1) refresh_token → access_token + var form = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = _opts.BazaarClientId, + ["client_secret"] = _opts.BazaarClientSecret, + ["refresh_token"] = _opts.BazaarRefreshToken, + }); + var tokenResp = await Http.PostAsync("https://pardakht.cafebazaar.ir/devapi/v2/auth/token/", form); + if (!tokenResp.IsSuccessStatusCode) return false; + using var tokenDoc = JsonDocument.Parse(await tokenResp.Content.ReadAsStringAsync()); + if (!tokenDoc.RootElement.TryGetProperty("access_token", out var at)) return false; + var access = at.GetString(); + + // 2) validate the purchase + var url = $"https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{_opts.PackageName}/inapp/{Uri.EscapeDataString(productId)}/purchases/{Uri.EscapeDataString(token)}/?access_token={access}"; + var vResp = await Http.GetAsync(url); + if (!vResp.IsSuccessStatusCode) return false; + using var vDoc = JsonDocument.Parse(await vResp.Content.ReadAsStringAsync()); + // purchaseState: 0 = purchased (1 = refunded/cancelled). Absent ⇒ a 200 body + // is itself proof of a valid purchase. + if (vDoc.RootElement.TryGetProperty("purchaseState", out var ps) && ps.ValueKind == JsonValueKind.Number) + return ps.GetInt32() == 0; + return true; + } + + /// + /// Myket: validate via the developer API (mirrors Google Play). The access + /// token comes from the Myket developer panel. + /// + private async Task VerifyMyket(string productId, string token) + { + if (string.IsNullOrWhiteSpace(_opts.MyketAccessToken)) return _opts.AllowUnverified; + + var url = $"https://developer.myket.ir/api/applications/{_opts.PackageName}/purchases/products/{Uri.EscapeDataString(productId)}/tokens/{Uri.EscapeDataString(token)}"; + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Add("X-Access-Token", _opts.MyketAccessToken); + var resp = await Http.SendAsync(req); + if (!resp.IsSuccessStatusCode) return false; + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + if (doc.RootElement.TryGetProperty("purchaseState", out var ps) && ps.ValueKind == JsonValueKind.Number) + return ps.GetInt32() == 0; + return true; + } +} diff --git a/server/src/Hokm.Server/Profiles/ProfileModels.cs b/server/src/Hokm.Server/Profiles/ProfileModels.cs index 9477ccd..d901e98 100644 --- a/server/src/Hokm.Server/Profiles/ProfileModels.cs +++ b/server/src/Hokm.Server/Profiles/ProfileModels.cs @@ -18,6 +18,15 @@ public class StatsDto public int RoundsWon { get; set; } } +/// Optional social-media handles a player chooses to share. +public class SocialLinksDto +{ + public string? Instagram { get; set; } + public string? Telegram { get; set; } + public string? X { get; set; } + public string? Youtube { get; set; } +} + /// Mirrors the client UserProfile (camelCase JSON). public class ProfileDto { @@ -48,11 +57,42 @@ public class ProfileDto public List Unlocked { get; set; } = new(); public long CreatedAt { get; set; } + // social + public string Gender { get; set; } = ""; // "" | male | female | other + public SocialLinksDto Socials { get; set; } = new(); + public string SocialsVisibility { get; set; } = "public"; // public | friends | hidden + // daily reward streak public int DailyDay { get; set; } = 1; public string? DailyLastClaimed { get; set; } // yyyy-MM-dd } +/// +/// Public-facing view of another player (no coins/phone/email). Mirrors the +/// client PublicProfile. Returned by GET /api/profile/{id}/public. +/// +public class PublicProfileDto +{ + public string Id { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Avatar { get; set; } = "a-fox"; + public string? AvatarImage { get; set; } + public string Plan { get; set; } = "free"; + public string? Title { get; set; } + public int Level { get; set; } = 1; + public int Rating { get; set; } = 1000; + public StatsDto Stats { get; set; } = new(); + public Dictionary Achievements { get; set; } = new(); + public List Unlocked { get; set; } = new(); + public long CreatedAt { get; set; } + public string Gender { get; set; } = ""; + /// Only populated when the viewer is allowed to see them (public / friend / self). + public SocialLinksDto? Socials { get; set; } + public bool IsFriend { get; set; } + public bool IsYou { get; set; } + public bool RequestSent { get; set; } +} + public class MatchSummaryDto { public bool Ranked { get; set; } diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index 497c547..bd5a681 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -64,6 +64,11 @@ public class ProfileService if (patch.TryGetProperty("title", out var ti) && ti.ValueKind == JsonValueKind.String) p.Title = ti.GetString(); if (patch.TryGetProperty("cardFront", out var cf) && cf.ValueKind == JsonValueKind.String) p.CardFront = cf.GetString()!; if (patch.TryGetProperty("cardBack", out var cb) && cb.ValueKind == JsonValueKind.String) p.CardBack = cb.GetString()!; + // social + if (patch.TryGetProperty("gender", out var ge) && ge.ValueKind == JsonValueKind.String) p.Gender = ge.GetString()!; + if (patch.TryGetProperty("socialsVisibility", out var sv) && sv.ValueKind == JsonValueKind.String) p.SocialsVisibility = sv.GetString()!; + if (patch.TryGetProperty("socials", out var so) && so.ValueKind == JsonValueKind.Object) + p.Socials = JsonSerializer.Deserialize(so.GetRawText(), JsonOpts.Default) ?? p.Socials; return await Save(p); } @@ -144,6 +149,7 @@ public class ProfileService "cardback" => p.OwnedCardBacks, "reactionpack" => p.OwnedReactionPacks, "stickerpack" => p.OwnedStickerPacks, + "title" => p.OwnedTitles, _ => null, }; if (list == null) return (false, null, "bad_kind"); @@ -158,7 +164,8 @@ public class ProfileService /* ----------------------------- daily ------------------------------ */ - private static readonly int[] DailyRewards = { 100, 150, 200, 300, 400, 500, 1000 }; + // Mirror the client DAILY_REWARDS (src/lib/online/gamification.ts) exactly. + private static readonly int[] DailyRewards = { 300, 500, 750, 1000, 1500, 2500, 7500 }; private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd"); public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid) diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs index 8d2dae6..5608039 100644 --- a/server/src/Hokm.Server/Program.cs +++ b/server/src/Hokm.Server/Program.cs @@ -41,6 +41,11 @@ if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4df builder.Services.AddSingleton(zp); builder.Services.AddSingleton(); +// --- Store in-app billing (Cafe Bazaar / Myket) verification --- +var iab = builder.Configuration.GetSection("Iab").Get() ?? new IabOptions(); +builder.Services.AddSingleton(iab); +builder.Services.AddSingleton(); + // --- SignalR (camelCase to match the TS client) --- builder.Services .AddSignalR() @@ -148,6 +153,19 @@ app.MapPut("/api/profile", async (ClaimsPrincipal u, ProfileService svc, JsonEle Results.Json(await svc.Update(Uid(u), patch), JsonOpts.Default)) .RequireAuthorization(); +// Public view of another player (profile card + achievement board). +app.MapGet("/api/profile/{id}/public", async (string id, ClaimsPrincipal u, SocialService s) => +{ + var p = await s.GetPublicProfile(Uid(u), id); + return p != null ? Results.Json(p, JsonOpts.Default) : Results.NotFound(); +}).RequireAuthorization(); + +// Discover players (find-friends hub): search by name + suggestions. +app.MapGet("/api/players/search", async (string? q, ClaimsPrincipal u, SocialService s) => + Results.Json(await s.SearchPlayers(Uid(u), q ?? ""), JsonOpts.Default)).RequireAuthorization(); +app.MapGet("/api/players/suggested", async (ClaimsPrincipal u, SocialService s) => + Results.Json(await s.Suggested(Uid(u)), JsonOpts.Default)).RequireAuthorization(); + app.MapPost("/api/profile/plan", async (ClaimsPrincipal u, ProfileService svc) => Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default)) .RequireAuthorization(); @@ -189,11 +207,12 @@ app.MapGet("/api/coins/pay/callback", async (string? authority, string? status, return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed"); }); -// Store in-app purchase (Cafe Bazaar / Myket): the native app sends the purchase -// token; we credit the matching pack. (SKU == packId for now.) -app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabVerifyReq req) => +// Store in-app purchase (Cafe Bazaar / Myket): the client sends the store purchase +// token; we verify it server-to-server, then credit the matching pack (SKU == packId). +app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabService iab, IabVerifyReq req) => { - // TODO: verify req.Token with Cafe Bazaar (Pardakht/Poolakey) or Myket dev API. + var valid = await iab.Verify(req.Store, req.ProductId, req.Token); + if (!valid) return Results.BadRequest(new { ok = false, error = "verification_failed" }); var (ok, p, coins) = await svc.BuyCoins(Uid(u), req.ProductId); return ok ? Results.Json(new { ok, profile = p, coins }, JsonOpts.Default) @@ -227,7 +246,9 @@ app.MapGet("/api/friends/requests", async (ClaimsPrincipal u, SocialService s) = Results.Json(await s.ListRequests(Uid(u)), JsonOpts.Default)).RequireAuthorization(); app.MapPost("/api/friends/add", async (ClaimsPrincipal u, SocialService s, QueryReq r) => { - var (ok, fa, en) = await s.AddFriend(Uid(u), r.Query); + var (ok, fa, en) = !string.IsNullOrWhiteSpace(r.UserId) + ? await s.AddFriendById(Uid(u), r.UserId!) + : await s.AddFriend(Uid(u), r.Query ?? ""); return Results.Json(new { ok, messageFa = fa, messageEn = en }, JsonOpts.Default); }).RequireAuthorization(); app.MapPost("/api/friends/accept", async (ClaimsPrincipal u, SocialService s, IdReq r) => @@ -263,6 +284,6 @@ record EmailLogin(string Email, string Password, string? Name); record BuyReq(string PackId); record ShopBuyReq(string Kind, string Id, int Price); record IabVerifyReq(string Store, string ProductId, string Token); -record QueryReq(string Query); +record QueryReq(string? Query = null, string? UserId = null); record IdReq(string Id); record SendReq(string PeerId, string Text); diff --git a/server/src/Hokm.Server/Social/SocialModels.cs b/server/src/Hokm.Server/Social/SocialModels.cs index ed80f54..bfd6c2d 100644 --- a/server/src/Hokm.Server/Social/SocialModels.cs +++ b/server/src/Hokm.Server/Social/SocialModels.cs @@ -32,3 +32,19 @@ public class ConversationDto public ChatMessageDto? LastMessage { get; set; } public int Unread { get; set; } } + +/// A discoverable player in the social "find friends" hub. +public class PlayerSummaryDto +{ + public string Id { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Avatar { get; set; } = "a-fox"; + public string? AvatarImage { get; set; } + public int Level { get; set; } + public int Rating { get; set; } + public string Status { get; set; } = "offline"; + public string Gender { get; set; } = ""; + public string? Title { get; set; } + public bool IsFriend { get; set; } + public bool RequestSent { get; set; } +} diff --git a/server/src/Hokm.Server/Social/SocialService.cs b/server/src/Hokm.Server/Social/SocialService.cs index e5d8e07..8f204e1 100644 --- a/server/src/Hokm.Server/Social/SocialService.cs +++ b/server/src/Hokm.Server/Social/SocialService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json; using Hokm.Server.Data; using Hokm.Server.Game; @@ -14,6 +15,12 @@ public class SocialService private readonly GameManager _mgr; private readonly IHubContext _hub; + /// Max outgoing friend requests allowed per user within a rolling hour. + public const int FriendReqLimit = 10; + private static readonly TimeSpan FriendReqWindow = TimeSpan.FromHours(1); + // Process-wide log of each user's recent outgoing-request timestamps (resets on restart). + private static readonly ConcurrentDictionary> _reqLog = new(); + public SocialService(AppDbContext db, GameManager mgr, IHubContext hub) { _db = db; @@ -21,6 +28,28 @@ public class SocialService _hub = hub; } + /// + /// Records an outgoing friend-request attempt against the rolling-hour cap. + /// Returns false (with the minutes until a slot frees) when over the limit. + /// + private static bool TryRecordRequest(string uid, out int retryMins) + { + retryMins = 0; + var now = DateTime.UtcNow; + var list = _reqLog.GetOrAdd(uid, _ => new List()); + lock (list) + { + list.RemoveAll(t => now - t >= FriendReqWindow); + if (list.Count >= FriendReqLimit) + { + retryMins = Math.Max(1, (int)Math.Ceiling((FriendReqWindow - (now - list[0])).TotalMinutes)); + return false; + } + list.Add(now); + return true; + } + } + private async Task FriendDtoFor(string userId) { var row = await _db.Profiles.FindAsync(userId); @@ -60,21 +89,129 @@ public class SocialService { var digits = new string(query.Where(char.IsDigit).ToArray()); var targetId = query.Contains(':') ? query.Trim() : (digits.Length >= 4 ? "phone:" + digits : query.Trim()); + return await AddFriendById(uid, targetId); + } + /// Send a friend request to a concrete user id (rate-limited to 10/hour). + public async Task<(bool ok, string messageFa, string messageEn)> AddFriendById(string uid, string targetId) + { + targetId = targetId.Trim(); var target = await _db.Profiles.FindAsync(targetId); if (target == null || targetId == uid) return (false, "کاربر پیدا نشد", "User not found"); if (await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId)) return (false, "از قبل دوست هستید", "Already friends"); - if (!await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId)) - { - _db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow }); - await _db.SaveChangesAsync(); - await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid)); - } + // Already pending → idempotent success, doesn't consume the hourly quota. + if (await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId)) + return (true, "درخواست دوستی ارسال شد", "Friend request sent"); + if (!TryRecordRequest(uid, out var mins)) + return (false, + $"در هر ساعت حداکثر {FriendReqLimit} درخواست دوستی می‌توانید بفرستید. {mins} دقیقه دیگر تلاش کنید.", + $"You can send at most {FriendReqLimit} friend requests per hour. Try again in {mins} min."); + _db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow }); + await _db.SaveChangesAsync(); + await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid)); return (true, "درخواست دوستی ارسال شد", "Friend request sent"); } + /* --------------------------- discovery ----------------------------- */ + + private PlayerSummaryDto ToSummary(ProfileDto p, HashSet friendIds, HashSet sentIds) => new() + { + Id = p.Id, + DisplayName = p.DisplayName, + Avatar = p.Avatar, + AvatarImage = p.AvatarImage, + Level = p.Level, + Rating = p.Rating, + Status = _mgr.IsOnline(p.Id) ? "online" : "offline", + Gender = p.Gender ?? "", + Title = p.Title, + IsFriend = friendIds.Contains(p.Id), + RequestSent = sentIds.Contains(p.Id), + }; + + /// Search players by display name (case-insensitive contains). + public async Task> SearchPlayers(string uid, string query) + { + query = (query ?? "").Trim(); + if (query.Length == 0) return new(); + var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet(); + var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet(); + var rows = await _db.Profiles.Where(p => p.Id != uid).ToListAsync(); + var list = new List(); + foreach (var row in rows) + { + var p = JsonSerializer.Deserialize(row.Json, JsonOpts.Default); + if (p?.DisplayName == null) continue; + if (!p.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) continue; + list.Add(ToSummary(p, friendIds, sentIds)); + if (list.Count >= 20) break; + } + return list; + } + + /// Suggested players to befriend (online-first, excludes existing friends). + public async Task> Suggested(string uid) + { + var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet(); + var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet(); + var rows = await _db.Profiles.Where(p => p.Id != uid).Take(80).ToListAsync(); + var list = new List(); + foreach (var row in rows) + { + var p = JsonSerializer.Deserialize(row.Json, JsonOpts.Default); + if (p == null || friendIds.Contains(p.Id)) continue; + list.Add(ToSummary(p, friendIds, sentIds)); + } + // Online players first, then by rating. + return list + .OrderByDescending(x => x.Status == "online") + .ThenByDescending(x => x.Rating) + .Take(12) + .ToList(); + } + + /// Another player's public profile + achievement board (no private fields). + public async Task GetPublicProfile(string uid, string targetId) + { + targetId = targetId.Trim(); + var row = await _db.Profiles.FindAsync(targetId); + if (row == null) return null; + var p = JsonSerializer.Deserialize(row.Json, JsonOpts.Default); + if (p == null) return null; + + var isFriend = await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId); + var requestSent = await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId); + var isYou = targetId == uid; + + // Social links honor the owner's privacy: public → everyone, friends → only + // friends (and the owner), hidden → nobody. + var vis = string.IsNullOrEmpty(p.SocialsVisibility) ? "public" : p.SocialsVisibility; + var canSeeSocials = isYou || vis == "public" || (vis == "friends" && isFriend); + + return new PublicProfileDto + { + Id = p.Id, + DisplayName = p.DisplayName, + Avatar = p.Avatar, + AvatarImage = p.AvatarImage, + Plan = p.Plan, + Title = p.Title, + Level = p.Level, + Rating = p.Rating, + Stats = p.Stats, + Achievements = p.Achievements, + Unlocked = p.Unlocked, + CreatedAt = p.CreatedAt, + Gender = p.Gender ?? "", + Socials = canSeeSocials ? p.Socials : null, + IsFriend = isFriend, + IsYou = isYou, + RequestSent = requestSent, + }; + } + public async Task Accept(string uid, long requestId) { var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid); diff --git a/server/src/Hokm.Server/appsettings.json b/server/src/Hokm.Server/appsettings.json index 1773d4a..27c6484 100644 --- a/server/src/Hokm.Server/appsettings.json +++ b/server/src/Hokm.Server/appsettings.json @@ -23,5 +23,13 @@ "Sandbox": true, "CallbackUrl": "http://localhost:5005/api/coins/pay/callback", "ClientReturnUrl": "http://localhost:3000" + }, + "Iab": { + "PackageName": "com.bargevasat.hokm", + "BazaarClientId": "", + "BazaarClientSecret": "", + "BazaarRefreshToken": "", + "MyketAccessToken": "", + "AllowUnverified": false } } diff --git a/src/app/globals.css b/src/app/globals.css index aa5e8d2..5baafe2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -201,3 +201,115 @@ body { display: none; } } + +/* ── Game-table UNO animations ──────────────────────────────────────── */ + +/* Floating coin for win celebrations */ +@keyframes coin-rise { + 0% { transform: translateY(0) rotate(0deg) scale(1); opacity: 1; } + 80% { opacity: 0.8; } + 100% { transform: translateY(-190px) rotate(520deg) scale(0.3); opacity: 0; } +} + +/* Playable card glow pulse */ +@keyframes playable-glow { + 0%, 100% { + box-shadow: + 0 8px 18px rgba(0,0,0,0.4), + 0 0 0 2px rgba(212,175,55,0.7), + 0 0 14px rgba(212,175,55,0.3); + } + 50% { + box-shadow: + 0 10px 22px rgba(0,0,0,0.5), + 0 0 0 3px rgba(212,175,55,1), + 0 0 30px rgba(212,175,55,0.65); + } +} +.card-playable { + animation: playable-glow 1.15s ease-in-out infinite; +} + +/* Active-player avatar ring pulse */ +@keyframes player-glow { + 0%, 100% { + box-shadow: 0 0 0 3px rgba(212,175,55,0.9), 0 0 18px rgba(212,175,55,0.4); + } + 50% { + box-shadow: 0 0 0 5px rgba(212,175,55,1), 0 0 40px rgba(212,175,55,0.75); + } +} +.active-player-ring { + animation: player-glow 1.4s ease-in-out infinite; +} + +/* Turn-indicator "YOUR TURN" pill bounce */ +@keyframes your-turn-bounce { + 0%, 100% { transform: translateX(-50%) scale(1); } + 50% { transform: translateX(-50%) scale(1.06); } +} + +/* Confetti fall */ +@keyframes confetti-fall { + 0% { transform: translateY(0) rotate(0deg); opacity: 1; } + 100% { transform: translateY(140px) rotate(400deg); opacity: 0; } +} + +/* XP bar fill */ +@keyframes xp-fill { + from { width: 0%; } +} +.xp-bar-fill { + animation: xp-fill 1.1s ease-out forwards; +} + +/* ── Accessibility / input ─────────────────────────────────────────── */ + +/* + Visible focus ring for keyboard / controller / switch navigation. Only shows + for keyboard-style focus (:focus-visible), never on mouse/touch tap — so it + stays invisible during normal play but makes the app fully navigable without a + pointer (game-ui-design: controller-first, no navigation dead-ends). +*/ +:focus-visible { + outline: 2px solid var(--gold-400); + outline-offset: 2px; + border-radius: 6px; +} +/* Inputs already show a gold ring on focus; avoid doubling it up. */ +input:focus-visible, +textarea:focus-visible { + outline: none; +} + +/* Minimum comfortable tap target (Apple 44pt / Google 48dp). Pair with a + centering grid so a small icon still gets a big, reliable hit area. */ +.tap { + min-width: 44px; + min-height: 44px; +} + +/* + Honor the OS "reduce motion" setting across the whole app. Decorative game + motion (coin rain, confetti, pulsing glows, count-ups) is neutralized to near- + instant, and the always-pulsing HUD loops are stopped outright. Functional + state changes still happen — they just snap instead of animating. + (game-ui-design: motion-sickness-trigger is a high-severity accessibility issue.) +*/ +@media (prefers-reduced-motion: reduce) { + .card-playable, + .active-player-ring, + .float-suit { + animation: none !important; + } + .float-suit { display: none; } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index fa95271..c34a590 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import { MotionConfig } from "framer-motion"; import { HomeScreen } from "@/components/HomeScreen"; import { GameScreen } from "@/components/screens/GameScreen"; import { ProfileScreen } from "@/components/screens/ProfileScreen"; @@ -19,12 +20,14 @@ import { DailyRewardModal } from "@/components/online/DailyRewardModal"; import { NotificationToaster } from "@/components/online/NotificationToaster"; import { ResumeGameBar } from "@/components/online/ResumeGameBar"; import { CelebrationOverlay } from "@/components/online/CelebrationOverlay"; +import { PublicProfileModal } from "@/components/online/PublicProfileModal"; import { CapacitorBack } from "@/components/CapacitorBack"; import { useSessionStore } from "@/lib/session-store"; import { useGameStore } from "@/lib/game-store"; import { useOnlineStore } from "@/lib/online-store"; import { useNotifStore, pushNotification } from "@/lib/notification-store"; import { getService } from "@/lib/online/service"; +import { captureBazaarRedirect } from "@/lib/storeBilling"; import { screenFromHash, useUIStore, type Screen } from "@/lib/ui-store"; /** Transient screens can't be restored without their state — fall back to home. */ @@ -72,6 +75,29 @@ export default function Page() { window.history.replaceState({}, "", window.location.pathname); } + // Cafe Bazaar in-app purchase return (?purchaseToken=...) → verify + credit. + const iab = captureBazaarRedirect(); + if (iab && iab.productId) { + getService() + .verifyIab(iab.store, iab.productId, iab.token) + .then((v) => { + if (v.ok) { + if (v.profile) useSessionStore.setState({ profile: v.profile }); + pushNotification({ + kind: "system", + titleFa: "پرداخت موفق", + titleEn: "Payment successful", + bodyFa: v.coins ? `${v.coins.toLocaleString()} سکه اضافه شد` : undefined, + bodyEn: v.coins ? `${v.coins.toLocaleString()} coins added` : undefined, + icon: "💰", + }); + } else { + pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" }); + } + }) + .finally(() => window.history.replaceState({}, "", window.location.pathname)); + } + useUIStore.getState().initHistory(); useNotifStore.getState().init(); // surface a daily-reward notification if it's available @@ -137,15 +163,18 @@ export default function Page() { }, [init]); return ( - <> + // reducedMotion="user" makes every Framer Motion animation honor the OS + // "reduce motion" accessibility setting (coin rain, confetti, count-ups…). + {renderScreen(screen)} + {loading && null} - + ); } diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index 0fa49f4..7b92bf6 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -1,9 +1,9 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react"; -import { useEffect, useState } from "react"; -import { TURN_MS, useGameStore } from "@/lib/game-store"; +import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff, Zap } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useGameStore } from "@/lib/game-store"; import { useSoundStore } from "@/lib/sound-store"; import { legalMoves } from "@/lib/hokm/engine"; import { sortHand } from "@/lib/hokm/deck"; @@ -18,7 +18,7 @@ import { } from "@/lib/hokm/types"; import { useI18n } from "@/lib/i18n"; import { useSessionStore } from "@/lib/session-store"; -import { cardBackById, cardFrontById, ownedReactions, ownedStickers } from "@/lib/online/gamification"; +import { cardBackById, cardFrontById, ownedReactions, ownedStickers, titleById, turnMsForStake } from "@/lib/online/gamification"; import { getService } from "@/lib/online/service"; import { cn } from "@/lib/cn"; import { PlayingCard } from "./PlayingCard"; @@ -43,7 +43,7 @@ function useCardSkins() { const b = cardBackById(backId); return { front: { bg1: f.bg1, bg2: f.bg2, border: f.border }, - back: { c1: b.c1, c2: b.c2, accent: b.accent }, + back: { c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif }, }; } @@ -68,11 +68,48 @@ export function GameTable({ const trickScale = vw < 360 ? 0.5 : vw < 460 ? 0.64 : 1; const { phase, players, hakem, trump, turn, currentTrick } = game; - const legalIds = new Set( - phase === "playing" && turn === 0 - ? legalMoves(game, 0).map((c) => c.id) - : [] + const legalMovesList = useMemo( + () => (phase === "playing" && turn === 0 ? legalMoves(game, 0) : []), + [phase, turn, game] ); + const legalIds = new Set(legalMovesList.map((c) => c.id)); + + // Keyboard shortcuts (desktop): 1–9 / 0 play the Nth playable card in hand + // order, Space/Enter play the first playable card, M mutes, F forfeits, + // Esc/Q quits. A floating hint lists them. + const playHuman = useGameStore((s) => s.playHuman); + const chooseTrump = useGameStore((s) => s.chooseTrump); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return; + const k = e.key.toLowerCase(); + + // Hakem choosing trump: 1–4 pick a suit. + if (phase === "choosing-trump" && players[hakem!]?.isHuman) { + const idx = "1234".indexOf(e.key); + if (idx >= 0) { e.preventDefault(); chooseTrump(SUITS[idx]); return; } + } + + if (phase === "playing" && turn === 0) { + const playable = sortHand(game.players[0].hand).filter((c) => legalIds.has(c.id)); + if (k === " " || k === "enter") { + if (playable[0]) { e.preventDefault(); playHuman(playable[0]); } + return; + } + // 1-9 then 0 → 10th + const digit = e.key === "0" ? 9 : "123456789".indexOf(e.key); + if (digit >= 0 && playable[digit]) { e.preventDefault(); playHuman(playable[digit]); return; } + } + + if (k === "m") { e.preventDefault(); toggleAll(); } + else if (k === "f" && onForfeit) { e.preventDefault(); setAskFf(true); } + else if (k === "escape" || k === "q") { e.preventDefault(); exit(); } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, turn, hakem, game.players, legalMovesList]); return (
@@ -80,6 +117,7 @@ export function GameTable({
+ {trump && }
); @@ -318,22 +380,26 @@ function OpponentHand({ const count = useGameStore((s) => s.game.players[seat].hand.length); const { back } = useCardSkins(); const cards = Array.from({ length: count }); + const mid = (count - 1) / 2; return ( -
- {cards.map((_, i) => ( -
- -
- ))} +
+ {cards.map((_, i) => { + const rot = horizontal ? (i - mid) * 4 : 0; + return ( +
+ +
+ ); + })}
); } @@ -367,29 +433,23 @@ function TrickArea({ const { front } = useCardSkins(); return (
-
+
{trick.map((pc) => { const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale }; const enter = TRICK_ENTER[pc.seat]; - const isWinner = - phase === "trick-complete" && winner === pc.seat; + const isWinner = phase === "trick-complete" && winner === pc.seat; return ( @@ -398,11 +458,55 @@ function TrickArea({ ); })} + {/* Burst particles when trick is won */} + + {phase === "trick-complete" && winner != null && ( + + )} +
); } +/* particles that fly out from center when you win a trick */ +const BURST_ANGLES = Array.from({ length: 10 }, (_, i) => { + const a = (i / 10) * 2 * Math.PI; + const d = 55 + (i % 3) * 22; + return { id: i, x: Math.cos(a) * d, y: Math.sin(a) * d, size: 7 + (i % 3) * 5 }; +}); + +function TrickBurst({ seat }: { seat: Seat }) { + const team = teamOf(seat); + const gradient = team === 0 + ? "radial-gradient(circle,#2dd4bf,#0d9488)" + : "radial-gradient(circle,#fb7185,#e11d48)"; + const glowColor = team === 0 ? "rgba(45,212,191,0.55)" : "rgba(251,113,133,0.55)"; + + return ( + <> + {/* centre flash */} + + {BURST_ANGLES.map(p => ( + + ))} + + ); +} + /* ----------------------------- Player hand ---------------------------- */ function useViewportWidth() { @@ -430,18 +534,21 @@ function PlayerHand({ legalIds }: { legalIds: Set }) { const sorted = sortHand(hand); const myTurn = phase === "playing" && turn === 0; - // While choosing trump the hakem must see their cards above the chooser overlay. const choosing = phase === "choosing-trump"; const n = sorted.length; // Compress the fan so every card fits the screen width (no overflow/scroll). const small = vw < 560; - const size = vw < 360 ? "sm" : vw < 560 ? "md" : "lg"; - const cardW = size === "sm" ? 44 : size === "md" ? 60 : 74; + const size = vw < 360 ? "sm" : vw < 480 ? "md" : vw < 640 ? "lg" : "xl"; + const cardW = size === "sm" ? 44 : size === "md" ? 62 : size === "lg" ? 78 : 92; const avail = Math.min(vw - 12, 620); const step = n > 1 ? Math.min(cardW * 0.94, Math.max(15, (avail - cardW) / (n - 1))) : 0; const overlap = step - cardW; // negative inline-start margin + // Desktop (with a keyboard) gets numbered shortcut badges on playable cards. + const showShortcutBadges = vw >= 768; + let playableSeq = 0; + return (
}) { const mid = (n - 1) / 2; const rot = (i - mid) * (small ? 2 : 3.2); const lift = Math.abs(i - mid) * (small ? 2 : 4); + const shortcutNum = playable ? ++playableSeq : 0; + const badge = shortcutNum >= 1 && shortcutNum <= 10 ? (shortcutNum === 10 ? "0" : String(shortcutNum)) : null; return ( }) { data-playable={playable ? "1" : "0"} style={{ marginInlineStart: i === 0 ? 0 : overlap }} className={cn( - "origin-bottom shrink-0", - playable ? "cursor-pointer relative z-30" : "cursor-default" + "origin-bottom shrink-0 relative", + playable ? "cursor-pointer z-30" : "cursor-default", + playable && "card-playable" )} > }) { size={size} dimmed={dimmed} front={front} - className={cn(playable && "ring-2 ring-gold-400/80")} /> + {showShortcutBadges && badge && ( + + {badge} + + )} ); })} @@ -490,6 +604,52 @@ function PlayerHand({ legalIds }: { legalIds: Set }) { ); } +/* --------------------------- Shortcuts hint --------------------------- */ + +function ShortcutsHint() { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const vw = useViewportWidth(); + if (vw < 768) return null; // keyboard shortcuts are desktop-only + return ( +
+ + {open && ( + + + + + + + + + )} + + +
+ ); +} + +function Row({ k, v }: { k: string; v: string }) { + return ( +
+ {k} + {v} +
+ ); +} + /* --------------------------- Turn indicator --------------------------- */ function TurnIndicator() { @@ -502,19 +662,28 @@ function TurnIndicator() { -
- {isYou ? t("turn.you") : t("turn.other", { name })} -
+ {isYou ? ( + + ✨ {t("turn.you")} + + ) : ( +
+ {t("turn.other", { name })} +
+ )}
); @@ -525,10 +694,12 @@ function TurnIndicator() { function TurnTimer() { const deadline = useGameStore((s) => s.turnDeadline); const phase = useGameStore((s) => s.game.phase); + const stake = useGameStore((s) => s.matchMeta.stake); + const speed = useGameStore((s) => s.matchMeta.speed); const secs = useCountdown(deadline); if (deadline == null || secs == null) return null; if (phase !== "playing" && phase !== "choosing-trump") return null; - const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / TURN_MS)); + const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / turnMsForStake(stake, speed))); const danger = secs <= 5; return (
@@ -808,6 +979,15 @@ function TrumpChooser() { ); } +const CONFETTI_SPECS = Array.from({ length: 22 }, (_, i) => ({ + id: i, + left: 4 + ((i * 4.3) % 92), + delay: (i * 0.07) % 1.2, + color: i % 4 === 0 ? "#d4af37" : i % 4 === 1 ? "#2dd4bf" : i % 4 === 2 ? "#f5ecd6" : "#fb7185", + size: 6 + (i % 4) * 3, + rot: (i * 41) % 360, +})); + function RoundOverlay() { const game = useGameStore((s) => s.game); const { t } = useI18n(); @@ -817,43 +997,68 @@ function RoundOverlay() { return ( + {/* confetti on win */} + {weWon && CONFETTI_SPECS.map(p => ( + + ))} + + + {weWon ? "🎉" : "😤"} +

{t("round.over")}

+ {r.kot && ( - {t("round.kot")}🔥 + {t("round.kot")} 🔥 )} -

{t("round.won", { team: weWon ? t("team.0") : t("team.1") })} + +

+ {t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}

-

- {t("round.score", { - us: game.matchScore[0], - them: game.matchScore[1], - })} -

-

- {t("round.next")} -

+

{t("round.next")}

); } +const WIN_COINS = Array.from({ length: 14 }, (_, i) => ({ + id: i, + left: 5 + ((i * 6.8) % 88), + delay: (i * 0.11) % 1.6, + fontSize: 18 + (i % 3) * 10, +})); + function MatchOverlay({ onExit }: { onExit: () => void }) { const game = useGameStore((s) => s.game); const { t } = useI18n(); @@ -861,37 +1066,46 @@ function MatchOverlay({ onExit }: { onExit: () => void }) { return ( + {/* coin rain on win */} + {youWin && WIN_COINS.map(c => ( + + 🪙 + + ))} + {youWin ? "🏆" : "🎴"} +

{t("match.over")}

-

+

{youWin ? t("match.youWin") : t("match.youLose")}

- {t("round.score", { - us: game.matchScore[0], - them: game.matchScore[1], - })} + {t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}

+
-
diff --git a/src/components/HomeScreen.tsx b/src/components/HomeScreen.tsx index ec9e03f..50430e4 100644 --- a/src/components/HomeScreen.tsx +++ b/src/components/HomeScreen.tsx @@ -15,13 +15,16 @@ import { Users, } from "lucide-react"; import { useEffect, useState } from "react"; +import { Zap } from "lucide-react"; import { useGameStore } from "@/lib/game-store"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore, type Screen } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { getService } from "@/lib/online/service"; import { sound } from "@/lib/sound"; +import { SPEED_TARGET_SCORE } from "@/lib/online/gamification"; import { SUIT_SYMBOL } from "@/lib/hokm/types"; +import { cn } from "@/lib/cn"; import { TopBar } from "./online/TopBar"; export function HomeScreen() { @@ -39,9 +42,15 @@ export function HomeScreen() { go(screen); }; + const [speed, setSpeed] = useState(false); + const playVsComputer = () => { const you = profile?.displayName || t("seat.you"); - newMatch({ names: [you, "آرش", "کیان", "نیلوفر"], targetScore: 7 }); + newMatch({ + names: [you, "آرش", "کیان", "نیلوفر"], + targetScore: speed ? SPEED_TARGET_SCORE : 7, + speed, + }); goGame("home"); }; @@ -102,9 +111,31 @@ export function HomeScreen() { } title={t("menu.vsComputer")} - desc={t("menu.vsComputerDesc")} + desc={speed ? t("speed.desc") : t("menu.vsComputerDesc")} onClick={playVsComputer} /> + {/* Normal / Speed mode picker */} +
+ + +
{/* tiles */} diff --git a/src/components/PlayingCard.tsx b/src/components/PlayingCard.tsx index c8e9c0d..31d4dbd 100644 --- a/src/components/PlayingCard.tsx +++ b/src/components/PlayingCard.tsx @@ -2,26 +2,20 @@ import { cn } from "@/lib/cn"; import { Card, SUIT_IS_RED, SUIT_SYMBOL, rankLabel } from "@/lib/hokm/types"; +import type { CardBackPattern } from "@/lib/online/types"; +import { cardBackMotif, cardBackVisual } from "@/lib/cardBack"; const SIZES = { - sm: { w: 44, h: 62, rank: "text-base", pip: "text-lg", center: "text-2xl" }, - md: { w: 60, h: 84, rank: "text-lg", pip: "text-xl", center: "text-3xl" }, - lg: { w: 74, h: 104, rank: "text-xl", pip: "text-2xl", center: "text-4xl" }, + sm: { w: 44, h: 62, rank: "text-[11px]", center: "text-2xl", radius: 7 }, + md: { w: 62, h: 87, rank: "text-sm", center: "text-3xl", radius: 9 }, + lg: { w: 78, h: 110, rank: "text-base", center: "text-4xl", radius: 11 }, + xl: { w: 92, h: 130, rank: "text-lg", center: "text-5xl", radius: 13 }, } as const; export type CardSize = keyof typeof SIZES; -interface CardBack { - c1: string; - c2: string; - accent: string; -} - -interface CardFront { - bg1: string; - bg2: string; - border: string; -} +interface CardBack { c1: string; c2: string; accent: string; pattern?: CardBackPattern; motif?: string; } +interface CardFront { bg1: string; bg2: string; border: string; } interface Props { card?: Card; @@ -44,77 +38,114 @@ export function PlayingCard({ }: Props) { const s = SIZES[size]; + /* ── Face-down ─────────────────────────────────────────────────── */ if (faceDown || !card) { - const styled = back - ? { - width: s.w, - height: s.h, - borderRadius: 8, - background: `repeating-linear-gradient(45deg, ${back.accent}40 0 6px, transparent 6px 12px), linear-gradient(160deg, ${back.c1}, ${back.c2})`, - border: `1px solid ${back.accent}80`, - boxShadow: "0 6px 14px rgba(0,0,0,0.4)", - } - : { width: s.w, height: s.h }; + const visual = back ? cardBackVisual(back.c1, back.c2, back.accent, back.pattern) : null; + const styled = back && visual ? { + width: s.w, height: s.h, borderRadius: s.radius, + background: visual.background, + backgroundSize: visual.backgroundSize, + border: `1.5px solid ${back.accent}88`, + boxShadow: "0 6px 18px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.12)", + } : { width: s.w, height: s.h }; + const motif = back ? cardBackMotif(back.pattern, back.motif) : "✦"; + return (
-
-
- ✦ -
+
+ {motif && ( + + {motif} + + )}
); } - const red = SUIT_IS_RED[card.suit]; - const color = red ? "text-rose-600" : "text-slate-900"; - const symbol = SUIT_SYMBOL[card.suit]; + /* ── Face-up ────────────────────────────────────────────────────── */ + const red = SUIT_IS_RED[card.suit]; + const symbol = SUIT_SYMBOL[card.suit]; + const label = rankLabel(card.rank); + + // UNO-style: suit-aware background + const cardBg = front + ? `linear-gradient(160deg,${front.bg1},${front.bg2})` + : red + ? "linear-gradient(160deg,#fff8f7,#fdecea)" + : "linear-gradient(160deg,#fefefe,#f4f2ec)"; + + const borderColor = front?.border ?? (red ? "rgba(200,70,70,0.22)" : "rgba(50,50,80,0.15)"); + + // Bold suit colours (UNO-style vivid) + const inkColor = red ? "#c0202a" : "#1c1c38"; + const pipColor = red ? "#e03540" : "#2a2a50"; return (
-
-
{rankLabel(card.rank)}
-
{symbol}
-
+ {/* Top-left corner */}
+
{label}
+
{symbol}
+
+ + {/* Center symbol — large, bold, slightly shadowed */} +
{symbol}
+ + {/* Bottom-right corner (rotated 180°) */}
-
{rankLabel(card.rank)}
-
{symbol}
+
{label}
+
{symbol}
+ + {/* Subtle inner rim for red suits — UNO-style */} + {red && ( +
+ )}
); } diff --git a/src/components/online/CelebrationOverlay.tsx b/src/components/online/CelebrationOverlay.tsx index c31bac2..0269556 100644 --- a/src/components/online/CelebrationOverlay.tsx +++ b/src/components/online/CelebrationOverlay.tsx @@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from "react"; import { useCelebrationStore } from "@/lib/celebration-store"; import { useI18n } from "@/lib/i18n"; import { sound } from "@/lib/sound"; +import { stickerPackForAchievement } from "@/lib/online/gamification"; +import { Sticker } from "./Sticker"; function useCountUp(target: number, ms = 900, run = true) { const [v, setV] = useState(0); @@ -125,27 +127,36 @@ function Card() { {/* achievements */} {current.achievements && current.achievements.length > 0 && (
- {current.achievements.map((a, i) => ( - - {a.icon} - - {t("reward.newAchievement")} - - {locale === "fa" ? a.nameFa : a.nameEn} + {current.achievements.map((a, i) => { + const pack = stickerPackForAchievement(a.id); + return ( + + {a.icon} + + {t("reward.newAchievement")} + + {locale === "fa" ? a.nameFa : a.nameEn} + + {pack && ( + + {pack.stickers[0] && } + {t("reward.stickerUnlocked")}: {locale === "fa" ? pack.nameFa : pack.nameEn} + + )} - - - +{a.coinReward} - - - - ))} + + +{a.coinReward} + + + + ); + })}
)} diff --git a/src/components/online/DailyRewardModal.tsx b/src/components/online/DailyRewardModal.tsx index 0cee07d..d726276 100644 --- a/src/components/online/DailyRewardModal.tsx +++ b/src/components/online/DailyRewardModal.tsx @@ -12,12 +12,28 @@ import { sound } from "@/lib/sound"; import { DailyRewardState } from "@/lib/online/types"; import { cn } from "@/lib/cn"; +// Per-day themed icons and colour accents +const DAY_META = [ + { icon: "🎁", label: null, accent: "from-navy-800/90 to-navy-900/90" }, + { icon: "💰", label: null, accent: "from-navy-800/90 to-navy-900/90" }, + { icon: "⭐", label: null, accent: "from-navy-800/90 to-navy-900/90" }, + { icon: "💎", label: null, accent: "from-navy-800/90 to-navy-900/90" }, + { icon: "🔥", label: null, accent: "from-navy-800/90 to-navy-900/90" }, + { icon: "🏆", label: null, accent: "from-navy-800/90 to-navy-900/90" }, + { icon: "👑", label: "special", accent: "from-gold-600/30 to-gold-500/10" }, +] as const; + +// Coin-rise particles shown when claiming day 7 +const MEGA_COINS = Array.from({ length: 10 }, (_, i) => ({ + id: i, left: 8 + (i * 9) % 84, delay: i * 0.12, +})); + export function DailyRewardModal() { - const open = useUIStore((s) => s.dailyModalOpen); - const close = useUIStore((s) => s.closeDaily); + const open = useUIStore((s) => s.dailyModalOpen); + const close = useUIStore((s) => s.closeDaily); const refreshProfile = useSessionStore((s) => s.refreshProfile); const { t } = useI18n(); - const [state, setState] = useState(null); + const [state, setState] = useState(null); const [claimed, setClaimed] = useState(null); useEffect(() => { @@ -35,6 +51,8 @@ export function DailyRewardModal() { setState(await getService().getDailyState()); }; + const isMegaDay = state?.day === 7; + return ( {open && ( @@ -43,68 +61,119 @@ export function DailyRewardModal() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={close} - className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5" + className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/85 backdrop-blur-sm p-5" > e.stopPropagation()} - className="glass rounded-3xl p-7 w-full max-w-sm text-center" + className="glass rounded-3xl p-7 w-full max-w-sm text-center relative overflow-hidden" > -

{t("daily.title")}

+ {/* gold glow */} +
-
+ + 🎁 + +

{t("daily.title")}

+ + {/* Day cards grid: 3-col rows, day 7 spans full width */} +
{DAILY_REWARDS.map((coins, i) => { - const day = i + 1; + const day = i + 1; + const meta = DAY_META[i]; const isToday = state?.day === day && state?.available; - const isPast = state ? day < state.day : false; + const isPast = state ? day < state.day : false; + const isMega = day === 7; + return ( -
- - {t("daily.day", { n: day })} + + {meta.icon} - + + {meta.label === "special" + ? t("daily.special") + : t("daily.day", { n: day })} + + - {coins} - - -
+ )}> + {coins.toLocaleString()} + + +
+ ); })}
+ {/* mega-day coin burst when claimed */} + {claimed != null && isMegaDay && MEGA_COINS.map(c => ( + + 🪙 + + ))} + + {/* Status row */} {claimed != null ? ( -

- +{claimed} {t("daily.claimed")} -

+ + 🎉 +{claimed.toLocaleString()} {t("daily.claimed")} + ) : state?.available ? ( - + + {t("daily.claim")} {DAY_META[(state.day ?? 1) - 1]?.icon} + ) : ( -

{t("daily.come")}

+

{t("daily.come")}

)} - diff --git a/src/components/online/LevelXpBar.tsx b/src/components/online/LevelXpBar.tsx new file mode 100644 index 0000000..dfb78ad --- /dev/null +++ b/src/components/online/LevelXpBar.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useSessionStore } from "@/lib/session-store"; +import { useUIStore } from "@/lib/ui-store"; +import { useI18n } from "@/lib/i18n"; +import { MAX_LEVEL, xpNeededForLevel } from "@/lib/online/gamification"; +import { Avatar } from "./Avatar"; +import { cn } from "@/lib/cn"; + +/** + * Always-visible level + XP progress chip. Tapping it opens the profile. + * Rendered on Home (TopBar) and at the top of every inner screen (ScreenHeader) + * so the player can always see their level and how close the next one is. + */ +export function LevelXpBar({ + className, + showAvatar = true, +}: { + className?: string; + showAvatar?: boolean; +}) { + const profile = useSessionStore((s) => s.profile); + const go = useUIStore((s) => s.go); + const { t } = useI18n(); + if (!profile) return null; + + const maxed = profile.level >= MAX_LEVEL; + const need = xpNeededForLevel(profile.level); + const pct = maxed ? 100 : Math.min(100, Math.max(0, Math.round((profile.xp / need) * 100))); + + return ( + + ); +} diff --git a/src/components/online/MatchPlayersList.tsx b/src/components/online/MatchPlayersList.tsx index abf0fdf..8b12c64 100644 --- a/src/components/online/MatchPlayersList.tsx +++ b/src/components/online/MatchPlayersList.tsx @@ -4,6 +4,7 @@ import { Check, UserPlus } from "lucide-react"; import { useState } from "react"; import { useGameStore } from "@/lib/game-store"; import { useSessionStore } from "@/lib/session-store"; +import { useUIStore } from "@/lib/ui-store"; import { getService } from "@/lib/online/service"; import { sound } from "@/lib/sound"; import { useI18n } from "@/lib/i18n"; @@ -13,6 +14,7 @@ export function MatchPlayersList() { const { t } = useI18n(); const seatPlayers = useGameStore((s) => s.seatPlayers); const myId = useSessionStore((s) => s.profile?.id); + const viewProfile = useUIStore((s) => s.viewProfile); const [sent, setSent] = useState>({}); if (!seatPlayers.length) return null; @@ -21,7 +23,7 @@ export function MatchPlayersList() { setSent((p) => ({ ...p, [id]: true })); sound.play("click"); try { - await getService().addFriend(id); + await getService().addFriendById(id); } catch { /* ignore — request is best-effort */ } @@ -36,21 +38,42 @@ export function MatchPlayersList() { const canAdd = !!p.id && !p.isBot && p.id !== myId; return (
- - {p.avatar} - - - - {p.name} - {isMe && ({t("match.you")})} - {p.isBot && ({t("match.bot")})} - - {p.level > 0 && ( - - {t("common.level")} {p.level} + {canAdd ? ( + + ) : ( + <> + + {p.avatar} + + + + {p.name} + {isMe && ({t("match.you")})} + {p.isBot && ({t("match.bot")})} + + {p.level > 0 && ( + + {t("common.level")} {p.level} + + )} + + + )} {canAdd && (sent[p.id!] ? ( diff --git a/src/components/online/PostMatchRewardsModal.tsx b/src/components/online/PostMatchRewardsModal.tsx index b4a583f..5a6f74c 100644 --- a/src/components/online/PostMatchRewardsModal.tsx +++ b/src/components/online/PostMatchRewardsModal.tsx @@ -1,31 +1,58 @@ "use client"; import { motion } from "framer-motion"; -import { Coins, Sparkles, Star, TrendingDown, TrendingUp } from "lucide-react"; +import { Coins, Sparkles, Star, TrendingDown, TrendingUp, Zap } from "lucide-react"; import { useEffect, useState } from "react"; import { useI18n } from "@/lib/i18n"; import { sound } from "@/lib/sound"; +import { stickerPackForAchievement } from "@/lib/online/gamification"; import { MatchPlayersList } from "./MatchPlayersList"; +import { Sticker } from "./Sticker"; import { RewardResult } from "@/lib/online/types"; -/** Animated count-up used for the coins-won hero. */ -function CountUp({ to }: { to: number }) { +function CountUp({ to, ms = 900 }: { to: number; ms?: number }) { const [n, setN] = useState(0); useEffect(() => { let raf = 0; const start = performance.now(); - const dur = 900; const tick = (now: number) => { - const p = Math.min(1, (now - start) / dur); - setN(Math.round(to * (1 - Math.pow(1 - p, 3)))); // ease-out + const p = Math.min(1, (now - start) / ms); + setN(Math.round(to * (1 - Math.pow(1 - p, 3)))); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); - }, [to]); + }, [to, ms]); return {n.toLocaleString()}; } +/* floating coins that rise from the bottom on a win */ +const COIN_SPECS = Array.from({ length: 16 }, (_, i) => ({ + id: i, + left: 4 + ((i * 6.1) % 90), + delay: (i * 0.12) % 1.8, + size: 16 + (i % 3) * 10, +})); + +function FloatingCoins() { + return ( +
+ {COIN_SPECS.map(c => ( + + 🪙 + + ))} +
+ ); +} + export function PostMatchRewardsModal({ reward, won, @@ -50,61 +77,67 @@ export function PostMatchRewardsModal({ + {/* radiating bg glow */} +
+ + {/* floating coins for wins */} + {won && reward.coinsDelta > 0 && } + {won ? "🏆" : "🎴"} -

{t("reward.title")}

-

+ +

{t("reward.title")}

+

{won ? t("reward.win") : t("reward.lose")}

- {/* Coins-won hero (animated count-up) */} + {/* Hero coins count-up */} {won && reward.coinsDelta > 0 && ( - - + + + + - + )} -
+
{reward.ratingDelta !== 0 && ( 0 ? ( - - ) : ( - - ) - } + icon={reward.ratingDelta > 0 + ? + : } label={t("reward.rating")} value={sign(reward.ratingDelta)} positive={reward.ratingDelta > 0} - delay={0.2} + delay={0.22} /> )} = 0} - delay={0.3} + delay={0.32} /> } label={t("reward.xp")} value={`+${reward.xpGained}`} positive - delay={0.4} + delay={0.42} />
+ {/* XP bar fill animation */} + + + + + {reward.leveledUp && ( - + + )} + {reward.promoted && ( + )} - {reward.promoted && } {reward.newAchievements.length > 0 && ( -
- {reward.newAchievements.map((a, i) => ( - - {a.icon} - - - {t("reward.newAchievement")} +
+ {reward.newAchievements.map((a, i) => { + const pack = stickerPackForAchievement(a.id); + return ( + + {a.icon} + + {t("reward.newAchievement")} + + {locale === "fa" ? a.nameFa : a.nameEn} + + {pack && ( + + {pack.stickers[0] && } + {t("reward.stickerUnlocked")}: {locale === "fa" ? pack.nameFa : pack.nameEn} + + )} - - {locale === "fa" ? a.nameFa : a.nameEn} + + +{a.coinReward} + - - - +{a.coinReward} - - - - ))} + + ); + })}
)} {reward.newTitles.length > 0 && ( -
+
{reward.newTitles.map((tt, i) => ( - 🏷️ + 🏷️ {t("reward.newTitle")} - + {locale === "fa" ? tt.nameFa : tt.nameEn} @@ -180,7 +239,7 @@ export function PostMatchRewardsModal({ - @@ -189,47 +248,35 @@ export function PostMatchRewardsModal({ } function RewardRow({ - icon, - label, - value, - positive, - delay, + icon, label, value, positive, delay, }: { - icon: React.ReactNode; - label: string; - value: string; - positive: boolean; - delay: number; + icon: React.ReactNode; label: string; value: string; positive: boolean; delay: number; }) { return ( - - {icon} - {label} - - + {icon}{label} + {value} ); } -function Banner({ text, delay }: { text: string; delay: number }) { +function Banner({ text, delay, color }: { text: string; delay: number; color: "gold" | "teal" }) { return ( {text} diff --git a/src/components/online/PublicProfileModal.tsx b/src/components/online/PublicProfileModal.tsx new file mode 100644 index 0000000..a3e98ca --- /dev/null +++ b/src/components/online/PublicProfileModal.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { Check, Clock, Coins, Crown, Loader2, UserPlus, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useUIStore } from "@/lib/ui-store"; +import { useOnlineStore } from "@/lib/online-store"; +import { useI18n } from "@/lib/i18n"; +import { + ACHIEVEMENTS, + ACHIEVEMENT_CATEGORIES, + TITLES, + achievementProgress, +} from "@/lib/online/gamification"; +import { getService } from "@/lib/online/service"; +import type { AchievementCategoryId, PublicProfile } from "@/lib/online/types"; +import { GENDER_META, SOCIAL_PLATFORMS, hasSocials, socialUrl } from "@/lib/social"; +import { Avatar } from "./Avatar"; +import { RankBadge } from "./RankBadge"; +import { cn } from "@/lib/cn"; + +type SendState = "idle" | "sending" | "sent" | "error"; + +export function PublicProfileModal() { + const userId = useUIStore((s) => s.viewProfileId); + const close = useUIStore((s) => s.closeProfile); + const { t, locale } = useI18n(); + const refreshFriends = useOnlineStore((s) => s.loadFriends); + + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(false); + const [tab, setTab] = useState("victory"); + const [send, setSend] = useState("idle"); + const [sendMsg, setSendMsg] = useState(""); + + useEffect(() => { + if (!userId) return; + setProfile(null); + setLoading(true); + setSend("idle"); + setSendMsg(""); + setTab("victory"); + let alive = true; + getService() + .getPublicProfile(userId) + .then((p) => { + if (!alive) return; + setProfile(p); + if (p.requestSent) setSend("sent"); + }) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [userId]); + + const sendRequest = async () => { + if (!profile) return; + setSend("sending"); + const res = await getService().addFriendById(profile.id); + setSendMsg(locale === "fa" ? res.messageFa : res.messageEn); + if (res.ok) { + setSend("sent"); + refreshFriends(); + } else { + setSend("error"); + } + }; + + const titleDef = profile?.title ? TITLES.find((x) => x.id === profile.title) : null; + const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null; + + const unlockedCount = profile + ? ACHIEVEMENTS.filter((a) => profile.unlocked.includes(a.id)).length + : 0; + const list = ACHIEVEMENTS.filter((a) => a.category === tab); + + return ( + + {userId && ( + + e.stopPropagation()} + className="glass w-full max-w-sm rounded-t-3xl sm:rounded-3xl p-5 max-h-[88vh] overflow-y-auto relative" + > + {/* close */} + + + {loading || !profile ? ( +
+ +
+ ) : ( + <> + {/* identity */} +
+
+
+
+ +
+ + {t("common.level")} {profile.level} + +
+ + {titleName &&
{titleName}
} + +

+ {profile.displayName} + {profile.gender && GENDER_META[profile.gender] && ( + + {GENDER_META[profile.gender].symbol} + + )} + {profile.plan === "pro" && ( + + )} +

+ +
+ +
+ + {/* social links (only present when the owner allows it) */} + {hasSocials(profile.socials) && ( +
+ {SOCIAL_PLATFORMS.map((p) => { + const val = profile.socials?.[p.key]?.trim(); + if (!val) return null; + return ( + + {p.icon} + {p.label} + + ); + })} +
+ )} +
+ + {/* friend action */} + {!profile.isYou && ( +
+ {profile.isFriend ? ( +
+ + {t("profile.alreadyFriend")} +
+ ) : send === "sent" ? ( +
+ + {t("profile.requestSent")} +
+ ) : ( + <> + + {send === "error" && sendMsg && ( +

{sendMsg}

+ )} + + )} +
+ )} + + {/* stats */} +
+ + + 0 ? Math.round((profile.stats.wins / profile.stats.games) * 100) : 0}%`} + /> + + + +
+ + {/* achievement board */} +
+

{t("achv.title")}

+ + {unlockedCount}/{ACHIEVEMENTS.length} + +
+ +
+ {ACHIEVEMENT_CATEGORIES.map((c) => { + const active = tab === c.id; + const done = ACHIEVEMENTS.filter( + (a) => a.category === c.id && profile.unlocked.includes(a.id) + ).length; + const total = ACHIEVEMENTS.filter((a) => a.category === c.id).length; + return ( + + ); + })} +
+ +
+ {list.map((a) => { + const unlocked = profile.unlocked.includes(a.id); + const prog = achievementProgress(a, profile.stats, profile.rating, profile.level); + const pct = Math.min(100, Math.round((prog / a.goal) * 100)); + return ( +
+ + {a.icon} + + {unlocked ? ( + + ) : ( + {pct}% + )} +
+ ); + })} +
+ +
+ + {t("profile.memberSince")}{" "} + {new Date(profile.createdAt).toLocaleDateString(locale === "fa" ? "fa-IR" : "en-US")} +
+ + )} + + + )} + + ); +} + +function Stat({ label, value }: { label: string; value: string | number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/src/components/online/ScreenHeader.tsx b/src/components/online/ScreenHeader.tsx index 38ec1e5..93197dd 100644 --- a/src/components/online/ScreenHeader.tsx +++ b/src/components/online/ScreenHeader.tsx @@ -3,30 +3,37 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { useI18n } from "@/lib/i18n"; import { useUIStore, type Screen } from "@/lib/ui-store"; +import { LevelXpBar } from "./LevelXpBar"; export function ScreenHeader({ title, back = "home", right, + showXp = true, }: { title: string; back?: Screen; right?: React.ReactNode; + /** Show the persistent level + XP chip beneath the header (default on). */ + showXp?: boolean; }) { const navBack = useUIStore((s) => s.back); const { locale } = useI18n(); const Chevron = locale === "fa" ? ChevronRight : ChevronLeft; return ( -
- -

{title}

-
{right}
-
+ <> +
+ +

{title}

+
{right}
+
+ {showXp && } + ); } diff --git a/src/components/online/Sticker.tsx b/src/components/online/Sticker.tsx index da78766..130e228 100644 --- a/src/components/online/Sticker.tsx +++ b/src/components/online/Sticker.tsx @@ -30,6 +30,53 @@ function Face({ ); } +/** A Persian-text "stamp" sticker (rounded badge + bold phrase). */ +function Stamp({ + text, + bg, + ring, + fg = "#ffffff", + rot = 0, + fs = 21, +}: { + text: string; + bg: string; + ring: string; + fg?: string; + rot?: number; + fs?: number; +}) { + return ( + <> + + + {text} + + + ); +} + +/** A mini playing card (court/ace) sticker. */ +function CourtCard({ corner, center, color = "#1b1b1b" }: { corner: string; center: string; color?: string }) { + return ( + <> + + {corner} + {center} + {corner} + + ); +} + const STICKERS: Record = { /* ----------------------------- faces ----------------------------- */ happy: ( @@ -262,6 +309,74 @@ const STICKERS: Record = { بردیم! ), + + /* ===================== کل‌کل / banter (text stamps) ===================== */ + sukhti: , + "yad-begir": , + "nobate-man": , + "naz-nakon": , + kojai: , + "hool-nasho": , + "didi-goftam": , + "bendaz-dige": , + "nakon-eddea": , + "shans-avordi": , + "biya-bebin": , + "kart-nadari": , + + /* ===================== Persian trends / praise ===================== */ + eyval: , + torkundi: , + "gol-kashti": , + "harf-nadari": , + "damet-garm-2": , + "nush-jan": , + "be-be": , + ghorbunet: , + + /* ===================== Victory / closers ===================== */ + "jam-kon": , + "kish-mat": , + khdahafez: , + + /* ===================== Court cards (Hokm) ===================== */ + "tak-khal": , + "as-del": , + "shah-khesht": , + "bibi-gesht": , + + /* ===================== Extra emotions ===================== */ + laugh: ( + + + + + + + ), + shocked: ( + + + + + + + + ), + cry: ( + + + + + + + ), + smug: ( + + + + + ), }; export const STICKER_IDS = Object.keys(STICKERS); diff --git a/src/components/online/TopBar.tsx b/src/components/online/TopBar.tsx index 798dc73..c0964af 100644 --- a/src/components/online/TopBar.tsx +++ b/src/components/online/TopBar.tsx @@ -5,6 +5,7 @@ import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; import { useNotifStore } from "@/lib/notification-store"; import { useI18n } from "@/lib/i18n"; +import { MAX_LEVEL, xpNeededForLevel } from "@/lib/online/gamification"; import { Avatar } from "./Avatar"; export function TopBar() { @@ -15,6 +16,10 @@ export function TopBar() { const { t } = useI18n(); if (!profile) return null; + const maxed = profile.level >= MAX_LEVEL; + const xpNeed = xpNeededForLevel(profile.level); + const xpPct = maxed ? 100 : Math.min(100, Math.max(0, Math.round((profile.xp / xpNeed) * 100))); + return (
diff --git a/src/components/online/XpBar.tsx b/src/components/online/XpBar.tsx index 5713cf8..afeefa1 100644 --- a/src/components/online/XpBar.tsx +++ b/src/components/online/XpBar.tsx @@ -1,30 +1,65 @@ "use client"; +import { motion } from "framer-motion"; +import { Star } from "lucide-react"; import { xpNeededForLevel } from "@/lib/online/gamification"; import { useI18n } from "@/lib/i18n"; +import { cn } from "@/lib/cn"; -export function XpBar({ level, xp }: { level: number; xp: number }) { +export function XpBar({ + level, + xp, + showBadge = false, +}: { + level: number; + xp: number; + showBadge?: boolean; +}) { const { t } = useI18n(); const need = xpNeededForLevel(level); const pct = Math.min(100, Math.round((xp / need) * 100)); + return (
-
- +
+ + {showBadge && ( + + + + )} {t("common.level")} {level} - - {xp} / {need} XP + + {xp.toLocaleString()} / {need.toLocaleString()} XP
-
-
+ +
+ + {/* glossy sheen */} + + + {/* animated shimmer sweep */} + {pct > 0 && pct < 100 && ( + + )}
); diff --git a/src/components/screens/BuyCoinsScreen.tsx b/src/components/screens/BuyCoinsScreen.tsx index 48ee6c0..d6a01ae 100644 --- a/src/components/screens/BuyCoinsScreen.tsx +++ b/src/components/screens/BuyCoinsScreen.tsx @@ -6,6 +6,7 @@ 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 { isStoreBilling, purchaseViaStore } from "@/lib/storeBilling"; import { sound } from "@/lib/sound"; import { CoinPack } from "@/lib/online/types"; import { cn } from "@/lib/cn"; @@ -14,31 +15,90 @@ export function BuyCoinsScreen() { const { t, locale } = useI18n(); const profile = useSessionStore((s) => s.profile); const setProfile = useSessionStore((s) => s.setProfile); + const refreshProfile = useSessionStore((s) => s.refreshProfile); const [packs, setPacks] = useState([]); const [busy, setBusy] = useState(null); const [gained, setGained] = useState(null); + const [msg, setMsg] = useState(""); useEffect(() => { getService().getCoinPacks().then(setPacks); }, []); + // When the user returns from the payment tab, pull the (possibly credited) balance. + useEffect(() => { + const onFocus = () => refreshProfile(); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [refreshProfile]); + 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); - // Live: redirect to the ZarinPal gateway; we credit on return via callback. - if (res.redirectUrl) { - window.location.href = res.redirectUrl; + setMsg(""); + + // Inside a store build (Cafe Bazaar / Myket), route through store billing. + if (isStoreBilling()) { + try { + const r = await purchaseViaStore(p); + if (r.kind === "redirect") return; // Bazaar navigated away; credited on return + if (r.kind === "token") { + const v = await getService().verifyIab(r.store, r.productId, r.token); + if (v.ok && v.profile) { + setProfile(v.profile); + sound.play("purchase"); + setGained(v.coins); + setTimeout(() => setGained(null), 2500); + } else { + setMsg(t("buy.failed")); + } + setBusy(null); + return; + } + // unavailable → fall through to the web gateway below + } catch { + setBusy(null); + setMsg(t("buy.failed")); + return; + } + } + + let res; + try { + res = await getService().buyCoins(p.id); + } catch { + setBusy(null); + setMsg(t("buy.failed")); return; } + + // Live: hand off to the ZarinPal gateway. Open it in a NEW tab so the app + // itself never navigates away (and so a slow/blocked gateway can't dead-end + // the whole app). Credit lands via the server callback; we refresh on focus. + if (res.redirectUrl) { + const url = res.redirectUrl; + if (!/^https?:\/\//i.test(url)) { + setBusy(null); + setMsg(t("buy.failed")); + return; + } + const win = window.open(url, "_blank", "noopener,noreferrer"); + if (!win) window.location.href = url; // popup blocked → same-tab fallback + setBusy(null); + setMsg(t("buy.redirecting")); + return; + } + // Mock/offline: instant credit. if (res.ok && res.profile) { setProfile(res.profile); sound.play("purchase"); setGained(res.coins); setTimeout(() => setGained(null), 2500); + } else { + setMsg(t("buy.failed")); } setBusy(null); }; @@ -64,6 +124,10 @@ export function BuyCoinsScreen() {
)} + {msg && ( +
{msg}
+ )} +
{packs.map((p) => ( - {avatarEmoji(friend.avatar)} -
-
{friend.displayName}
-
- {friend.status === "online" - ? t("friends.online") - : friend.status === "in-game" - ? t("friends.inGame") - : t("friends.offline")} +
+ {/* messages */}
{messages.length === 0 && ( -

{t("chat.empty")}

+
+ + + +

{t("chat.empty")}

+
)} {messages.map((m) => (
+ ); +} + +/* ------------------------------ Friends tab ------------------------------ */ + +function FriendsTab() { + const { t } = useI18n(); + const friends = useOnlineStore((s) => s.friends); + const requests = useOnlineStore((s) => s.requests); + const accept = useOnlineStore((s) => s.acceptRequest); + const decline = useOnlineStore((s) => s.declineRequest); + const remove = useOnlineStore((s) => s.removeFriend); + const openChat = useOnlineStore((s) => s.openChat); + const go = useUIStore((s) => s.go); + const viewProfile = useUIStore((s) => s.viewProfile); + const [confirmId, setConfirmId] = useState(null); + const statusLabel = (s: PresenceStatus) => s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline"); - const add = async () => { - if (!query.trim()) return; - await addFriend(query); - setQuery(""); - }; - return ( - - - - {/* add */} -
- setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && add()} - placeholder={t("friends.addPlaceholder")} - className="flex-1 rounded-xl bg-navy-900/70 gold-border px-3 py-2 text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40" - /> - -
- - {/* requests */} + <> {requests.length > 0 && ( -
+

{t("friends.requests")}

{requests.map((r) => (
- {avatarEmoji(r.from.avatar)} - - {r.from.displayName} - - + {r.from.displayName} + -
@@ -91,67 +142,36 @@ export function FriendsScreen() {
)} - {/* list */} -
- {friends.length === 0 && ( -

{t("friends.empty")}

- )} +
+ {friends.length === 0 && } text={t("friends.empty")} />} {friends.map((f: Friend) => (
-
- {avatarEmoji(f.avatar)} - -
-
-
{f.displayName}
-
- {statusLabel(f.status)} · {t("common.level")} {f.level} +
- {Math.round(f.rating)} +
+
{f.displayName}
+
{statusLabel(f.status)} · {t("common.level")} {f.level}
+
+ {confirmId === f.id ? ( <> {t("friends.removeQ")} - - ) : ( <> - - @@ -159,7 +179,218 @@ export function FriendsScreen() {
))}
- {locale} - + + ); +} + +/* ------------------------------ Discover tab ----------------------------- */ + +function DiscoverTab() { + const { t } = useI18n(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState(null); + const [suggested, setSuggested] = useState(null); + const [loading, setLoading] = useState(false); + const debounce = useRef | null>(null); + + useEffect(() => { + getService().suggestedPlayers().then(setSuggested).catch(() => setSuggested([])); + }, []); + + useEffect(() => { + if (debounce.current) clearTimeout(debounce.current); + const q = query.trim(); + if (!q) { + setResults(null); + setLoading(false); + return; + } + setLoading(true); + debounce.current = setTimeout(async () => { + try { + setResults(await getService().searchPlayers(q)); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }, 350); + return () => { if (debounce.current) clearTimeout(debounce.current); }; + }, [query]); + + const list = results ?? suggested; + + return ( +
+ {/* search */} +
+ + setQuery(e.target.value)} + placeholder={t("discover.searchPlaceholder")} + className="flex-1 bg-transparent py-1.5 text-cream placeholder:text-cream/30 outline-none" + /> + {query && ( + + )} +
+ +

+ {results ? : } + {results ? t("discover.results") : t("discover.suggested")} +

+ + {loading &&
} + + {!loading && list && list.length === 0 && ( + } text={t("discover.noResults")} /> + )} + +
+ {!loading && list?.map((p) => )} +
+
+ ); +} + +function DiscoverRow({ player }: { player: PlayerSummary }) { + const { t, locale } = useI18n(); + const viewProfile = useUIStore((s) => s.viewProfile); + const refreshFriends = useOnlineStore((s) => s.loadFriends); + const [state, setState] = useState<"idle" | "sending" | "sent" | "friend" | "error">( + player.isFriend ? "friend" : player.requestSent ? "sent" : "idle" + ); + const [err, setErr] = useState(""); + + const add = async () => { + setState("sending"); + const res = await getService().addFriendById(player.id); + if (res.ok) { + setState("sent"); + refreshFriends(); + } else { + setErr(locale === "fa" ? res.messageFa : res.messageEn); + setState("error"); + } + }; + + return ( +
+ + + {state === "friend" ? ( + {t("discover.friend")} + ) : state === "sent" ? ( + {t("profile.requestSent")} + ) : ( + + )} +
+ ); +} + +/* ------------------------------ Messages tab ----------------------------- */ + +function MessagesTab() { + const { t } = useI18n(); + const openChat = useOnlineStore((s) => s.openChat); + const go = useUIStore((s) => s.go); + const [convs, setConvs] = useState(null); + + useEffect(() => { + getService().listConversations().then(setConvs).catch(() => setConvs([])); + }, []); + + const open = async (c: Conversation) => { + await openChat(c.friend); + go("chat"); + }; + + const timeAgo = (ts: number) => { + const mins = Math.max(0, Math.floor((Date.now() - ts) / 60000)); + if (mins < 1) return t("time.now"); + if (mins < 60) return t("time.min", { n: mins }); + const hrs = Math.floor(mins / 60); + if (hrs < 24) return t("time.hour", { n: hrs }); + return t("time.day", { n: Math.floor(hrs / 24) }); + }; + + if (convs == null) return
; + + return ( +
+ {convs.length === 0 && } text={t("messages.empty")} />} + {convs.map((c) => ( + + ))} +
+ ); +} + +function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) { + return ( +
+ {icon} +

{text}

+
); } diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx index d619082..3c4bb36 100644 --- a/src/components/screens/GameScreen.tsx +++ b/src/components/screens/GameScreen.tsx @@ -10,6 +10,7 @@ import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { getService } from "@/lib/online/service"; import { pushNotification } from "@/lib/notification-store"; +import { celebrate } from "@/lib/celebration-store"; import { MatchSummary, RewardResult } from "@/lib/online/types"; export function GameScreen() { @@ -66,6 +67,30 @@ export function GameScreen() { }); }; + // Splashy celebration overlay for every achievement / level-up earned in the + // match — fired once the post-match reward summary is dismissed so each unlock + // gets its own animated moment (queued by the celebration store). + const celebrateRewards = (r: RewardResult | null) => { + if (!r) return; + if (r.leveledUp) { + celebrate({ + variant: "xp", + icon: "🎚️", + title: t("reward.levelUp"), + levelBefore: r.levelBefore, + levelAfter: r.levelAfter, + }); + } + for (const a of r.newAchievements) { + celebrate({ + variant: "purchase", + icon: a.icon, + title: t("reward.newAchievement"), + achievements: [a], + }); + } + }; + // Client-run games (private rooms / casual): submit the result to the server. useEffect(() => { if (!live && mode === "online" && game.phase === "match-over" && !submitted.current) { @@ -149,7 +174,9 @@ export function GameScreen() { reward={reward} won={game.matchWinner === 0} onClose={() => { + const r = reward; setReward(null); + celebrateRewards(r); finish(); }} /> diff --git a/src/components/screens/LeaderboardScreen.tsx b/src/components/screens/LeaderboardScreen.tsx index 8f7507f..be14183 100644 --- a/src/components/screens/LeaderboardScreen.tsx +++ b/src/components/screens/LeaderboardScreen.tsx @@ -5,6 +5,7 @@ import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { RankBadge } from "@/components/online/RankBadge"; import { Avatar } from "@/components/online/Avatar"; import { useOnlineStore } from "@/lib/online-store"; +import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { cn } from "@/lib/cn"; @@ -14,6 +15,7 @@ export function LeaderboardScreen() { const { t } = useI18n(); const leaderboard = useOnlineStore((s) => s.leaderboard); const load = useOnlineStore((s) => s.loadLeaderboard); + const viewProfile = useUIStore((s) => s.viewProfile); useEffect(() => { load(); @@ -22,12 +24,30 @@ export function LeaderboardScreen() { return ( + + {leaderboard.length === 0 && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + + + + +
+ ))} +
+ )} +
{leaderboard.map((e) => ( -
viewProfile(e.id)} className={cn( - "rounded-xl p-2.5 flex items-center gap-2.5 border", + "w-full text-start rounded-xl p-2.5 flex items-center gap-2.5 border transition hover:brightness-110 active:scale-[0.99]", e.isYou ? "bg-gold-500/15 border-gold-500/50" : "glass border-transparent" @@ -67,7 +87,7 @@ export function LeaderboardScreen() {
-
+ ))}
diff --git a/src/components/screens/MatchmakingScreen.tsx b/src/components/screens/MatchmakingScreen.tsx index d2af51b..6860ad5 100644 --- a/src/components/screens/MatchmakingScreen.tsx +++ b/src/components/screens/MatchmakingScreen.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Crown, Loader2 } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { ScreenShell } from "@/components/online/ScreenHeader"; import { Avatar } from "@/components/online/Avatar"; import { useGameStore } from "@/lib/game-store"; @@ -24,8 +24,20 @@ export function MatchmakingScreen() { const ready = mm.phase === "ready"; const queued = mm.phase === "queued"; + const searching = mm.phase === "searching"; const slots = [0, 1, 2, 3]; + // Elapsed seconds while searching (resets when the search (re)starts). + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + if (!searching) { + setElapsed(0); + return; + } + const id = setInterval(() => setElapsed((s) => s + 1), 1000); + return () => clearInterval(id); + }, [searching]); + // Live server: the server starts the match itself — auto-enter when ready. useEffect(() => { if (mm.phase === "ready" && getService().live) { @@ -107,6 +119,13 @@ export function MatchmakingScreen() { {ready ? t("mm.ready") : mm.phase === "found" ? t("mm.found") : t("mm.searching")} + {searching && ( + <> +
{elapsed}s
+

{t("mm.fillHint")}

+ + )} +
{slots.map((i) => { const p = mm.players[i]; diff --git a/src/components/screens/NotificationsScreen.tsx b/src/components/screens/NotificationsScreen.tsx index a9e53f5..650fc35 100644 --- a/src/components/screens/NotificationsScreen.tsx +++ b/src/components/screens/NotificationsScreen.tsx @@ -1,5 +1,6 @@ "use client"; +import { BellOff } from "lucide-react"; import { useEffect } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { useNotifStore } from "@/lib/notification-store"; @@ -24,7 +25,12 @@ export function NotificationsScreen() { {items.length === 0 && ( -

{t("notif.empty")}

+
+ + + +

{t("notif.empty")}

+
)}
{items.map((n) => ( diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx index dceb252..51bdf1a 100644 --- a/src/components/screens/ProfileScreen.tsx +++ b/src/components/screens/ProfileScreen.tsx @@ -1,6 +1,7 @@ "use client"; -import { Check, ChevronLeft, Coins, Crown, Lock, Music, Pencil, Upload, Volume2 } from "lucide-react"; +import { motion } from "framer-motion"; +import { Check, ChevronLeft, Coins, Crown, Eye, EyeOff, Lock, Music, Pencil, Star, Upload, Users, Volume2 } from "lucide-react"; import { useRef, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { RankBadge } from "@/components/online/RankBadge"; @@ -23,7 +24,9 @@ import { /** Level required before a player can upload a custom profile photo. */ const PHOTO_UPLOAD_MIN_LEVEL = 25; -import { AVATARS } from "@/lib/online/types"; +import { AVATARS, Gender, SocialVisibility } from "@/lib/online/types"; +import { GENDER_META, SOCIAL_PLATFORMS } from "@/lib/social"; +import { backVisualFromDef, cardBackMotif } from "@/lib/cardBack"; import { cn } from "@/lib/cn"; export function ProfileScreen() { @@ -64,15 +67,34 @@ export function ProfileScreen() { {/* identity */} -
+ + {/* soft gold glow behind avatar */} +
+
-
+ -
+ {/* level badge */} - - {t("common.level")} {profile.level} - + + + {profile.level} +
- +
@@ -142,7 +164,7 @@ export function ProfileScreen() { )}
-
+
{/* avatar picker */}
@@ -227,18 +249,22 @@ export function ProfileScreen() { )} > + className="w-7 h-10 rounded-md border grid place-items-center" + style={{ borderColor: `${c.accent}80`, ...backVisualFromDef(c) }} + > + + {cardBackMotif(c.pattern, c.motif)} + + {locale === "fa" ? c.nameFa : c.nameEn} ))}
+ {/* gender + social links */} + + {/* audio settings */} @@ -281,13 +307,16 @@ export function ProfileScreen() { return ub - ua; }) .slice(0, 6) - .map((a) => { + .map((a, idx) => { const prog = achievementProgress(a, s, profile.rating, profile.level); const unlocked = profile.unlocked.includes(a.id) || prog >= a.goal; const pct = Math.min(100, Math.round((prog / a.goal) * 100)); return ( -
{unlocked && } -
+ ); })}
@@ -331,6 +360,115 @@ function Stat({ label, value }: { label: string; value: string | number }) { ); } +const GENDERS: Gender[] = ["male", "female", "other", ""]; +const VIS_OPTIONS: { id: SocialVisibility; icon: React.ReactNode; key: string }[] = [ + { id: "public", icon: , key: "profile.visPublic" }, + { id: "friends", icon: , key: "profile.visFriends" }, + { id: "hidden", icon: , key: "profile.visHidden" }, +]; + +function SocialSettings() { + const { t, locale } = useI18n(); + const profile = useSessionStore((s) => s.profile); + const updateProfile = useSessionStore((s) => s.updateProfile); + const [links, setLinks] = useState>(() => ({ + instagram: profile?.socials?.instagram ?? "", + telegram: profile?.socials?.telegram ?? "", + x: profile?.socials?.x ?? "", + youtube: profile?.socials?.youtube ?? "", + })); + const [saved, setSaved] = useState(false); + if (!profile) return null; + + const gender = profile.gender ?? ""; + const vis = profile.socialsVisibility ?? "public"; + + const saveLinks = async () => { + const socials = Object.fromEntries( + Object.entries(links).map(([k, v]) => [k, v.trim()]).filter(([, v]) => v) + ); + await updateProfile({ socials }); + setSaved(true); + setTimeout(() => setSaved(false), 1800); + }; + + return ( +
+

{t("profile.social")}

+ + {/* gender */} +
{t("profile.gender")}
+
+ {GENDERS.map((g) => { + const meta = g ? GENDER_META[g] : null; + const active = gender === g; + return ( + + ); + })} +
+ + {/* social links */} +
{t("profile.socialLinks")}
+
+ {SOCIAL_PLATFORMS.map((p) => ( +
+ + {p.icon} + + setLinks((l) => ({ ...l, [p.key]: e.target.value }))} + placeholder={p.label} + dir="ltr" + className="flex-1 rounded-lg bg-navy-900/70 gold-border px-3 py-1.5 text-sm text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40" + /> +
+ ))} +
+ + {/* visibility */} +
{t("profile.socialsVisibility")}
+
+ {VIS_OPTIONS.map((o) => ( + + ))} +
+

{t("profile.socialsHint")}

+ + +
+ ); +} + function SoundSettings() { const { t } = useI18n(); const { sfx, music, toggleSfx, toggleMusic } = useSoundStore(); diff --git a/src/components/screens/RoomScreen.tsx b/src/components/screens/RoomScreen.tsx index 9883add..5e92ceb 100644 --- a/src/components/screens/RoomScreen.tsx +++ b/src/components/screens/RoomScreen.tsx @@ -208,26 +208,30 @@ function SeatCard({ {t("room.waiting")} ) : ( role !== "you" && ( - ) )} ) : ( -
+
diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index 3739ef0..95451da 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -9,7 +9,8 @@ import { useSessionStore } from "@/lib/session-store"; import { useI18n } from "@/lib/i18n"; import { getService } from "@/lib/online/service"; import { sound } from "@/lib/sound"; -import { achievementById } from "@/lib/online/gamification"; +import { achievementById, cardBackById } from "@/lib/online/gamification"; +import { backVisualFromDef, cardBackMotif } from "@/lib/cardBack"; import { celebrate } from "@/lib/celebration-store"; import { AchievementUnlock, ShopItem } from "@/lib/online/types"; import { cn } from "@/lib/cn"; @@ -34,17 +35,32 @@ function Preview({ item, size }: { item: ShopItem; size: number }) { ♠ ); - case "cardback": + case "cardback": { + const back = cardBackById(item.id); return ( + > + + {cardBackMotif(back.pattern, back.motif)} + + + ); + } + case "title": + return ( + + 🏷️ + ); default: // avatar, reactionpack, xp → emoji glyph return {item.kind === "xp" ? "⚡" : item.preview}; @@ -71,6 +87,7 @@ export function ShopScreen() { case "cardfront": return profile.ownedCardFronts.includes(item.id); case "cardback": return profile.ownedCardBacks.includes(item.id); case "reactionpack": return profile.ownedReactionPacks.includes(item.id); + case "title": return profile.ownedTitles.includes(item.id); case "xp": return false; // consumable — always buyable default: return profile.ownedStickerPacks.includes(item.id); } @@ -123,6 +140,7 @@ export function ShopScreen() { { title: t("shop.cardbacks"), kind: "cardback" }, { title: t("shop.reactions"), kind: "reactionpack" }, { title: t("shop.stickers"), kind: "stickerpack" }, + { title: t("shop.titles"), kind: "title", hint: t("shop.titlesHint") }, { title: t("shop.xp"), kind: "xp", hint: t("shop.xpHint") }, ]; @@ -142,6 +160,25 @@ export function ShopScreen() {
{msg}
)} + {items.length === 0 && ( +
+ {Array.from({ length: 2 }).map((_, s) => ( +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ ))} +
+ )} + {sections.map((sec) => { const list = items.filter((i) => i.kind === sec.kind); if (!list.length) return null; @@ -183,14 +220,23 @@ function Section({ title, hint, children }: { title: string; hint?: string; chil } function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) { - const { locale } = useI18n(); + const { locale, t } = useI18n(); const count = item.contents?.length; + const luxury = item.price >= 2000; // premium "luxury" tier return ( + {luxury && !owned && ( + + ✦ {t("shop.luxury")} + + )} {owned && ( diff --git a/src/lib/cardBack.ts b/src/lib/cardBack.ts new file mode 100644 index 0000000..fa4219d --- /dev/null +++ b/src/lib/cardBack.ts @@ -0,0 +1,87 @@ +import { CardBackDef, CardBackPattern } from "./online/types"; + +export interface BackVisual { + background: string; + backgroundSize?: string; +} + +/** + * Build the CSS background for a card back. Each pattern family looks visibly + * different (not just recoloured) — luxury backs use the fancier ones. + */ +export function cardBackVisual( + c1: string, + c2: string, + accent: string, + pattern: CardBackPattern = "stripes" +): BackVisual { + const base = `linear-gradient(160deg, ${c1}, ${c2})`; + switch (pattern) { + case "argyle": + return { + background: + `repeating-linear-gradient(45deg, ${accent}3a 0 9px, transparent 9px 18px),` + + `repeating-linear-gradient(-45deg, ${accent}3a 0 9px, transparent 9px 18px), ${base}`, + }; + case "grid": + return { + background: + `repeating-linear-gradient(0deg, ${accent}38 0 1.5px, transparent 1.5px 11px),` + + `repeating-linear-gradient(90deg, ${accent}38 0 1.5px, transparent 1.5px 11px), ${base}`, + }; + case "dots": + return { + background: `radial-gradient(${accent}66 1.6px, transparent 2px), ${base}`, + backgroundSize: "10px 10px, 100% 100%", + }; + case "rays": + return { + background: `repeating-conic-gradient(from 0deg at 50% 50%, ${accent}26 0deg 9deg, transparent 9deg 18deg), ${base}`, + }; + case "scales": + return { + background: + `radial-gradient(circle at 50% 100%, transparent 5px, ${accent}40 5.5px 6.5px, transparent 7px), ${base}`, + backgroundSize: "13px 9px, 100% 100%", + }; + case "crosshatch": + return { + background: + `repeating-linear-gradient(45deg, ${accent}30 0 2px, transparent 2px 7px),` + + `repeating-linear-gradient(-45deg, ${accent}30 0 2px, transparent 2px 7px), ${base}`, + }; + case "royal": + return { + background: + `repeating-linear-gradient(0deg, ${accent}22 0 1px, transparent 1px 9px),` + + `repeating-linear-gradient(90deg, ${accent}22 0 1px, transparent 1px 9px),` + + `radial-gradient(circle at 50% 42%, ${accent}38, transparent 58%), ${base}`, + }; + case "filigree": + return { + background: + `repeating-linear-gradient(45deg, ${accent}26 0 4px, transparent 4px 10px),` + + `radial-gradient(circle at 50% 50%, ${accent}30, transparent 62%), ${base}`, + }; + case "gem": + return { + background: + `repeating-linear-gradient(60deg, ${accent}33 0 6px, transparent 6px 13px),` + + `repeating-linear-gradient(-60deg, ${accent}33 0 6px, transparent 6px 13px), ${base}`, + }; + case "stripes": + default: + return { background: `repeating-linear-gradient(45deg, ${accent}44 0 6px, transparent 6px 12px), ${base}` }; + } +} + +/** Centered emblem glyph for a back (only the fancy ones carry one). */ +export function cardBackMotif(pattern: CardBackPattern | undefined, motif: string | undefined): string { + if (motif) return motif; + return pattern == null || pattern === "stripes" ? "✦" : ""; +} + +/** Convenience: full visual from a CardBackDef. */ +export function backVisualFromDef(b: CardBackDef): BackVisual { + return cardBackVisual(b.c1, b.c2, b.accent, b.pattern); +} diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index 6d78e34..2372ca4 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -14,6 +14,8 @@ import { import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types"; import { avatarEmoji, ForfeitRequest, RewardResult, ServerGameState } from "./online/types"; import type { OnlineService } from "./online/service"; +import { turnMsForStake } from "./online/gamification"; +import { useSessionStore } from "./session-store"; import { sound } from "./sound"; const KOT_POINTS = 2; @@ -27,8 +29,9 @@ export const TIMING = { roundPause: 2600, } as const; -/** How long a player has to act before the system plays for them. */ -export const TURN_MS = 20000; +/** Base turn time (starter league / vs-AI). Higher leagues use less — see + * `turnMsForStake`. Kept for reference; scheduling derives the real value. */ +export const TURN_MS = 15000; /** Grace period to wait for a disconnected player to return. */ export const RECONNECT_MS = 15000; /** Per-turn chance an online opponent briefly drops (mock). */ @@ -42,11 +45,14 @@ export interface SeatPlayer { level: number; id?: string; // real player's user id (for add-friend); absent for bots/you isBot?: boolean; + title?: string | null; // equipped title id (shown under the avatar on the table) } export interface GameSettings { names: [string, string, string, string]; targetScore: number; + /** Blitz/speed mode — fast turn clock + snappier pacing. */ + speed?: boolean; } export interface OnlineMatchConfig { @@ -54,6 +60,7 @@ export interface OnlineMatchConfig { targetScore: number; stake: number; ranked: boolean; + speed?: boolean; } interface MatchTally { @@ -68,7 +75,7 @@ interface GameStore { started: boolean; mode: GameMode; seatPlayers: SeatPlayer[]; - matchMeta: { ranked: boolean; stake: number }; + matchMeta: { ranked: boolean; stake: number; speed: boolean }; tally: MatchTally; /** epoch ms by which the current actor must act (for the turn-timer UI). */ @@ -198,6 +205,8 @@ export const useGameStore = create((set, get) => { function scheduleAuto() { clearPending(); const g = get().game; + // Speed mode → snappier pacing (animations/pauses run ~half time). + const fast = (ms: number) => (get().matchMeta.speed ? Math.round(ms * 0.5) : ms); switch (g.phase) { case "selecting-hakem": @@ -206,7 +215,7 @@ export const useGameStore = create((set, get) => { set({ game: dealForTrump(get().game) }); sound.play("deal"); scheduleAuto(); - }, TIMING.hakemDraw); + }, fast(TIMING.hakemDraw)); break; case "choosing-trump": { @@ -217,15 +226,16 @@ export const useGameStore = create((set, get) => { set({ tally: { ...get().tally, hakemRounds: get().tally.hakemRounds + 1 } }); } if (g.players[hakem].isHuman) { - // human hakem: timed choice, system auto-picks on timeout - set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null }); + // human hakem: timed choice (less time in higher leagues), system auto-picks on timeout + const turnMs = turnMsForStake(get().matchMeta.stake, get().matchMeta.speed); + set({ turnDeadline: Date.now() + turnMs, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { const cur = get().game; if (cur.phase !== "choosing-trump") return; const suit = chooseTrumpAI(cur.players[cur.hakem!].hand); set({ game: engineChooseTrump(cur, suit), turnDeadline: null }); scheduleAuto(); - }, TURN_MS); + }, turnMs); } else { set({ turnDeadline: null }); pending = setTimeout(() => { @@ -234,7 +244,7 @@ export const useGameStore = create((set, get) => { set({ game: engineChooseTrump(cur, suit) }); sound.play("trump"); scheduleAuto(); - }, TIMING.aiTrump); + }, fast(TIMING.aiTrump)); } break; } @@ -242,15 +252,16 @@ export const useGameStore = create((set, get) => { case "playing": { const seat = g.turn!; if (g.players[seat].isHuman) { - // human turn: timed; system plays a smart legal move on timeout - set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null }); + // human turn: timed (less time in higher leagues); system plays a smart legal move on timeout + const turnMs = turnMsForStake(get().matchMeta.stake, get().matchMeta.speed); + set({ turnDeadline: Date.now() + turnMs, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { const cur = get().game; if (cur.phase !== "playing" || cur.turn !== seat) return; set({ game: playCard(cur, seat, chooseCardAI(cur, seat)), turnDeadline: null }); sound.play("cardPlay"); scheduleAuto(); - }, TURN_MS); + }, turnMs); } else { const st = get(); if ( @@ -271,7 +282,7 @@ export const useGameStore = create((set, get) => { }, back); } else { set({ turnDeadline: null }); - pending = setTimeout(() => playSeatAI(seat), TIMING.aiPlay); + pending = setTimeout(() => playSeatAI(seat), fast(TIMING.aiPlay)); } } break; @@ -290,7 +301,7 @@ export const useGameStore = create((set, get) => { sound.play("kot"); } scheduleAuto(); - }, TIMING.trickPause); + }, fast(TIMING.trickPause)); break; case "round-over": @@ -299,7 +310,7 @@ export const useGameStore = create((set, get) => { recordRound(get().game.lastRoundResult); set({ game: startNextRound(get().game) }); scheduleAuto(); - }, TIMING.roundPause); + }, fast(TIMING.roundPause)); break; default: @@ -313,7 +324,7 @@ export const useGameStore = create((set, get) => { started: false, mode: "ai", seatPlayers: [], - matchMeta: { ranked: false, stake: 0 }, + matchMeta: { ranked: false, stake: 0, speed: false }, tally: freshTally(), turnDeadline: null, disconnectedSeat: null, @@ -336,7 +347,7 @@ export const useGameStore = create((set, get) => { paused: false, forfeited: false, forfeitRequest: null, - matchMeta: { ranked: false, stake: 0 }, + matchMeta: { ranked: false, stake: 0, speed: !!settings.speed }, tally: freshTally(), turnDeadline: null, disconnectedSeat: null, @@ -346,6 +357,7 @@ export const useGameStore = create((set, get) => { avatar: AI_AVATARS[i], level: 0, isBot: i > 0, // seat 0 is you + title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null, })), }); scheduleAuto(); @@ -364,15 +376,16 @@ export const useGameStore = create((set, get) => { paused: false, forfeited: false, forfeitRequest: null, - matchMeta: { ranked: cfg.ranked, stake: cfg.stake }, + matchMeta: { ranked: cfg.ranked, stake: cfg.stake, speed: !!cfg.speed }, tally: freshTally(), turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null, - seatPlayers: cfg.players.map((p) => ({ + seatPlayers: cfg.players.map((p, i) => ({ name: p.displayName, avatar: avatarEmoji(p.avatar), level: p.level, + title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null, })), }); scheduleAuto(); @@ -397,7 +410,7 @@ export const useGameStore = create((set, get) => { paused: false, forfeited: false, forfeitRequest: null, - matchMeta: { ranked: true, stake: 0 }, + matchMeta: { ranked: true, stake: 0, speed: false }, tally: freshTally(), turnDeadline: null, disconnectedSeat: null, @@ -409,9 +422,17 @@ export const useGameStore = create((set, get) => { applyServerState: (s) => { const prev = get().game; const next = mapServerState(s); + const me = useSessionStore.getState().profile; const seatPlayers: SeatPlayer[] = [...s.seatPlayers] .sort((a, b) => a.seat - b.seat) - .map((sp) => ({ name: sp.name, avatar: avatarEmoji(sp.avatar), level: sp.level, id: sp.userId, isBot: sp.isBot })); + .map((sp) => ({ + name: sp.name, + avatar: avatarEmoji(sp.avatar), + level: sp.level, + id: sp.userId, + isBot: sp.isBot, + title: sp.userId && me && sp.userId === me.id ? me.title ?? null : null, + })); // accumulate the reward tally when the match score grows (a round ended) const prevTotal = prev.matchScore[0] + prev.matchScore[1]; @@ -431,7 +452,7 @@ export const useGameStore = create((set, get) => { set({ game: next, seatPlayers, - matchMeta: { ranked: s.ranked, stake: s.stake }, + matchMeta: { ranked: s.ranked, stake: s.stake, speed: false }, turnDeadline: s.turnDeadline ?? null, disconnectedSeat: (s.disconnectedSeat ?? null) as Seat | null, reconnectDeadline: s.disconnectedSeat != null ? Date.now() + RECONNECT_MS : null, diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 844d0c3..ab00f44 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -74,6 +74,14 @@ const fa: Dict = { "turn.you": "نوبت شماست", "turn.other": "نوبت {name}", + "keys.title": "میان‌برهای صفحه‌کلید", + "keys.play": "بازی کردن کارت", + "keys.first": "اولین کارت مجاز", + "keys.trump": "انتخاب حکم", + "keys.mute": "قطع صدا", + "keys.forfeit": "تسلیم", + "keys.quit": "خروج", + "trick.wins": "{name} دست را برد", "round.over": "پایان دست", @@ -95,6 +103,9 @@ const fa: Dict = { "menu.vsComputer": "بازی با کامپیوتر", "menu.vsComputerDesc": "تمرین با حریف‌های هوشمند", + "speed.label": "سریع", + "speed.normal": "عادی", + "speed.desc": "حالت سریع: نوبت‌های کوتاه‌تر و بازی برق‌آسا", "menu.online": "بازی آنلاین", "menu.onlineDesc": "با دوستان یا بازیکن‌های واقعی", "menu.profile": "پروفایل", @@ -123,6 +134,8 @@ const fa: Dict = { "buy.note": "پرداخت امن — درگاه پرداخت ایرانی به‌زودی اضافه می‌شود", "buy.toman": "تومان", "buy.bonus": "هدیه", + "buy.redirecting": "صفحهٔ پرداخت در تب جدید باز شد. پس از پرداخت، سکه‌ها به‌صورت خودکار اضافه می‌شوند.", + "buy.failed": "پرداخت در دسترس نیست. بعداً دوباره تلاش کنید.", "buy.popular": "محبوب", "buy.best": "بهترین", "buy.starter": "شروع", @@ -146,6 +159,10 @@ const fa: Dict = { "profile.kots": "کُت‌ها", "profile.streak": "بهترین نوار", "profile.achievements": "دستاوردها", + "profile.sendRequest": "افزودن دوست", + "profile.requestSent": "درخواست ارسال شد", + "profile.alreadyFriend": "دوست شماست", + "profile.memberSince": "عضو از", "profile.editName": "ویرایش نام", "profile.chooseAvatar": "انتخاب آواتار", @@ -162,6 +179,23 @@ const fa: Dict = { "friends.remove": "حذف", "friends.empty": "هنوز دوستی ندارید", + "social.title": "اجتماعی", + "social.tabFriends": "دوستان", + "social.tabDiscover": "یافتن", + "social.tabMessages": "پیام‌ها", + "discover.searchPlaceholder": "جستجوی بازیکن با نام…", + "discover.results": "نتایج جستجو", + "discover.suggested": "بازیکنان پیشنهادی", + "discover.noResults": "بازیکنی پیدا نشد", + "discover.friend": "دوست", + "messages.empty": "هنوز گفتگویی ندارید", + "messages.you": "شما", + "time.now": "همین حالا", + "time.min": "{n} دقیقه پیش", + "time.hour": "{n} ساعت پیش", + "time.day": "{n} روز پیش", + "common.retry": "تلاش دوباره", + "lobby.title": "بازی آنلاین", "lobby.createRoom": "ساخت اتاق خصوصی", "lobby.createDesc": "هم‌تیمی و حریف‌ها را خودتان انتخاب کنید", @@ -186,6 +220,7 @@ const fa: Dict = { "mm.searching": "در حال یافتن حریف…", "mm.found": "بازیکنان پیدا شدند!", "mm.ready": "آماده شروع", + "mm.fillHint": "اگر بازیکن آنلاینی پیدا نشود، ربات‌ها جایگزین می‌شوند", "mm.cancel": "لغو", "mm.start": "ورود به بازی", @@ -195,6 +230,7 @@ const fa: Dict = { "shop.title": "فروشگاه", "shop.buy": "خرید", "shop.owned": "موجود", + "shop.luxury": "ویژه", "shop.avatars": "آواتارها", "shop.themes": "تم‌ها", "shop.notEnough": "سکه کافی نیست", @@ -229,6 +265,7 @@ const fa: Dict = { "reward.promoted": "ارتقای لیگ!", "reward.demoted": "سقوط لیگ", "reward.newAchievement": "دستاورد جدید", + "reward.stickerUnlocked": "بستهٔ استیکر باز شد", "reward.continue": "ادامه", "reward.win": "بردید! 🏆", "reward.lose": "باختید", @@ -238,6 +275,7 @@ const fa: Dict = { "daily.claim": "دریافت", "daily.claimed": "دریافت شد", "daily.come": "فردا برگردید", + "daily.special": "پاداش ویژه", "rank.label": "لیگ", @@ -264,6 +302,8 @@ const fa: Dict = { "shop.cardstyles": "طرح کارت‌ها", "shop.reactions": "بسته شکلک‌ها", "shop.stickers": "بسته استیکرها", + "shop.titles": "عناوین", + "shop.titlesHint": "عنوان شما زیر نامتان در بازی و لیست‌ها نمایش داده می‌شود", "shop.xp": "امتیاز تجربه (XP)", "shop.xpHint": "افزایش سریع سطح — XP گران است", "shop.includes": "شامل", @@ -281,6 +321,17 @@ const fa: Dict = { "profile.cardFront": "روی کارت", "profile.cardBack": "پشت کارت", + "profile.social": "اجتماعی و ارتباط", + "profile.gender": "جنسیت", + "profile.genderNone": "نامشخص", + "profile.socialLinks": "شبکه‌های اجتماعی", + "profile.socialsVisibility": "نمایش شبکه‌ها به", + "profile.visPublic": "همه", + "profile.visFriends": "دوستان", + "profile.visHidden": "هیچ‌کس", + "profile.socialsHint": "می‌توانید نمایش پیج‌هایتان را عمومی، فقط برای دوستان یا غیرفعال کنید.", + "profile.saveLinks": "ذخیره شبکه‌ها", + "profile.saved": "ذخیره شد", "shop.cardfronts": "روی کارت‌ها", "shop.cardbacks": "پشت کارت‌ها", }; @@ -346,6 +397,14 @@ const en: Dict = { "turn.you": "Your turn", "turn.other": "{name}'s turn", + "keys.title": "Keyboard shortcuts", + "keys.play": "Play a card", + "keys.first": "First legal card", + "keys.trump": "Choose trump", + "keys.mute": "Mute", + "keys.forfeit": "Forfeit", + "keys.quit": "Quit", + "trick.wins": "{name} wins the trick", "round.over": "Round over", @@ -367,6 +426,9 @@ const en: Dict = { "menu.vsComputer": "Play vs Computer", "menu.vsComputerDesc": "Practice against smart bots", + "speed.label": "Speed", + "speed.normal": "Normal", + "speed.desc": "Blitz mode: short turns, lightning-fast match", "menu.online": "Play Online", "menu.onlineDesc": "With friends or real players", "menu.profile": "Profile", @@ -395,6 +457,8 @@ const en: Dict = { "buy.note": "Secure payment — Iranian gateway coming soon", "buy.toman": "Toman", "buy.bonus": "bonus", + "buy.redirecting": "Payment opened in a new tab. Your coins will be added automatically once you pay.", + "buy.failed": "Payment unavailable. Please try again later.", "buy.popular": "Popular", "buy.best": "Best value", "buy.starter": "Starter", @@ -418,6 +482,10 @@ const en: Dict = { "profile.kots": "Kots", "profile.streak": "Best streak", "profile.achievements": "Achievements", + "profile.sendRequest": "Add friend", + "profile.requestSent": "Request sent", + "profile.alreadyFriend": "Your friend", + "profile.memberSince": "Member since", "profile.editName": "Edit name", "profile.chooseAvatar": "Choose avatar", @@ -434,6 +502,23 @@ const en: Dict = { "friends.remove": "Remove", "friends.empty": "No friends yet", + "social.title": "Social", + "social.tabFriends": "Friends", + "social.tabDiscover": "Discover", + "social.tabMessages": "Messages", + "discover.searchPlaceholder": "Search players by name…", + "discover.results": "Search results", + "discover.suggested": "Suggested players", + "discover.noResults": "No players found", + "discover.friend": "Friend", + "messages.empty": "No conversations yet", + "messages.you": "You", + "time.now": "now", + "time.min": "{n}m ago", + "time.hour": "{n}h ago", + "time.day": "{n}d ago", + "common.retry": "Retry", + "lobby.title": "Play Online", "lobby.createRoom": "Create private room", "lobby.createDesc": "Choose your partner and opponents", @@ -458,6 +543,7 @@ const en: Dict = { "mm.searching": "Searching for opponents…", "mm.found": "Players found!", "mm.ready": "Ready to start", + "mm.fillHint": "If no online players are found, bots will fill in", "mm.cancel": "Cancel", "mm.start": "Enter game", @@ -467,6 +553,7 @@ const en: Dict = { "shop.title": "Shop", "shop.buy": "Buy", "shop.owned": "Owned", + "shop.luxury": "Luxury", "shop.avatars": "Avatars", "shop.themes": "Themes", "shop.notEnough": "Not enough coins", @@ -501,6 +588,7 @@ const en: Dict = { "reward.promoted": "Promoted!", "reward.demoted": "Demoted", "reward.newAchievement": "New achievement", + "reward.stickerUnlocked": "Sticker pack unlocked", "reward.continue": "Continue", "reward.win": "You won! 🏆", "reward.lose": "You lost", @@ -510,6 +598,7 @@ const en: Dict = { "daily.claim": "Claim", "daily.claimed": "Claimed", "daily.come": "Come back tomorrow", + "daily.special": "Special Reward", "rank.label": "League", @@ -536,6 +625,8 @@ const en: Dict = { "shop.cardstyles": "Card styles", "shop.reactions": "Reaction packs", "shop.stickers": "Sticker packs", + "shop.titles": "Titles", + "shop.titlesHint": "Your title shows under your name in games & lists", "shop.xp": "XP packs", "shop.xpHint": "Level up faster — XP is expensive", "shop.includes": "Includes", @@ -553,6 +644,17 @@ const en: Dict = { "profile.cardFront": "Card front", "profile.cardBack": "Card back", + "profile.social": "Social & contact", + "profile.gender": "Gender", + "profile.genderNone": "Unspecified", + "profile.socialLinks": "Social media", + "profile.socialsVisibility": "Show socials to", + "profile.visPublic": "Everyone", + "profile.visFriends": "Friends", + "profile.visHidden": "Nobody", + "profile.socialsHint": "Choose who can see your social pages: everyone, only friends, or nobody.", + "profile.saveLinks": "Save links", + "profile.saved": "Saved", "shop.cardfronts": "Card fronts", "shop.cardbacks": "Card backs", }; diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index 397440f..b3689f9 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -161,6 +161,29 @@ export function leagueXpFactor(stake: number): number { /** XP multiplier for premium (pro) players. */ export const PREMIUM_XP_MULT = 1.5; +/* ----------------------------- Turn time ----------------------------- */ + +/** + * How long a player has to act, by league (derived from the coin stake). Higher + * leagues give LESS time, so stronger players must think faster: + * Starter / vs-AI / private (stake < 500) → 15s + * Pro league (stake ≥ 500) → 10s + * Expert league (stake ≥ 1000) → 7s + * Both the offline client and the live server use this same mapping so the turn + * clock matches in either mode. + */ +/** Blitz/speed-mode turn time — a flat, fast clock for casual quick games. */ +export const SPEED_TURN_MS = 5000; +/** Speed mode races to fewer points so a match is over fast. */ +export const SPEED_TARGET_SCORE = 5; + +export function turnMsForStake(stake: number, speed = false): number { + if (speed) return SPEED_TURN_MS; + if (stake >= 1000) return 7000; + if (stake >= 500) return 10000; + return 15000; +} + export function matchXp(summary: MatchSummary): number { // Forfeiting (surrendering) earns no XP. if (summary.forfeit && !summary.won) return 0; @@ -362,8 +385,22 @@ export const TITLES: TitleDef[] = [ { id: "immortal", nameFa: "جاودانه", nameEn: "Immortal", hintFa: "سطح ۵۰", hintEn: "Level 50" }, { id: "the_one", nameFa: "یگانه", nameEn: "The One", hintFa: "۵۰۰ برد", hintEn: "500 wins" }, { id: "legend", nameFa: "اسطوره", nameEn: "Legend", hintFa: "لیگ استاد", hintEn: "Master league" }, + // ✨ Luxury titles — the most prestigious badges in the game + { id: "sultan", nameFa: "سلطان حکم", nameEn: "Hokm Sultan", hintFa: "۱۰۰ کُت", hintEn: "100 kots" }, + { id: "emperor", nameFa: "امپراتور", nameEn: "Emperor", hintFa: "سطح ۷۵", hintEn: "Level 75" }, + { id: "grandmaster", nameFa: "استاد بزرگ", nameEn: "Grandmaster", hintFa: "امتیاز ۲۱۰۰+", hintEn: "2100+ rating" }, + // 💰 Purchasable titles — buy with coins (shown in the shop's Titles section) + { id: "vip", nameFa: "وی‌آی‌پی", nameEn: "VIP", hintFa: "خرید", hintEn: "Purchase", price: 2500 }, + { id: "maestro", nameFa: "اوستا", nameEn: "Maestro", hintFa: "خرید", hintEn: "Purchase", price: 2000 }, + { id: "prince", nameFa: "شاهزاده", nameEn: "Prince", hintFa: "خرید", hintEn: "Purchase", price: 3500 }, + { id: "mythic", nameFa: "افسانه‌ای", nameEn: "Mythic", hintFa: "خرید", hintEn: "Purchase", price: 6000 }, ]; +export function titleById(id: string | null | undefined): TitleDef | undefined { + if (!id) return undefined; + return TITLES.find((t) => t.id === id); +} + export function titleUnlocked( id: string, stats: PlayerStats, @@ -407,6 +444,12 @@ export function titleUnlocked( return stats.wins >= 500; case "legend": return rating >= tierById("master").floor; + case "sultan": + return stats.kotsFor >= 100; + case "emperor": + return level >= 75; + case "grandmaster": + return rating >= 2100; default: return false; } @@ -416,19 +459,25 @@ export function titleUnlocked( // Card BACKS (pattern on the reverse of every card). export const CARD_BACKS: CardBackDef[] = [ - { id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true }, - { id: "midnight", nameFa: "نیمه‌شب", nameEn: "Midnight", c1: "#1b2540", c2: "#0a0f1f", accent: "#8aa0c8", price: 1200 }, - { id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800 }, - { id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000 }, - { id: "jade", nameFa: "یشم", nameEn: "Jade", c1: "#136f63", c2: "#08221e", accent: "#7fe3c0", price: 2000 }, - { id: "onyx", nameFa: "اونیکس", nameEn: "Onyx", c1: "#26262b", c2: "#0c0c10", accent: "#b0b0c0", price: 1500 }, + { id: "classic", nameFa: "کلاسیک", nameEn: "Classic", c1: "#14274f", c2: "#0a142e", accent: "#d4af37", price: 0, default: true, pattern: "stripes" }, + { id: "midnight", nameFa: "نیمه‌شب", nameEn: "Midnight", c1: "#1b2540", c2: "#0a0f1f", accent: "#8aa0c8", price: 1200, pattern: "grid" }, + { id: "sapphire", nameFa: "یاقوت کبود", nameEn: "Sapphire", c1: "#0b3a82", c2: "#06173a", accent: "#6aa6ff", price: 800, pattern: "dots" }, + { id: "emerald", nameFa: "زمرد", nameEn: "Emerald", c1: "#0d6b5e", c2: "#062420", accent: "#2dd4bf", price: 1000, pattern: "argyle" }, + { id: "jade", nameFa: "یشم", nameEn: "Jade", c1: "#136f63", c2: "#08221e", accent: "#7fe3c0", price: 2000, pattern: "scales" }, + { id: "onyx", nameFa: "اونیکس", nameEn: "Onyx", c1: "#26262b", c2: "#0c0c10", accent: "#b0b0c0", price: 1500, pattern: "crosshatch" }, // earned by rank / wins — the higher the rank, the rarer the back - { id: "crimson", nameFa: "ارغوانی", nameEn: "Crimson", c1: "#7a1322", c2: "#2a0710", accent: "#ff8a9c", price: 0, unlockWins: 25 }, - { id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300 }, - { id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50 }, - { id: "aurora", nameFa: "شفق", nameEn: "Aurora", c1: "#1d4e6e", c2: "#0a2230", accent: "#5be0c8", price: 0, unlockRating: 1500 }, - { id: "obsidian", nameFa: "ابسیدین", nameEn: "Obsidian", c1: "#101018", c2: "#000005", accent: "#7c5cff", price: 0, unlockRating: 1700 }, - { id: "imperial", nameFa: "شاهنشاهی", nameEn: "Imperial", c1: "#5a3c0a", c2: "#241704", accent: "#ffd76a", price: 0, unlockRating: 1900 }, + { id: "crimson", nameFa: "ارغوانی", nameEn: "Crimson", c1: "#7a1322", c2: "#2a0710", accent: "#ff8a9c", price: 0, unlockWins: 25, pattern: "rays" }, + { id: "ruby", nameFa: "یاقوت", nameEn: "Ruby", c1: "#7f1d2e", c2: "#2b0a12", accent: "#ff7a90", price: 0, unlockRating: 1300, pattern: "argyle", motif: "♦" }, + { id: "royal", nameFa: "سلطنتی", nameEn: "Royal", c1: "#4a1d7f", c2: "#1a0a2e", accent: "#c77dff", price: 0, unlockWins: 50, pattern: "royal", motif: "♛" }, + { id: "aurora", nameFa: "شفق", nameEn: "Aurora", c1: "#1d4e6e", c2: "#0a2230", accent: "#5be0c8", price: 0, unlockRating: 1500, pattern: "rays" }, + { id: "obsidian", nameFa: "ابسیدین", nameEn: "Obsidian", c1: "#101018", c2: "#000005", accent: "#7c5cff", price: 0, unlockRating: 1700, pattern: "crosshatch", motif: "✦" }, + { id: "imperial", nameFa: "شاهنشاهی", nameEn: "Imperial", c1: "#5a3c0a", c2: "#241704", accent: "#ffd76a", price: 0, unlockRating: 1900, pattern: "royal", motif: "♔" }, + // ✨ Luxury card backs — premium purchasable, each a distinct fancy motif + { id: "diamond", nameFa: "الماس", nameEn: "Diamond", c1: "#1a3a55", c2: "#0a1a2e", accent: "#9fe6ff", price: 2800, pattern: "gem", motif: "◆" }, + { id: "blackgold", nameFa: "طلای سیاه", nameEn: "Black Gold", c1: "#1a1407", c2: "#000000", accent: "#ffd76a", price: 3500, pattern: "filigree", motif: "♠" }, + { id: "platinum-back", nameFa: "پلاتین", nameEn: "Platinum", c1: "#3a3f4a", c2: "#15171c", accent: "#e6ebf2", price: 4200, pattern: "royal", motif: "✦" }, + { id: "peacock-back", nameFa: "طاووس", nameEn: "Peacock", c1: "#0a3a52", c2: "#06202e", accent: "#16d3c0", price: 3000, pattern: "scales", motif: "❖" }, + { id: "rosegold-back", nameFa: "رزگلد", nameEn: "Rose Gold", c1: "#5a2438", c2: "#2a0e1c", accent: "#ffb0c4", price: 3200, pattern: "argyle", motif: "♥" }, ]; // Card FRONTS (the face background/border behind the suit + rank). @@ -445,6 +494,9 @@ export const CARD_FRONTS: CardFrontDef[] = [ { id: "goldleaf", nameFa: "زرورق", nameEn: "Gold Leaf", bg1: "#fff7df", bg2: "#f2dd9b", border: "#caa53a", price: 0, unlockRating: 1500 }, { id: "crystal", nameFa: "بلور", nameEn: "Crystal", bg1: "#eefcff", bg2: "#cdeefa", border: "#5fb6d6", price: 0, unlockRating: 1700 }, { id: "imperial-face", nameFa: "شاهانه", nameEn: "Imperial", bg1: "#fff4cf", bg2: "#ecc873", border: "#b8862a", price: 0, unlockWins: 100 }, + // ✨ Luxury card fronts — premium purchasable + { id: "diamond-face", nameFa: "الماس", nameEn: "Diamond", bg1: "#f4fdff", bg2: "#d7f0fb", border: "#7fc6e6", price: 2500 }, + { id: "blackgold-face", nameFa: "طلای سیاه", nameEn: "Black Gold", bg1: "#2a2410", bg2: "#14110a", border: "#caa53a", price: 3200 }, ]; export function cardBackById(id: string): CardBackDef { @@ -537,7 +589,22 @@ export const STICKER_PACKS: StickerPackDef[] = [ // Custom packs earned only via achievements / rank. { id: "rulership", nameFa: "حاکمیت", nameEn: "Rulership", stickers: ["crown-gold", "seven-zip"], price: 0, unlockAchievement: "hakem_7" }, { id: "firestorm", nameFa: "آتشین", nameEn: "Firestorm", stickers: ["streak-fire"], price: 0, unlockAchievement: "streak_10" }, - { id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text"], price: 0, unlockRating: 1500 }, + + /* ---- New themed packs: کل‌کل (banter), Persian trends, Hokm/game ---- */ + // کل‌کل / تیکه — trash-talk you fling at the table + { id: "kolkol", nameFa: "کل‌کل", nameEn: "Banter", stickers: ["sukhti", "yad-begir", "nobate-man", "naz-nakon"], price: 800 }, + { id: "tikeh", nameFa: "تیکه‌انداز", nameEn: "Taunts", stickers: ["kojai", "hool-nasho", "didi-goftam", "bendaz-dige"], price: 1000 }, + { id: "shakkak", nameFa: "شاکی", nameEn: "Salty", stickers: ["nakon-eddea", "shans-avordi", "biya-bebin", "kart-nadari"], price: 1000 }, + // Persian trend phrases / praise + { id: "trends", nameFa: "ترندها", nameEn: "Trends", stickers: ["eyval", "torkundi", "gol-kashti", "harf-nadari"], price: 900 }, + { id: "tashvigh", nameFa: "تشویق", nameEn: "Cheers", stickers: ["damet-garm-2", "nush-jan", "be-be", "ghorbunet"], price: 700 }, + // Hokm / card-game themed + { id: "khanevadeh", nameFa: "خانواده خال", nameEn: "Court Cards", stickers: ["tak-khal", "as-del", "shah-khesht", "bibi-gesht"], price: 1200 }, + { id: "victory", nameFa: "پیروزی", nameEn: "Victory", stickers: ["bardim", "hokm-text", "jam-kon", "kish-mat"], price: 0, unlockRating: 1500 }, + // Extra emotions + { id: "ehsasat", nameFa: "احساسات", nameEn: "Moods", stickers: ["laugh", "shocked", "cry", "smug"], price: 600 }, + // Mega banter bundle (earned, not sold) — the spicy stuff for rivals + { id: "raghib", nameFa: "رقیب", nameEn: "Rivalry", stickers: ["khdahafez", "weak", "clown", "sleep"], price: 0, unlockAchievement: "kot_10" }, ]; export function stickerPackById(id: string): StickerPackDef | undefined { @@ -686,7 +753,7 @@ export function applyMatchResult( /* --------------------------- Daily reward ---------------------------- */ -export const DAILY_REWARDS = [100, 150, 200, 300, 400, 500, 1000]; +export const DAILY_REWARDS = [300, 500, 750, 1000, 1500, 2500, 7500]; export function dailyRewardFor(day: number): number { return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100; diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index f665121..5895c04 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -3,11 +3,14 @@ // with timers, and computes rewards via gamification.ts. import { + ACHIEVEMENTS, CARD_BACKS, CARD_FRONTS, REACTION_PACKS, STICKER_PACKS, + TITLES, XP_PACKS, + achievementProgress, addXp, applyMatchResult, dailyRewardFor, @@ -31,10 +34,16 @@ import { DailyRewardState, Friend, FriendRequest, + Gender, LeaderboardEntry, MatchSummary, MatchmakingState, + PlayerStats, + PlayerSummary, PresenceStatus, + PublicProfile, + SocialLinks, + SocialVisibility, RewardResult, Room, RoomSeat, @@ -42,6 +51,10 @@ import { UserProfile, } from "./types"; +/** Max friend requests a player may send within a rolling hour. */ +export const FRIEND_REQ_LIMIT = 10; +export const FRIEND_REQ_WINDOW_MS = 60 * 60 * 1000; + const PERSIAN_NAMES = [ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک", "شیما", "حسام", "تینا", "کاوه", "رویا", "مازیار", @@ -176,6 +189,10 @@ export class MockOnlineService implements OnlineService { private profile: UserProfile | null = null; private friends: Friend[] = []; private requests: FriendRequest[] = []; + /** epoch-ms timestamps of friend requests this session sent (for rate limiting) */ + private sentRequestTimes: number[] = []; + /** user ids we've already sent a pending request to */ + private sentRequestIds = new Set(); private room: Room | null = null; private matchmaking: MatchmakingState = { phase: "idle", @@ -342,11 +359,7 @@ export class MockOnlineService implements OnlineService { return this.profile; } - async updateProfile( - patch: Partial< - Pick - > - ) { + async updateProfile(patch: Parameters[0]) { const p = await this.getProfile(); this.profile = { ...p, ...patch }; this.saveProfile(); @@ -370,16 +383,183 @@ export class MockOnlineService implements OnlineService { async listRequests() { return [...this.requests]; } + /** + * Enforce the rolling-hour cap on outgoing friend requests. Returns an error + * payload when over the limit, or null when the request may proceed (and + * records the timestamp). + */ + private rateLimitFriendRequest(): + | { ok: false; messageFa: string; messageEn: string } + | null { + const now = Date.now(); + this.sentRequestTimes = this.sentRequestTimes.filter((t) => now - t < FRIEND_REQ_WINDOW_MS); + if (this.sentRequestTimes.length >= FRIEND_REQ_LIMIT) { + const mins = Math.max( + 1, + Math.ceil((FRIEND_REQ_WINDOW_MS - (now - this.sentRequestTimes[0])) / 60000) + ); + return { + ok: false, + messageFa: `در هر ساعت حداکثر ${faNum(FRIEND_REQ_LIMIT)} درخواست دوستی می‌توانید بفرستید. ${faNum(mins)} دقیقه دیگر تلاش کنید.`, + messageEn: `You can send at most ${FRIEND_REQ_LIMIT} friend requests per hour. Try again in ${mins} min.`, + }; + } + this.sentRequestTimes.push(now); + return null; + } + async addFriend(query: string) { if (!query.trim()) { return { ok: false, messageFa: "نام یا شماره را وارد کنید", messageEn: "Enter a name or number" }; } + const limited = this.rateLimitFriendRequest(); + if (limited) return limited; const f = makeFriend("offline"); f.displayName = query.trim().startsWith("0") ? pick(PERSIAN_NAMES) : query.trim(); this.friends = [f, ...this.friends]; this.emitFriends(); return { ok: true, messageFa: "درخواست دوستی ارسال شد", messageEn: "Friend request sent" }; } + + async addFriendById(userId: string) { + if (this.friends.some((f) => f.id === userId)) { + return { ok: false, messageFa: "از قبل دوست شماست", messageEn: "Already your friend" }; + } + if (this.sentRequestIds.has(userId)) { + return { ok: false, messageFa: "درخواست قبلاً ارسال شده", messageEn: "Request already sent" }; + } + const limited = this.rateLimitFriendRequest(); + if (limited) return limited; + this.sentRequestIds.add(userId); + return { ok: true, messageFa: "درخواست دوستی ارسال شد", messageEn: "Friend request sent" }; + } + + async getPublicProfile(userId: string): Promise { + // Viewing yourself → expose your own data. + if (this.profile && userId === this.profile.id) { + const p = this.profile; + return { + id: p.id, + displayName: p.displayName, + avatar: p.avatar, + avatarImage: p.avatarImage, + plan: p.plan, + title: p.title, + level: p.level, + rating: p.rating, + stats: p.stats, + achievements: p.achievements, + unlocked: p.unlocked, + createdAt: p.createdAt, + gender: p.gender ?? "", + socials: p.socials, // always visible to yourself + isFriend: false, + isYou: true, + requestSent: false, + }; + } + + const friend = this.friends.find((f) => f.id === userId); + // Deterministic pseudo-stats seeded from the id so a player looks consistent. + let seed = 0; + for (let i = 0; i < userId.length; i++) seed = (seed * 31 + userId.charCodeAt(i)) >>> 0; + const rng = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff); + const games = 40 + Math.floor(rng() * 700); + const wins = Math.floor(games * (0.4 + rng() * 0.3)); + const stats: PlayerStats = { + games, + wins, + losses: games - wins, + kotsFor: Math.floor(wins * (0.2 + rng() * 0.3)), + kotsAgainst: Math.floor((games - wins) * (0.1 + rng() * 0.2)), + tricks: Math.floor(games * (3 + rng() * 4)), + bestWinStreak: 2 + Math.floor(rng() * 12), + currentWinStreak: Math.floor(rng() * 4), + shutoutWins: Math.floor(rng() * 8), + hakemRounds: Math.floor(games * (0.6 + rng())), + roundsWon: Math.floor(games * (1.5 + rng() * 1.5)), + }; + const level = friend?.level ?? 1 + Math.floor(rng() * 60); + const rating = friend?.rating ?? 1000 + Math.floor(rng() * 1100); + // A plausible unlocked subset from the metric-driven achievement defs. + const unlocked = ACHIEVEMENTS.filter( + (a) => achievementProgress(a, stats, rating, level) >= a.goal + ).map((a) => a.id); + + // Synthesized gender + socials with a synthesized visibility setting. + const gender = (["male", "female", "male", "female", "other", ""] as Gender[])[Math.floor(rng() * 6)]; + const isFriend = !!friend; + const vis: SocialVisibility = rng() > 0.66 ? "public" : rng() > 0.5 ? "friends" : "hidden"; + const handle = (friend?.displayName ?? "player").replace(/\s+/g, "_").toLowerCase(); + const sampleSocials: SocialLinks = { instagram: handle }; + const canSeeSocials = vis === "public" || (vis === "friends" && isFriend); + + return { + id: userId, + displayName: friend?.displayName ?? pick(PERSIAN_NAMES), + avatar: friend?.avatar ?? pick(AVATARS).id, + plan: rng() > 0.7 ? "pro" : "free", + title: null, + level, + rating, + stats, + achievements: {}, + unlocked, + createdAt: Date.now() - Math.floor(rng() * 300) * 864e5, + gender, + socials: canSeeSocials ? sampleSocials : undefined, + isFriend, + isYou: false, + requestSent: this.sentRequestIds.has(userId), + }; + } + /** Build a discoverable player summary from a synthesized friend. */ + private summaryFromFriend(f: Friend): PlayerSummary { + // Stable-ish gender + title from the id so a player looks consistent across views. + let s = 0; + for (let i = 0; i < f.id.length; i++) s = (s * 31 + f.id.charCodeAt(i)) >>> 0; + const gender = (["male", "female", "male", "female", "other", ""] as Gender[])[s % 6]; + const titlePool = ["winner", "expert", "kot_master", "vip", "maestro", "captain", null, null]; + const title = titlePool[s % titlePool.length]; + return { + id: f.id, + displayName: f.displayName, + avatar: f.avatar, + level: f.level, + rating: f.rating, + status: f.status, + gender, + title, + isFriend: this.friends.some((x) => x.id === f.id), + requestSent: this.sentRequestIds.has(f.id), + }; + } + + async searchPlayers(query: string): Promise { + const q = query.trim(); + if (!q) return []; + // Synthesize a handful of "matching" players; the first echoes the query. + const n = 6; + const out: PlayerSummary[] = []; + for (let i = 0; i < n; i++) { + const f = makeFriend(pick(["online", "offline", "in-game", "online"])); + f.displayName = i === 0 && !q.startsWith("0") ? q : `${pick(PERSIAN_NAMES)} ${randInt(1, 99)}`; + out.push(this.summaryFromFriend(f)); + } + return out; + } + + async suggestedPlayers(): Promise { + const me = this.profile; + const lvl = me?.level ?? 10; + return Array.from({ length: 12 }, () => { + const f = makeFriend(pick(["online", "online", "in-game", "offline"])); + // bias suggestions toward a similar level + f.level = Math.max(1, lvl + randInt(-6, 6)); + return this.summaryFromFriend(f); + }); + } + async acceptRequest(id: string) { const req = this.requests.find((r) => r.id === id); if (req) { @@ -698,11 +878,18 @@ export class MockOnlineService implements OnlineService { }; this.emitMM(); - const reveal = (delay: number) => + // Wait ~15s (randomized 12–18s) for "online" players to show up; whoever + // hasn't joined by then is filled with a bot when the match forms. The exact + // wait varies so it never feels robotically identical. + const searchMs = randInt(12000, 18000); + // 0–3 humans actually appear; the rest of the table fills with bots. + const humansFound = randInt(0, 3); + + const reveal = (delay: number, isBot: boolean) => this.after(delay, () => { if (this.matchmaking.phase !== "searching") return; this.matchmaking.players.push({ - id: rid("p"), + id: rid(isBot ? "bot" : "p"), displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 50), @@ -711,11 +898,16 @@ export class MockOnlineService implements OnlineService { this.emitMM(); }); - reveal(900); - reveal(1900); - reveal(2900); + // Real players trickle in across the search window… + for (let i = 0; i < humansFound; i++) { + reveal(Math.round(searchMs * (0.25 + i * 0.22)), false); + } + // …then bots fill the remaining seats just before the match forms. + for (let i = 0; i < 3 - humansFound; i++) { + reveal(searchMs - 600 + i * 120, true); + } - this.after(3500, () => { + this.after(searchMs, () => { if (this.matchmaking.phase !== "searching") return; this.matchmaking.phase = "found"; this.emitMM(); @@ -787,6 +979,11 @@ export class MockOnlineService implements OnlineService { return { ok: true, profile: this.profile, coins: added }; } + async verifyIab(_store: string, productId: string, _token: string) { + // Offline/dev: no real store to verify against — credit the matching pack. + return this.buyCoins(productId); + } + private onlineCount = 60 + Math.floor(Math.random() * 110); async getOnlineCount(): Promise { // gentle random walk so the badge feels alive; never drops below 50 @@ -873,6 +1070,16 @@ export class MockOnlineService implements OnlineService { descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`, descEn: `${p.stickers.length} in-game stickers`, })); + const titleItems: ShopItem[] = TITLES.filter((tt) => (tt.price ?? 0) > 0).map((tt) => ({ + id: tt.id, + kind: "title", + nameFa: tt.nameFa, + nameEn: tt.nameEn, + price: tt.price!, + preview: "🏷️", + descFa: "عنوان نمایه که زیر نام شما در بازی و لیست‌ها نشان داده می‌شود", + descEn: "A profile title shown under your name in games & lists", + })); const xpItems: ShopItem[] = XP_PACKS.map((x) => ({ id: x.id, kind: "xp", @@ -884,7 +1091,7 @@ export class MockOnlineService implements OnlineService { descFa: `${faNum(x.xp)} امتیاز تجربه که بلافاصله به حساب اضافه می‌شود`, descEn: `${x.xp} XP added to your account instantly`, })); - return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...xpItems]; + return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...titleItems, ...xpItems]; } async buyItem(id: string) { @@ -912,6 +1119,7 @@ export class MockOnlineService implements OnlineService { cardback: p.ownedCardBacks, reactionpack: p.ownedReactionPacks, stickerpack: p.ownedStickerPacks, + title: p.ownedTitles, }; if (ownedMap[item.kind]?.includes(id)) return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" }; @@ -930,6 +1138,7 @@ export class MockOnlineService implements OnlineService { item.kind === "reactionpack" ? [...p.ownedReactionPacks, id] : p.ownedReactionPacks, ownedStickerPacks: item.kind === "stickerpack" ? [...p.ownedStickerPacks, id] : p.ownedStickerPacks, + ownedTitles: item.kind === "title" ? [...p.ownedTitles, id] : p.ownedTitles, }; this.saveProfile(); return { ok: true, profile: this.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" }; diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index b39e757..4b9e25e 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -14,6 +14,8 @@ import { Friend, FriendRequest, LeaderboardEntry, + PlayerSummary, + PublicProfile, MatchSummary, MatchmakingState, RewardResult, @@ -51,7 +53,11 @@ export interface OnlineService { getProfile(): Promise; updateProfile( patch: Partial< - Pick + Pick< + UserProfile, + | "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack" + | "gender" | "socials" | "socialsVisibility" + > > ): Promise; upgradePlan(): Promise; @@ -60,6 +66,14 @@ export interface OnlineService { listFriends(): Promise; listRequests(): Promise; addFriend(query: string): Promise<{ ok: boolean; messageFa: string; messageEn: string }>; + /** Send a friend request to a specific user id (from a profile/leaderboard tap). */ + addFriendById(userId: string): Promise<{ ok: boolean; messageFa: string; messageEn: string }>; + /** Fetch another player's public profile + achievement board. */ + getPublicProfile(userId: string): Promise; + /** Search players by display name (for the "find friends" discovery tab). */ + searchPlayers(query: string): Promise; + /** Suggested players to befriend (online / not-yet-friends). */ + suggestedPlayers(): Promise; acceptRequest(id: string): Promise; declineRequest(id: string): Promise; removeFriend(id: string): Promise; @@ -129,6 +143,12 @@ export interface OnlineService { getCoinPacks(): Promise; /** Mock credits instantly; live returns a `redirectUrl` to the ZarinPal gateway. */ buyCoins(packId: string): Promise<{ ok: boolean; profile?: UserProfile; coins: number; redirectUrl?: string }>; + /** Verify a store (Cafe Bazaar / Myket) purchase token and credit the pack. */ + verifyIab( + store: string, + productId: string, + token: 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 17bb333..b6e0f30 100644 --- a/src/lib/online/signalr-service.ts +++ b/src/lib/online/signalr-service.ts @@ -20,6 +20,8 @@ import { Friend, FriendRequest, LeaderboardEntry, + PlayerSummary, + PublicProfile, MatchSummary, MatchmakingState, RewardResult, @@ -359,6 +361,19 @@ export class SignalrService implements OnlineService { return this.send<{ ok: boolean; messageFa: string; messageEn: string }>( "POST", "/api/friends/add", { query: q }); } + addFriendById(userId: string) { + return this.send<{ ok: boolean; messageFa: string; messageEn: string }>( + "POST", "/api/friends/add", { userId }); + } + getPublicProfile(userId: string): Promise { + return this.getJson(`/api/profile/${encodeURIComponent(userId)}/public`); + } + searchPlayers(query: string): Promise { + return this.getJson(`/api/players/search?q=${encodeURIComponent(query)}`); + } + suggestedPlayers(): Promise { + return this.getJson("/api/players/suggested"); + } async acceptRequest(id: string) { await this.send("POST", "/api/friends/accept", { id }); } async declineRequest(id: string) { await this.send("POST", "/api/friends/decline", { id }); } async removeFriend(id: string) { await this.send("POST", "/api/friends/remove", { id }); } @@ -436,4 +451,14 @@ export class SignalrService implements OnlineService { "POST", "/api/coins/pay/request", { packId: id }); return { ok: r.ok, coins: 0, redirectUrl: r.url }; } + async verifyIab(store: string, productId: string, token: string) { + try { + const r = await this.send<{ ok: boolean; profile?: UserProfile; coins: number }>( + "POST", "/api/coins/iab/verify", { store, productId, token }); + if (r.profile) this.cachedProfile = r.profile; + return { ok: r.ok, profile: r.profile, coins: r.coins ?? 0 }; + } catch { + return { ok: false, coins: 0 }; + } + } } diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index ec83d0c..cd808ae 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -33,6 +33,20 @@ export interface PlayerStats { export type PlanId = "free" | "pro"; +/** Player-stated gender (empty = unspecified / not shown). */ +export type Gender = "" | "male" | "female" | "other"; + +/** Who may see a player's social links. */ +export type SocialVisibility = "public" | "friends" | "hidden"; + +/** Optional social-media handles/links a player chooses to share. */ +export interface SocialLinks { + instagram?: string; + telegram?: string; + x?: string; + youtube?: string; +} + export interface UserProfile { id: string; username: string; @@ -67,9 +81,43 @@ export interface UserProfile { achievements: Record; // achievementId -> progress count unlocked: string[]; // achievementId list already unlocked + // social + gender?: Gender; + socials?: SocialLinks; + socialsVisibility?: SocialVisibility; // default "public" + createdAt: number; } +/** + * A public-facing view of another player — what you may see by tapping their + * row in the leaderboard / friends list. No private fields (coins, phone, + * email). Achievements/stats are exposed so others can see their board. + */ +export interface PublicProfile { + id: string; + displayName: string; + avatar: string; + avatarImage?: string; + plan: PlanId; + title: string | null; + level: number; + rating: number; + stats: PlayerStats; + achievements: Record; + unlocked: string[]; + createdAt: number; + gender?: Gender; + /** Only present when the viewer is allowed to see them (public / friend). */ + socials?: SocialLinks; + /** is this player already your friend? */ + isFriend: boolean; + /** is this you? */ + isYou: boolean; + /** have you already sent them a pending friend request? */ + requestSent: boolean; +} + /* ------------------------------- Ranks ------------------------------- */ export type RankTierId = @@ -174,8 +222,23 @@ export interface TitleDef { /** how it's unlocked (for display) */ hintFa: string; hintEn: string; + /** >0 = purchasable in the shop (otherwise unlocked via stats/rank) */ + price?: number; } +/** Distinct visual pattern families for card backs (see lib/cardBack.ts). */ +export type CardBackPattern = + | "stripes" + | "argyle" + | "grid" + | "dots" + | "rays" + | "scales" + | "crosshatch" + | "royal" + | "filigree" + | "gem"; + export interface CardBackDef { id: string; nameFa: string; @@ -184,6 +247,10 @@ export interface CardBackDef { c2: string; // back gradient end accent: string; // pattern/border accent price: number; // >0 = purchasable + /** visual pattern (default "stripes"); luxury backs use fancier ones */ + pattern?: CardBackPattern; + /** optional centered emblem glyph (luxury backs) */ + motif?: string; default?: boolean; unlockRating?: number; unlockWins?: number; @@ -381,6 +448,7 @@ export type ShopItemKind = | "cardback" | "reactionpack" | "stickerpack" + | "title" | "xp"; export interface ShopItem { @@ -407,6 +475,8 @@ export interface CoinPack { bonus: number; // extra coins priceToman: number; tag?: "popular" | "best" | "starter"; + /** store product id (Bazaar/Myket SKU). Defaults to `id` when omitted. */ + sku?: string; } /* --------------------------- Daily reward ---------------------------- */ @@ -435,6 +505,24 @@ export interface Conversation { unread: number; } +/** A discoverable player (search results / suggestions in the social hub). */ +export interface PlayerSummary { + id: string; + displayName: string; + avatar: string; + avatarImage?: string; + level: number; + rating: number; + status: PresenceStatus; + gender?: Gender; + /** equipped title id (shown under the name) */ + title?: string | null; + /** already your friend? */ + isFriend: boolean; + /** you've already sent them a pending request? */ + requestSent: boolean; +} + /* ------------------- Server (SignalR) game state -------------------- */ export interface ServerCard { suit: string; rank: number; id: string } @@ -532,6 +620,12 @@ export const AVATARS: AvatarDef[] = [ { id: "a-dragon", emoji: "🐲", price: 1500 }, { id: "a-unicorn", emoji: "🦄", price: 1500 }, { id: "a-peacock", emoji: "🦚", price: 2000 }, + // ✨ Luxury avatars — premium, high-price collectibles + { id: "a-swan", emoji: "🦢", price: 1800 }, + { id: "a-tophat", emoji: "🎩", price: 2200 }, + { id: "a-diamond", emoji: "💎", price: 3000 }, + { id: "a-moneybag", emoji: "💰", price: 3500 }, + { id: "a-trophy", emoji: "🏆", price: 4000 }, // earned by rank / wins — the rarer faces sit behind higher ranks { id: "a-robot", emoji: "🤖", unlockWins: 50 }, { id: "a-wizard", emoji: "🧙", unlockRating: 1300 }, @@ -539,6 +633,7 @@ export const AVATARS: AvatarDef[] = [ { id: "a-king", emoji: "🤴", unlockRating: 1500 }, { id: "a-genie", emoji: "🧞", unlockRating: 1700 }, { id: "a-crown", emoji: "👑", unlockRating: 1900 }, + { id: "a-gem", emoji: "💠", unlockRating: 2100 }, ]; export function avatarEmoji(id: string): string { diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts index 642a8c2..1269c9f 100644 --- a/src/lib/session-store.ts +++ b/src/lib/session-store.ts @@ -22,7 +22,13 @@ interface SessionStore { signOut: () => Promise; updateProfile: ( - patch: Partial> + patch: Partial< + Pick< + UserProfile, + | "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack" + | "gender" | "socials" | "socialsVisibility" + > + > ) => Promise; upgradePlan: () => Promise; } diff --git a/src/lib/social.ts b/src/lib/social.ts new file mode 100644 index 0000000..6dd5d10 --- /dev/null +++ b/src/lib/social.ts @@ -0,0 +1,36 @@ +import { Gender, SocialLinks } from "./online/types"; + +/** Display metadata for each gender (symbol + colour + localized label). */ +export const GENDER_META: Record< + Exclude, + { symbol: string; color: string; faLabel: string; enLabel: string } +> = { + male: { symbol: "♂", color: "#5aa6e0", faLabel: "آقا", enLabel: "Male" }, + female: { symbol: "♀", color: "#ff7aa8", faLabel: "خانم", enLabel: "Female" }, + other: { symbol: "⚧", color: "#c77dff", faLabel: "دیگر", enLabel: "Other" }, +}; + +/** The social platforms a player can share, with link prefixes + brand colours. */ +export const SOCIAL_PLATFORMS = [ + { key: "instagram", label: "Instagram", icon: "📸", color: "#E1306C", prefix: "https://instagram.com/" }, + { key: "telegram", label: "Telegram", icon: "✈️", color: "#229ED9", prefix: "https://t.me/" }, + { key: "x", label: "X", icon: "𝕏", color: "#cfd2d6", prefix: "https://x.com/" }, + { key: "youtube", label: "YouTube", icon: "▶️", color: "#FF4444", prefix: "https://youtube.com/@" }, +] as const; + +export type SocialKey = (typeof SOCIAL_PLATFORMS)[number]["key"]; + +/** Build a tappable URL from a handle or full URL. */ +export function socialUrl(key: string, value: string): string { + const v = value.trim(); + if (/^https?:\/\//i.test(v)) return v; + const handle = v.replace(/^@+/, ""); + const p = SOCIAL_PLATFORMS.find((x) => x.key === key); + return p ? p.prefix + handle : v; +} + +/** Does the player have at least one non-empty social link? */ +export function hasSocials(s?: SocialLinks | null): boolean { + if (!s) return false; + return SOCIAL_PLATFORMS.some((p) => (s[p.key] ?? "").trim().length > 0); +} diff --git a/src/lib/storeBilling.ts b/src/lib/storeBilling.ts new file mode 100644 index 0000000..b5a19d4 --- /dev/null +++ b/src/lib/storeBilling.ts @@ -0,0 +1,102 @@ +// Store in-app billing (Cafe Bazaar / Myket) for coin packs. +// +// - **Bazaar** (embedded PWA): pure deep-link flow. We navigate to +// `bazaar://in_app?...&sku=...&redirect_url=...`; Bazaar processes payment and +// reopens the app at redirect_url with `?purchaseToken=...`. We stash the SKU +// first, then on return POST the token to `/api/coins/iab/verify`. +// - **Myket**: native AIDL billing. A Capacitor plugin must inject +// `window.MyketBilling` (see ANDROID.md). We call `.purchase(sku)` and POST the +// returned token to verify. Without the bridge, Myket is "unavailable". +// +// The active store is the build flavor `NEXT_PUBLIC_STORE` (bazaar|myket|web), +// overridden at runtime if the Myket native bridge is present. + +import { CoinPack } from "./online/types"; + +export type StoreId = "bazaar" | "myket" | "web"; + +const ENV_STORE = ((process.env.NEXT_PUBLIC_STORE as StoreId | undefined) ?? "web"); +const PACKAGE = process.env.NEXT_PUBLIC_APP_PACKAGE ?? "com.bargevasat.hokm"; +const PENDING_SKU_KEY = "iab_pending_sku"; + +/** Native bridge contract a Myket Capacitor plugin must fulfil. */ +interface MyketBridge { + available?: boolean; + purchase: (sku: string) => Promise<{ purchaseToken: string; productId?: string }>; + consume?: (token: string) => Promise; +} +declare global { + interface Window { + MyketBilling?: MyketBridge; + } +} + +export function getStore(): StoreId { + if (typeof window !== "undefined" && window.MyketBilling?.available) return "myket"; + return ENV_STORE; +} + +/** True when coin purchases should go through a store (not the web ZarinPal gateway). */ +export function isStoreBilling(): boolean { + return getStore() !== "web"; +} + +function skuFor(pack: CoinPack): string { + return pack.sku ?? pack.id; +} + +export type PurchaseStart = + | { kind: "redirect" } // Bazaar — the app navigated away; result arrives on return + | { kind: "token"; store: StoreId; productId: string; token: string } // Myket — verify now + | { kind: "unavailable" }; + +/** Begin a store purchase for a coin pack. */ +export async function purchaseViaStore(pack: CoinPack): Promise { + const store = getStore(); + const sku = skuFor(pack); + + if (store === "bazaar") { + try { + localStorage.setItem(PENDING_SKU_KEY, sku); + } catch { + /* ignore storage errors */ + } + const redirect = encodeURIComponent(window.location.origin + window.location.pathname); + window.location.href = + `bazaar://in_app?package_name=${encodeURIComponent(PACKAGE)}` + + `&sku=${encodeURIComponent(sku)}&redirect_url=${redirect}`; + return { kind: "redirect" }; + } + + if (store === "myket" && window.MyketBilling) { + const res = await window.MyketBilling.purchase(sku); + return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken }; + } + + return { kind: "unavailable" }; +} + +/** + * On app load, capture a Bazaar redirect (`?purchaseToken=...`). Returns the + * pending purchase to verify, or null. Also clears the stashed SKU. + */ +export function captureBazaarRedirect(): { store: StoreId; productId: string; token: string } | null { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const token = params.get("purchaseToken"); + if (!token) return null; + let productId = params.get("sku") ?? params.get("productId") ?? ""; + if (!productId) { + try { + productId = localStorage.getItem(PENDING_SKU_KEY) ?? ""; + } catch { + /* ignore */ + } + } + try { + localStorage.removeItem(PENDING_SKU_KEY); + } catch { + /* ignore */ + } + return { store: "bazaar", productId, token }; +} diff --git a/src/lib/ui-store.ts b/src/lib/ui-store.ts index 8508e83..5f8eb46 100644 --- a/src/lib/ui-store.ts +++ b/src/lib/ui-store.ts @@ -52,6 +52,8 @@ interface UIStore { /** screen to return to from the game table */ returnTo: Screen; dailyModalOpen: boolean; + /** user id whose public profile is being viewed in a modal (null = closed) */ + viewProfileId: string | null; go: (screen: Screen) => void; goGame: (returnTo?: Screen) => void; @@ -64,12 +66,17 @@ interface UIStore { openDaily: () => void; closeDaily: () => void; + + /** Open another player's public profile in an overlay modal. */ + viewProfile: (userId: string) => void; + closeProfile: () => void; } export const useUIStore = create((set, get) => ({ screen: "home", returnTo: "home", dailyModalOpen: false, + viewProfileId: null, go: (screen) => { if (get().screen === screen) return; @@ -106,4 +113,7 @@ export const useUIStore = create((set, get) => ({ openDaily: () => set({ dailyModalOpen: true }), closeDaily: () => set({ dailyModalOpen: false }), + + viewProfile: (userId) => set({ viewProfileId: userId }), + closeProfile: () => set({ viewProfileId: null }), }));