feat: UNO-style table, social hub, cosmetics, speed mode, store IAB

Game table & play
- UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow,
  big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round
  confetti, match coin-rain.
- Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert;
  mirrored server-side in GameRoom.TurnMs.
- Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing.
- Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint.

Rewards / gifts
- Richer post-match modal (floating coins, XP bar), celebration overlay reveals
  the unlocked sticker pack, boosted daily rewards (client+server synced),
  themed 7-day daily with special day-7.

Social
- Public profile modal (identity, stats, achievement board) from leaderboard /
  friends / discover / end-of-game roster; rate-limited add-friend (10/hour).
- Social hub: Friends / Discover (player search + suggestions) / Messages inbox.
- Profile gender (shown in finder/profile) + social links with public/friends/
  hidden visibility, enforced server-side.

Cosmetics
- Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/
  rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts),
  consistent on table/shop/profile; +Peacock/Rose-Gold backs.
- Purchasable titles (shop Titles section); title shown under the seat on the
  table and in discover/public profile.
- 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods).
- Persistent level+XP bar on Home and every inner screen.

Payments
- Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh.
- Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture,
  Myket native-bridge contract, server-side IabService.Verify for both stores,
  config-driven via Iab__* env. POST /api/coins/iab/verify (JWT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 18:39:24 +03:30
parent e450a6a2ed
commit cb27a16dc1
49 changed files with 3438 additions and 592 deletions
+21 -7
View File
@@ -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 1218s) 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", "70 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** `<MotionConfig reducedMotion="user">` 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<void> }`.
`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).
+15
View File
@@ -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
+7
View File
@@ -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:
+7 -3
View File
@@ -17,8 +17,12 @@ public sealed class Player
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
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 (1218s) 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));
+4 -1
View File
@@ -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<GameHub> _hub;
@@ -0,0 +1,121 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace Hokm.Server.Payments;
/// <summary>
/// Config for store in-app billing verification. Fill these from the Cafe Bazaar
/// (pardakht) and Myket developer panels. Bound from the "Iab" config section /
/// <c>Iab__*</c> env vars.
/// </summary>
public sealed class IabOptions
{
/// <summary>Android package name registered in the store panels.</summary>
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; } = "";
/// <summary>
/// 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.
/// </summary>
public bool AllowUnverified { get; set; } = false;
}
/// <summary>
/// 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.
/// </summary>
public sealed class IabService
{
private static readonly HttpClient Http = new();
private readonly IabOptions _opts;
private readonly ILogger<IabService> _log;
public IabService(IabOptions opts, ILogger<IabService> log)
{
_opts = opts;
_log = log;
}
public async Task<bool> 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;
}
}
/// <summary>
/// Cafe Bazaar: exchange the refresh token for an access token, then validate
/// the in-app purchase. See https://pardakht.cafebazaar.ir/devapi/v2/.
/// </summary>
private async Task<bool> VerifyBazaar(string productId, string token)
{
if (string.IsNullOrWhiteSpace(_opts.BazaarRefreshToken)) return _opts.AllowUnverified;
// 1) refresh_token → access_token
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["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;
}
/// <summary>
/// Myket: validate via the developer API (mirrors Google Play). The access
/// token comes from the Myket developer panel.
/// </summary>
private async Task<bool> 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;
}
}
@@ -18,6 +18,15 @@ public class StatsDto
public int RoundsWon { get; set; }
}
/// <summary>Optional social-media handles a player chooses to share.</summary>
public class SocialLinksDto
{
public string? Instagram { get; set; }
public string? Telegram { get; set; }
public string? X { get; set; }
public string? Youtube { get; set; }
}
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
public class ProfileDto
{
@@ -48,11 +57,42 @@ public class ProfileDto
public List<string> 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
}
/// <summary>
/// Public-facing view of another player (no coins/phone/email). Mirrors the
/// client <c>PublicProfile</c>. Returned by <c>GET /api/profile/{id}/public</c>.
/// </summary>
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<string, int> Achievements { get; set; } = new();
public List<string> Unlocked { get; set; } = new();
public long CreatedAt { get; set; }
public string Gender { get; set; } = "";
/// <summary>Only populated when the viewer is allowed to see them (public / friend / self).</summary>
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; }
@@ -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<SocialLinksDto>(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)
+27 -6
View File
@@ -41,6 +41,11 @@ if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4df
builder.Services.AddSingleton(zp);
builder.Services.AddSingleton<ZarinpalService>();
// --- Store in-app billing (Cafe Bazaar / Myket) verification ---
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOptions();
builder.Services.AddSingleton(iab);
builder.Services.AddSingleton<IabService>();
// --- 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);
@@ -32,3 +32,19 @@ public class ConversationDto
public ChatMessageDto? LastMessage { get; set; }
public int Unread { get; set; }
}
/// <summary>A discoverable player in the social "find friends" hub.</summary>
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; }
}
+140 -3
View File
@@ -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<GameHub> _hub;
/// <summary>Max outgoing friend requests allowed per user within a rolling hour.</summary>
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<string, List<DateTime>> _reqLog = new();
public SocialService(AppDbContext db, GameManager mgr, IHubContext<GameHub> hub)
{
_db = db;
@@ -21,6 +28,28 @@ public class SocialService
_hub = hub;
}
/// <summary>
/// Records an outgoing friend-request attempt against the rolling-hour cap.
/// Returns false (with the minutes until a slot frees) when over the limit.
/// </summary>
private static bool TryRecordRequest(string uid, out int retryMins)
{
retryMins = 0;
var now = DateTime.UtcNow;
var list = _reqLog.GetOrAdd(uid, _ => new List<DateTime>());
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<FriendDto> 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);
}
/// <summary>Send a friend request to a concrete user id (rate-limited to 10/hour).</summary>
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))
{
// 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<string> friendIds, HashSet<string> 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),
};
/// <summary>Search players by display name (case-insensitive contains).</summary>
public async Task<List<PlayerSummaryDto>> 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<PlayerSummaryDto>();
foreach (var row in rows)
{
var p = JsonSerializer.Deserialize<ProfileDto>(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;
}
/// <summary>Suggested players to befriend (online-first, excludes existing friends).</summary>
public async Task<List<PlayerSummaryDto>> 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<PlayerSummaryDto>();
foreach (var row in rows)
{
var p = JsonSerializer.Deserialize<ProfileDto>(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();
}
/// <summary>Another player's public profile + achievement board (no private fields).</summary>
public async Task<PublicProfileDto?> GetPublicProfile(string uid, string targetId)
{
targetId = targetId.Trim();
var row = await _db.Profiles.FindAsync(targetId);
if (row == null) return null;
var p = JsonSerializer.Deserialize<ProfileDto>(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);
+8
View File
@@ -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
}
}
+112
View File
@@ -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;
}
}
+31 -2
View File
@@ -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…).
<MotionConfig reducedMotion="user">
{renderScreen(screen)}
<DailyRewardModal />
<NotificationToaster />
<ResumeGameBar />
<CelebrationOverlay />
<PublicProfileModal />
<CapacitorBack />
{loading && null}
</>
</MotionConfig>
);
}
+311 -97
View File
@@ -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): 19 / 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: 14 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 (
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
@@ -80,6 +117,7 @@ export function GameTable({
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between gap-2 safe-top safe-x pb-3 sm:p-4">
<Scoreboard />
<div className="flex items-center gap-2">
<SpeedBadge />
{trump && <TrumpBadge trump={trump} />}
<button
onClick={toggleAll}
@@ -170,6 +208,7 @@ export function GameTable({
<TurnTimer />
<DisconnectBanner />
<Reactions />
<ShortcutsHint />
{/* Overlays */}
<AnimatePresence>
@@ -237,6 +276,25 @@ function ScoreCol({
);
}
/* ----------------------------- Speed badge ---------------------------- */
function SpeedBadge() {
const speed = useGameStore((s) => s.matchMeta.speed);
const { t } = useI18n();
if (!speed) return null;
return (
<motion.div
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
className="glass rounded-2xl px-2.5 py-2 flex items-center gap-1 text-gold-300"
title={t("speed.label")}
>
<Zap className="size-4 fill-gold-400 text-gold-400" />
<span className="text-[10px] font-black uppercase tracking-wide">{t("speed.label")}</span>
</motion.div>
);
}
/* ----------------------------- Trump badge ---------------------------- */
function TrumpBadge({ trump }: { trump: Suit }) {
@@ -268,6 +326,7 @@ function TrumpBadge({ trump }: { trump: Suit }) {
function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
const game = useGameStore((s) => s.game);
const sp = useGameStore((s) => s.seatPlayers[seat]);
const { locale } = useI18n();
const player = game.players[seat];
const active =
(game.phase === "playing" && game.turn === seat) ||
@@ -275,30 +334,33 @@ function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
const isHakem = game.hakem === seat;
const team = teamOf(seat);
const name = sp?.name ?? player.name;
const titleDef = titleById(sp?.title);
const titleName = titleDef ? (locale === "fa" ? titleDef.nameFa : titleDef.nameEn) : null;
return (
<div className={cn("z-20 flex flex-col items-center gap-1", className)}>
<motion.div
animate={
active
? { boxShadow: "0 0 0 3px rgba(212,175,55,0.9), 0 0 24px rgba(212,175,55,0.5)" }
: { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" }
}
<div
className={cn(
"relative size-10 sm:size-12 rounded-full flex items-center justify-center font-bold text-lg sm:text-xl",
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100"
"relative size-12 sm:size-14 rounded-full flex items-center justify-center font-bold text-xl sm:text-2xl transition-all",
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100",
active && "active-player-ring"
)}
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
>
{sp?.avatar ?? name.charAt(0)}
{isHakem && (
<Crown className="absolute -top-3 size-4 text-gold-400 fill-gold-500" />
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
)}
</motion.div>
{active && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 size-2.5 rounded-full bg-gold-400 ring-2 ring-navy-900" />
)}
</div>
<span className="text-[11px] font-semibold text-cream max-w-20 truncate hud-shadow">{name}</span>
{titleName && (
<span className="text-[9px] font-bold gold-text leading-none max-w-24 truncate hud-shadow">{titleName}</span>
)}
{sp && sp.level > 0 && (
<span className="text-[9px] text-gold-300 leading-none hud-shadow">
{`Lv ${sp.level}`}
</span>
<span className="text-[10px] text-gold-300/80 leading-none hud-shadow">{`Lv ${sp.level}`}</span>
)}
</div>
);
@@ -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 (
<div className={cn("flex", horizontal ? "flex-row items-end" : "flex-col items-center", className)}>
{cards.map((_, i) => {
const rot = horizontal ? (i - mid) * 4 : 0;
return (
<div
className={cn(
"flex",
horizontal ? "flex-row" : "flex-col",
className
)}
>
{cards.map((_, i) => (
<div
key={i}
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
style={{
...(horizontal
? { marginInlineStart: i === 0 ? 0 : -30 }
: { marginTop: i === 0 ? 0 : -46 }),
transform: rot ? `rotate(${rot}deg)` : undefined,
transformOrigin: "bottom center",
}}
>
<PlayingCard faceDown size="sm" back={back} />
</div>
))}
);
})}
</div>
);
}
@@ -367,29 +433,23 @@ function TrickArea({
const { front } = useCardSkins();
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-1 ">
<div className="relative size-1">
<AnimatePresence>
{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 (
<motion.div
key={pc.card.id}
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
animate={{
x: off.x,
y: off.y,
opacity: 1,
scale: isWinner ? 1.12 : 1,
}}
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 260, damping: 26 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{
filter: isWinner
? "drop-shadow(0 0 14px rgba(212,175,55,0.9))"
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
: undefined,
}}
>
@@ -398,11 +458,55 @@ function TrickArea({
);
})}
</AnimatePresence>
{/* Burst particles when trick is won */}
<AnimatePresence>
{phase === "trick-complete" && winner != null && (
<TrickBurst key={`burst-${winner}`} seat={winner} />
)}
</AnimatePresence>
</div>
</div>
);
}
/* 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 */}
<motion.div
initial={{ scale: 0, opacity: 0.85 }}
animate={{ scale: 3.5, opacity: 0 }}
transition={{ duration: 0.38 }}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
style={{ width: 44, height: 44, background: glowColor }}
/>
{BURST_ANGLES.map(p => (
<motion.div
key={p.id}
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
animate={{ x: p.x, y: p.y, opacity: 0, scale: 0 }}
transition={{ duration: 0.55, ease: [0.2, 0.8, 0.4, 1] }}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none"
style={{ width: p.size, height: p.size, background: gradient }}
/>
))}
</>
);
}
/* ----------------------------- Player hand ---------------------------- */
function useViewportWidth() {
@@ -430,18 +534,21 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
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 (
<div
className={cn(
@@ -456,6 +563,8 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
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 (
<motion.button
key={card.id}
@@ -471,8 +580,9 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
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"
)}
>
<PlayingCard
@@ -480,8 +590,12 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
size={size}
dimmed={dimmed}
front={front}
className={cn(playable && "ring-2 ring-gold-400/80")}
/>
{showShortcutBadges && badge && (
<span className="absolute -top-2 left-1/2 -translate-x-1/2 z-40 size-5 rounded-full btn-gold text-[11px] font-black grid place-items-center shadow-md">
{badge}
</span>
)}
</motion.button>
);
})}
@@ -490,6 +604,52 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
);
}
/* --------------------------- 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 (
<div className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] ltr:left-4 rtl:right-4 z-50">
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
className="glass rounded-2xl p-3 mb-2 w-56 text-xs text-cream/80 space-y-1.5"
>
<Row k="19 / 0" v={t("keys.play")} />
<Row k="Space" v={t("keys.first")} />
<Row k="14" v={t("keys.trump")} />
<Row k="M" v={t("keys.mute")} />
<Row k="F" v={t("keys.forfeit")} />
<Row k="Esc / Q" v={t("keys.quit")} />
</motion.div>
)}
</AnimatePresence>
<button
onClick={() => setOpen((o) => !o)}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition text-gold-400 font-black"
title={t("keys.title")}
>
</button>
</div>
);
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex items-center justify-between gap-3">
<kbd className="rounded-md bg-navy-900/80 gold-border px-1.5 py-0.5 font-mono text-[10px] text-gold-300">{k}</kbd>
<span className="text-cream/70">{v}</span>
</div>
);
}
/* --------------------------- Turn indicator --------------------------- */
function TurnIndicator() {
@@ -502,19 +662,28 @@ function TurnIndicator() {
<AnimatePresence mode="wait">
<motion.div
key={game.turn}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute bottom-[120px] sm:bottom-[150px] left-1/2 -translate-x-1/2 z-30"
initial={{ opacity: 0, scale: 0.75, y: 18 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.85, y: -8 }}
transition={{ type: "spring", stiffness: 340, damping: 24 }}
className="absolute bottom-[136px] sm:bottom-[168px] left-1/2 -translate-x-1/2 z-30"
>
<div
className={cn(
"rounded-full px-4 py-1.5 text-sm font-semibold glass",
isYou ? "text-gold-300" : "text-cream/70"
)}
{isYou ? (
<motion.div
animate={{ scale: [1, 1.055, 1] }}
transition={{ repeat: Infinity, duration: 1.05, ease: "easeInOut" }}
className="btn-gold press-3d rounded-full px-7 py-2.5 font-black text-[15px] tracking-wide"
style={{
boxShadow: "0 0 0 3px rgba(212,175,55,0.55), 0 0 28px rgba(212,175,55,0.45), 0 5px 0 rgba(0,0,0,0.32)",
}}
>
{isYou ? t("turn.you") : t("turn.other", { name })}
{t("turn.you")}
</motion.div>
) : (
<div className="glass rounded-full px-5 py-2 text-sm font-semibold text-cream/70">
{t("turn.other", { name })}
</div>
)}
</motion.div>
</AnimatePresence>
);
@@ -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 (
<div className="absolute bottom-[156px] sm:bottom-[190px] left-1/2 -translate-x-1/2 z-30 w-36 sm:w-40 text-center">
@@ -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 (
<Backdrop>
<motion.div
initial={{ scale: 0.85, y: 20 }}
initial={{ scale: 0.82, y: 24 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-8 text-center max-w-sm w-full"
transition={{ type: "spring", stiffness: 220, damping: 20 }}
className="glass rounded-3xl p-8 text-center max-w-sm w-full relative overflow-hidden"
>
{/* confetti on win */}
{weWon && CONFETTI_SPECS.map(p => (
<motion.div
key={p.id}
initial={{ y: -10, opacity: 1, rotate: p.rot }}
animate={{ y: 160, opacity: 0, rotate: p.rot + 450 }}
transition={{ duration: 1.5 + p.delay * 0.4, delay: p.delay, ease: "linear" }}
className="absolute pointer-events-none rounded-sm"
style={{ width: p.size, height: p.size, background: p.color, left: `${p.left}%`, top: 0 }}
/>
))}
<motion.div
initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 16 }}
className="text-5xl mb-1"
>
{weWon ? "🎉" : "😤"}
</motion.div>
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
{r.kot && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay: 0.15 }}
className="mt-3 inline-block rounded-full btn-gold px-5 py-1.5 text-lg font-black"
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 350, damping: 14, delay: 0.15 }}
className="mt-3 inline-flex items-center gap-1.5 rounded-full btn-gold press-3d px-6 py-2 text-xl font-black"
>
{t("round.kot")}🔥
{t("round.kot")} 🔥
</motion.div>
)}
<p
className={cn(
"mt-4 text-xl font-bold",
weWon ? "text-teal-300" : "text-rose-300"
)}
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.22 }}
className={cn("mt-4 text-2xl font-black", weWon ? "text-teal-300" : "text-rose-300")}
>
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
</motion.p>
<p className="text-cream/70 mt-2 font-semibold">
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
</p>
<p className="text-cream/70 mt-2">
{t("round.score", {
us: game.matchScore[0],
them: game.matchScore[1],
})}
</p>
<p className="text-cream/40 text-sm mt-5 animate-pulse">
{t("round.next")}
</p>
<p className="text-cream/40 text-sm mt-5 animate-pulse">{t("round.next")}</p>
</motion.div>
</Backdrop>
);
}
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 (
<Backdrop>
<motion.div
initial={{ scale: 0.85 }}
animate={{ scale: 1 }}
className="glass rounded-3xl p-9 text-center max-w-sm w-full"
initial={{ scale: 0.82, y: 16 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 18 }}
className="glass rounded-3xl p-9 text-center max-w-sm w-full relative overflow-hidden"
>
{/* coin rain on win */}
{youWin && WIN_COINS.map(c => (
<motion.div
initial={{ rotate: -15, scale: 0 }}
key={c.id}
initial={{ top: "-30px", opacity: 0, rotate: 0 }}
animate={{ top: "110%", opacity: [0, 1, 1, 0], rotate: 540 }}
transition={{ duration: 2.2 + c.delay * 0.3, delay: c.delay, ease: "easeIn" }}
className="absolute pointer-events-none select-none"
style={{ left: `${c.left}%`, fontSize: c.fontSize }}
>
🪙
</motion.div>
))}
<motion.div
initial={{ rotate: -20, scale: 0 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 160 }}
className="text-6xl mb-3"
transition={{ type: "spring", stiffness: 180, damping: 14 }}
className="text-7xl mb-3 relative"
>
{youWin ? "🏆" : "🎴"}
</motion.div>
<h2 className="gold-text text-3xl font-black">{t("match.over")}</h2>
<p
className={cn(
"mt-3 text-2xl font-bold",
youWin ? "text-gold-300" : "text-rose-300"
)}
>
<p className={cn("mt-3 text-2xl font-bold", youWin ? "text-gold-300" : "text-rose-300")}>
{youWin ? t("match.youWin") : t("match.youLose")}
</p>
<p className="text-cream/70 mt-2">
{t("round.score", {
us: game.matchScore[0],
them: game.matchScore[1],
})}
{t("round.score", { us: game.matchScore[0], them: game.matchScore[1] })}
</p>
<MatchPlayersList />
<div className="mt-7 flex gap-3">
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3">
<button onClick={onExit} className="press-3d btn-gold flex-1 rounded-xl py-3 font-black text-lg">
{t("match.menu")}
</button>
</div>
+33 -2
View File
@@ -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() {
<PrimaryCard
icon={<Bot className="size-6" />}
title={t("menu.vsComputer")}
desc={t("menu.vsComputerDesc")}
desc={speed ? t("speed.desc") : t("menu.vsComputerDesc")}
onClick={playVsComputer}
/>
{/* Normal / Speed mode picker */}
<div className="mt-2 glass rounded-2xl p-1 flex gap-1">
<button
onClick={() => setSpeed(false)}
className={cn(
"flex-1 rounded-xl py-2 text-xs font-bold transition flex items-center justify-center gap-1.5",
!speed ? "btn-gold" : "text-cream/60 hover:text-cream"
)}
>
{t("speed.normal")}
</button>
<button
onClick={() => setSpeed(true)}
className={cn(
"flex-1 rounded-xl py-2 text-xs font-bold transition flex items-center justify-center gap-1.5",
speed ? "btn-gold" : "text-cream/60 hover:text-cream"
)}
>
<Zap className={cn("size-3.5", speed && "fill-current")} />
{t("speed.label")}
</button>
</div>
</div>
{/* tiles */}
+89 -58
View File
@@ -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 (
<div
className={cn(!back && "card-back", "rounded-lg shrink-0", className)}
style={styled}
className={cn(!back && "card-back", "shrink-0", className)}
style={{ ...styled, borderRadius: s.radius }}
aria-hidden
>
<div className="h-full w-full rounded-lg flex items-center justify-center">
<div
className={cn("text-lg font-bold", !back && "text-gold-500/70")}
style={back ? { color: `${back.accent}cc` } : undefined}
className="h-full w-full flex items-center justify-center"
style={{ borderRadius: s.radius }}
>
</div>
{motif && (
<span
className={cn("font-bold select-none", !back && "text-gold-500/70")}
style={{
fontSize: s.w * 0.34,
color: back ? `${back.accent}dd` : undefined,
textShadow: back ? `0 1px 3px rgba(0,0,0,0.45)` : undefined,
}}
>
{motif}
</span>
)}
</div>
</div>
);
}
/* ── Face-up ────────────────────────────────────────────────────── */
const red = SUIT_IS_RED[card.suit];
const color = red ? "text-rose-600" : "text-slate-900";
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 (
<div
className={cn(
"card-face rounded-lg shrink-0 relative select-none transition-opacity",
dimmed && "opacity-45",
className
)}
className={cn("shrink-0 relative select-none transition-opacity", dimmed && "opacity-40", className)}
style={{
width: s.w,
height: s.h,
...(front
? { background: `linear-gradient(160deg, ${front.bg1}, ${front.bg2})`, borderColor: front.border }
: {}),
width: s.w, height: s.h, borderRadius: s.radius,
background: cardBg,
border: `1.5px solid ${borderColor}`,
boxShadow: "0 6px 18px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.85)",
}}
>
<div className={cn("absolute top-1 left-1.5 leading-none font-bold", color, s.rank)}>
<div>{rankLabel(card.rank)}</div>
<div className={s.rank}>{symbol}</div>
</div>
{/* Top-left corner */}
<div
className={cn(
"absolute inset-0 flex items-center justify-center font-bold",
color,
s.center
)}
className={cn("absolute top-[3px] left-[5px] leading-[1.1] font-black", s.rank)}
style={{ color: inkColor }}
>
<div>{label}</div>
<div style={{ color: pipColor, fontSize: "0.82em" }}>{symbol}</div>
</div>
{/* Center symbol — large, bold, slightly shadowed */}
<div
className={cn("absolute inset-0 flex items-center justify-center font-black", s.center)}
style={{
color: inkColor,
textShadow: red
? "0 2px 10px rgba(210,40,40,0.18)"
: "0 2px 10px rgba(28,28,56,0.12)",
}}
>
{symbol}
</div>
{/* Bottom-right corner (rotated 180°) */}
<div
className={cn(
"absolute bottom-1 right-1.5 leading-none font-bold rotate-180",
color,
s.rank
)}
className={cn("absolute bottom-[3px] right-[5px] leading-[1.1] font-black rotate-180", s.rank)}
style={{ color: inkColor }}
>
<div>{rankLabel(card.rank)}</div>
<div className={s.rank}>{symbol}</div>
<div>{label}</div>
<div style={{ color: pipColor, fontSize: "0.82em" }}>{symbol}</div>
</div>
{/* Subtle inner rim for red suits — UNO-style */}
{red && (
<div
className="absolute inset-[3px] pointer-events-none"
style={{
borderRadius: Math.max(0, s.radius - 4),
border: "1px solid rgba(210,40,40,0.14)",
}}
/>
)}
</div>
);
}
+13 -2
View File
@@ -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,7 +127,9 @@ function Card() {
{/* achievements */}
{current.achievements && current.achievements.length > 0 && (
<div className="relative mt-4 space-y-2">
{current.achievements.map((a, i) => (
{current.achievements.map((a, i) => {
const pack = stickerPackForAchievement(a.id);
return (
<motion.div
key={a.id}
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
@@ -139,13 +143,20 @@ function Card() {
<span className="block text-sm font-semibold text-cream truncate">
{locale === "fa" ? a.nameFa : a.nameEn}
</span>
{pack && (
<span className="mt-0.5 flex items-center gap-1 text-[10px] text-teal-300">
{pack.stickers[0] && <Sticker id={pack.stickers[0]} size={14} />}
{t("reward.stickerUnlocked")}: {locale === "fa" ? pack.nameFa : pack.nameEn}
</span>
)}
</span>
<span className="text-xs text-gold-300 flex items-center gap-1 shrink-0">
+{a.coinReward}
<Coins className="size-3" />
</span>
</motion.div>
))}
);
})}
</div>
)}
+100 -31
View File
@@ -12,6 +12,22 @@ 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);
@@ -35,6 +51,8 @@ export function DailyRewardModal() {
setState(await getService().getDailyState());
};
const isMegaDay = state?.day === 7;
return (
<AnimatePresence>
{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"
>
<motion.div
initial={{ scale: 0.9, y: 16 }}
initial={{ scale: 0.88, y: 18 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 20 }}
onClick={(e) => 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"
>
<h2 className="gold-text text-2xl font-black">{t("daily.title")}</h2>
{/* gold glow */}
<div className="pointer-events-none absolute -inset-10 bg-gold-500/10 blur-3xl" />
<div className="grid grid-cols-4 gap-2 mt-5">
<motion.div
initial={{ scale: 0, rotate: -15 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 240, damping: 16 }}
className="relative text-5xl mb-1"
>
🎁
</motion.div>
<h2 className="relative gold-text text-2xl font-black">{t("daily.title")}</h2>
{/* Day cards grid: 3-col rows, day 7 spans full width */}
<div className="relative grid grid-cols-3 gap-2 mt-5">
{DAILY_REWARDS.map((coins, i) => {
const day = i + 1;
const meta = DAY_META[i];
const isToday = state?.day === day && state?.available;
const isPast = state ? day < state.day : false;
const isMega = day === 7;
return (
<div
<motion.div
key={day}
initial={{ opacity: 0, y: 12, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: i * 0.06, type: "spring", stiffness: 280, damping: 22 }}
className={cn(
"rounded-xl py-2.5 flex flex-col items-center gap-1 border",
i === 6 && "col-span-4 flex-row justify-center gap-3",
"rounded-2xl py-3 px-2 flex flex-col items-center gap-1.5 border transition-all",
isMega && "col-span-3 flex-row justify-center gap-4 py-4 px-5",
isToday
? "btn-gold border-transparent"
? "btn-gold border-transparent shadow-lg"
: isPast
? "bg-navy-900/50 border-teal-500/30 opacity-60"
: "bg-navy-900/70 gold-border"
? "bg-navy-900/50 border-teal-500/30 opacity-55"
: `bg-gradient-to-b ${meta.accent} gold-border`
)}
>
<span className={cn("text-[10px]", isToday ? "text-[#2a1f04]" : "text-cream/60")}>
{t("daily.day", { n: day })}
<span className={cn("text-2xl leading-none", isMega && "text-3xl")}>
{meta.icon}
</span>
<span
className={cn(
"flex items-center gap-1 text-sm font-bold",
<div className={cn("flex flex-col items-center gap-0.5", isMega && "items-start")}>
<span className={cn(
"text-[10px] font-semibold leading-none",
isToday ? "text-[#2a1f04]" : "text-cream/55",
isMega && !isToday && "text-gold-400"
)}>
{meta.label === "special"
? t("daily.special")
: t("daily.day", { n: day })}
</span>
<span className={cn(
"flex items-center gap-1 font-black tabular-nums",
isMega ? "text-lg" : "text-sm",
isToday ? "text-[#2a1f04]" : "text-gold-300"
)}
>
{coins}
<Coins className="size-3" />
)}>
{coins.toLocaleString()}
<Coins className={cn("shrink-0", isMega ? "size-4" : "size-3")} />
</span>
</div>
</motion.div>
);
})}
</div>
{/* mega-day coin burst when claimed */}
{claimed != null && isMegaDay && MEGA_COINS.map(c => (
<motion.div
key={c.id}
initial={{ bottom: 60, opacity: 0, rotate: 0 }}
animate={{ bottom: "110%", opacity: [0, 1, 1, 0], rotate: 540 }}
transition={{ duration: 1.6 + c.delay * 0.2, delay: c.delay, ease: "easeOut" }}
className="absolute pointer-events-none text-2xl select-none"
style={{ left: `${c.left}%` }}
>
🪙
</motion.div>
))}
{/* Status row */}
{claimed != null ? (
<p className="mt-5 text-teal-300 font-bold flex items-center justify-center gap-1.5">
+{claimed} <Coins className="size-4 text-gold-400" /> {t("daily.claimed")}
</p>
<motion.p
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 260 }}
className="relative mt-5 text-teal-300 font-black text-base flex items-center justify-center gap-2"
>
🎉 +{claimed.toLocaleString()} <Coins className="size-4 text-gold-400" /> {t("daily.claimed")}
</motion.p>
) : state?.available ? (
<button onClick={claim} className="btn-gold w-full rounded-xl py-3 mt-5">
{t("daily.claim")}
</button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
onClick={claim}
className="relative press-3d btn-gold w-full rounded-xl py-3.5 mt-5 font-black text-base"
>
{t("daily.claim")} {DAY_META[(state.day ?? 1) - 1]?.icon}
</motion.button>
) : (
<p className="mt-5 text-cream/50 text-sm">{t("daily.come")}</p>
<p className="relative mt-5 text-cream/50 text-sm">{t("daily.come")}</p>
)}
<button
onClick={close}
className="mt-3 text-cream/50 text-sm hover:text-cream/80"
>
<button onClick={close} className="relative mt-3 text-cream/50 text-sm hover:text-cream/80">
{t("common.back")}
</button>
</motion.div>
+65
View File
@@ -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 (
<button
onClick={() => go("profile")}
className={cn(
"glass rounded-full ltr:pl-1.5 rtl:pr-1.5 ltr:pr-3 rtl:pl-3 py-1.5 flex items-center gap-2.5 active:scale-[0.98] transition",
className
)}
>
{showAvatar && (
<span className="relative size-8 rounded-full bg-navy-900 gold-border flex items-center justify-center overflow-hidden shrink-0">
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 32 : 22} />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 rounded-full bg-navy-950 gold-border px-1 text-[8px] font-black text-gold-300 leading-tight">
{profile.level}
</span>
</span>
)}
<span className="flex-1 min-w-[84px]">
<span className="flex items-center justify-between text-[10px] leading-none mb-1">
<span className="text-gold-300 font-bold">
{t("common.level")} {profile.level}
</span>
<span className="text-cream/45 tabular-nums">
{maxed ? "MAX" : `${profile.xp.toLocaleString()}/${need.toLocaleString()}`}
</span>
</span>
<span className="block h-1.5 rounded-full bg-navy-900 overflow-hidden">
<span
className="block h-full rounded-full bg-gradient-to-r from-gold-500 to-gold-300 transition-[width] duration-500"
style={{ width: `${pct}%` }}
/>
</span>
</span>
</button>
);
}
+24 -1
View File
@@ -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<Record<string, boolean>>({});
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,6 +38,25 @@ export function MatchPlayersList() {
const canAdd = !!p.id && !p.isBot && p.id !== myId;
return (
<div key={i} className="flex items-center gap-2.5 glass rounded-xl px-2.5 py-1.5">
{canAdd ? (
<button
onClick={() => viewProfile(p.id!)}
className="flex items-center gap-2.5 flex-1 min-w-0 text-start active:scale-[0.99] transition"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
{p.avatar}
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-semibold text-cream truncate">{p.name}</span>
{p.level > 0 && (
<span className="block text-[10px] text-cream/45">
{t("common.level")} {p.level}
</span>
)}
</span>
</button>
) : (
<>
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
{p.avatar}
</span>
@@ -51,6 +72,8 @@ export function MatchPlayersList() {
</span>
)}
</span>
</>
)}
{canAdd &&
(sent[p.id!] ? (
<span className="text-[11px] text-teal-300 flex items-center gap-1 shrink-0">
+130 -83
View File
@@ -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 <span className="tabular-nums">{n.toLocaleString()}</span>;
}
/* 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 (
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-3xl">
{COIN_SPECS.map(c => (
<motion.div
key={c.id}
initial={{ bottom: "-20px", opacity: 0, rotate: 0 }}
animate={{ bottom: "105%", opacity: [0, 1, 1, 0], rotate: 540 }}
transition={{ duration: 2 + c.delay * 0.4, delay: c.delay, ease: "easeOut" }}
className="absolute select-none"
style={{ left: `${c.left}%`, fontSize: c.size }}
>
🪙
</motion.div>
))}
</div>
);
}
export function PostMatchRewardsModal({
reward,
won,
@@ -50,61 +77,67 @@ export function PostMatchRewardsModal({
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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"
>
<motion.div
initial={{ scale: 0.85, y: 24 }}
initial={{ scale: 0.82, y: 28 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
className="glass rounded-3xl p-7 w-full max-w-sm text-center"
transition={{ type: "spring", stiffness: 200, damping: 18 }}
className="glass rounded-3xl p-7 w-full max-w-sm text-center relative overflow-hidden"
>
{/* radiating bg glow */}
<div
className="pointer-events-none absolute -inset-10 blur-3xl opacity-30"
style={{ background: won ? "radial-gradient(circle,#d4af37,transparent 65%)" : "radial-gradient(circle,#fb7185,transparent 65%)" }}
/>
{/* floating coins for wins */}
{won && reward.coinsDelta > 0 && <FloatingCoins />}
<motion.div
initial={{ scale: 0, rotate: -15 }}
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 160, delay: 0.1 }}
className="text-5xl mb-2"
transition={{ type: "spring", stiffness: 180, delay: 0.1 }}
className="text-6xl mb-2 relative"
>
{won ? "🏆" : "🎴"}
</motion.div>
<h2 className="gold-text text-2xl font-black">{t("reward.title")}</h2>
<p className={"mt-1 font-bold " + (won ? "text-teal-300" : "text-rose-300")}>
<h2 className="gold-text text-2xl font-black relative">{t("reward.title")}</h2>
<p className={"relative mt-1 font-bold text-lg " + (won ? "text-teal-300" : "text-rose-300")}>
{won ? t("reward.win") : t("reward.lose")}
</p>
{/* Coins-won hero (animated count-up) */}
{/* Hero coins count-up */}
{won && reward.coinsDelta > 0 && (
<motion.div
initial={{ scale: 0.6, opacity: 0 }}
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 170, damping: 14, delay: 0.15 }}
className="mt-4 flex items-center justify-center gap-2"
transition={{ type: "spring", stiffness: 200, damping: 14, delay: 0.18 }}
className="relative mt-4 flex items-center justify-center gap-2"
>
<span className="text-4xl font-black gold-text">
+<CountUp to={reward.coinsDelta} />
<span className="text-5xl font-black gold-text">
+<CountUp to={reward.coinsDelta} ms={1100} />
</span>
<motion.span
animate={{ rotate: [0, -12, 12, 0], y: [0, -4, 0] }}
transition={{ duration: 0.8, delay: 0.3 }}
animate={{ rotate: [0, -14, 14, -8, 0], y: [0, -6, 0] }}
transition={{ duration: 0.9, delay: 0.38 }}
>
<Coins className="size-8 text-gold-400" />
<Coins className="size-9 text-gold-400" />
</motion.span>
</motion.div>
)}
<div className="mt-5 space-y-2.5">
<div className="relative mt-5 space-y-2">
{reward.ratingDelta !== 0 && (
<RewardRow
icon={
reward.ratingDelta > 0 ? (
<TrendingUp className="size-4 text-teal-300" />
) : (
<TrendingDown className="size-4 text-rose-300" />
)
}
icon={reward.ratingDelta > 0
? <TrendingUp className="size-4 text-teal-300" />
: <TrendingDown className="size-4 text-rose-300" />}
label={t("reward.rating")}
value={sign(reward.ratingDelta)}
positive={reward.ratingDelta > 0}
delay={0.2}
delay={0.22}
/>
)}
<RewardRow
@@ -112,64 +145,90 @@ export function PostMatchRewardsModal({
label={t("reward.coins")}
value={sign(reward.coinsDelta)}
positive={reward.coinsDelta >= 0}
delay={0.3}
delay={0.32}
/>
<RewardRow
icon={<Star className="size-4 text-gold-400" />}
label={t("reward.xp")}
value={`+${reward.xpGained}`}
positive
delay={0.4}
delay={0.42}
/>
</div>
{/* XP bar fill animation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.55 }}
className="relative mt-3 h-2 rounded-full bg-navy-900 overflow-hidden"
>
<motion.div
initial={{ width: "0%" }}
animate={{ width: "100%" }}
transition={{ duration: 1.2, delay: 0.65, ease: "easeOut" }}
className="h-full rounded-full"
style={{ background: "linear-gradient(90deg,#d4af37,#f1da8a)" }}
/>
<Zap className="absolute right-1 top-1/2 -translate-y-1/2 size-2.5 text-gold-300" />
</motion.div>
{reward.leveledUp && (
<Banner delay={0.5} text={`${t("reward.levelUp")}${reward.levelAfter}`} />
<Banner delay={0.58} text={`${t("reward.levelUp")}${reward.levelAfter}`} color="gold" />
)}
{reward.promoted && (
<Banner delay={0.62} text={t("reward.promoted")} color="teal" />
)}
{reward.promoted && <Banner delay={0.55} text={t("reward.promoted")} />}
{reward.newAchievements.length > 0 && (
<div className="mt-4 space-y-2">
{reward.newAchievements.map((a, i) => (
<div className="relative mt-4 space-y-2">
{reward.newAchievements.map((a, i) => {
const pack = stickerPackForAchievement(a.id);
return (
<motion.div
key={a.id}
initial={{ opacity: 0, x: locale === "fa" ? 20 : -20 }}
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 + i * 0.12 }}
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
transition={{ delay: 0.68 + i * 0.12, type: "spring", stiffness: 200 }}
className="glass rounded-xl px-3 py-2.5 flex items-center gap-2.5 text-start"
>
<span className="text-xl">{a.icon}</span>
<span className="flex-1">
<span className="block text-[10px] text-gold-400">
{t("reward.newAchievement")}
</span>
<span className="block text-sm text-cream font-semibold">
<span className="text-2xl">{a.icon}</span>
<span className="flex-1 min-w-0">
<span className="block text-[10px] text-gold-400">{t("reward.newAchievement")}</span>
<span className="block text-sm font-semibold text-cream truncate">
{locale === "fa" ? a.nameFa : a.nameEn}
</span>
{pack && (
<span className="mt-0.5 flex items-center gap-1 text-[10px] text-teal-300">
{pack.stickers[0] && <Sticker id={pack.stickers[0]} size={14} />}
{t("reward.stickerUnlocked")}: {locale === "fa" ? pack.nameFa : pack.nameEn}
</span>
<span className="text-xs text-gold-300 flex items-center gap-1">
)}
</span>
<span className="text-xs text-gold-300 flex items-center gap-1 shrink-0 font-bold">
+{a.coinReward}
<Coins className="size-3" />
</span>
</motion.div>
))}
);
})}
</div>
)}
{reward.newTitles.length > 0 && (
<div className="mt-3 space-y-2">
<div className="relative mt-3 space-y-2">
{reward.newTitles.map((tt, i) => (
<motion.div
key={tt.id}
initial={{ opacity: 0, x: locale === "fa" ? 20 : -20 }}
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 + i * 0.12 }}
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
transition={{ delay: 0.76 + i * 0.12 }}
className="glass rounded-xl px-3 py-2.5 flex items-center gap-2.5 text-start"
>
<span className="text-xl">🏷</span>
<span className="text-2xl">🏷</span>
<span className="flex-1">
<span className="block text-[10px] text-gold-400">{t("reward.newTitle")}</span>
<span className="block text-sm text-cream font-semibold">
<span className="block text-sm font-semibold text-cream">
{locale === "fa" ? tt.nameFa : tt.nameEn}
</span>
</span>
@@ -180,7 +239,7 @@ export function PostMatchRewardsModal({
<MatchPlayersList />
<button onClick={onClose} className="press-3d btn-gold w-full rounded-xl py-3 mt-6">
<button onClick={onClose} className="relative press-3d btn-gold w-full rounded-xl py-3.5 mt-6 font-black text-base">
{t("reward.continue")}
</button>
</motion.div>
@@ -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 (
<motion.div
initial={{ opacity: 0, y: 8 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
className="glass rounded-xl px-4 py-2.5 flex items-center justify-between"
>
<span className="flex items-center gap-2 text-cream/80 text-sm">
{icon}
{label}
</span>
<span
className={
"font-black tabular-nums " + (positive ? "text-teal-300" : "text-rose-300")
}
>
<span className="flex items-center gap-2 text-cream/80 text-sm">{icon}{label}</span>
<span className={"font-black tabular-nums text-base " + (positive ? "text-teal-300" : "text-rose-300")}>
{value}
</span>
</motion.div>
);
}
function Banner({ text, delay }: { text: string; delay: number }) {
function Banner({ text, delay, color }: { text: string; delay: number; color: "gold" | "teal" }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, delay }}
className="mt-4 inline-flex items-center gap-2 btn-gold rounded-full px-5 py-1.5 font-black"
initial={{ scale: 0, rotate: -5 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 260, delay }}
className={
"relative mt-4 inline-flex items-center gap-2 rounded-full px-5 py-1.5 font-black " +
(color === "gold" ? "btn-gold" : "bg-teal-500/25 text-teal-200 border border-teal-500/40")
}
>
<Sparkles className="size-4" />
{text}
@@ -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<PublicProfile | null>(null);
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState<AchievementCategoryId>("victory");
const [send, setSend] = useState<SendState>("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 (
<AnimatePresence>
{userId && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={close}
className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center bg-navy-950/85 backdrop-blur-sm sm:p-5"
>
<motion.div
initial={{ y: 60, scale: 0.96, opacity: 0 }}
animate={{ y: 0, scale: 1, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
transition={{ type: "spring", stiffness: 240, damping: 24 }}
onClick={(e) => 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 */}
<button
onClick={close}
className="absolute top-3 ltr:right-3 rtl:left-3 z-10 glass rounded-full p-1.5 hover:bg-navy-800 transition"
>
<X className="size-4 text-cream/70" />
</button>
{loading || !profile ? (
<div className="h-64 grid place-items-center">
<Loader2 className="size-8 text-gold-400 animate-spin" />
</div>
) : (
<>
{/* identity */}
<div className="text-center relative">
<div className="pointer-events-none absolute -top-6 left-1/2 -translate-x-1/2 size-32 bg-gold-500/10 blur-3xl rounded-full" />
<div className="relative size-20 mx-auto">
<div className="size-20 rounded-2xl bg-navy-900 gold-border flex items-center justify-center overflow-hidden">
<Avatar
id={profile.avatar}
image={profile.avatarImage}
size={profile.avatarImage ? 80 : 56}
/>
</div>
<span className="absolute -top-2 ltr:-left-2 rtl:-right-2 inline-flex items-center gap-0.5 rounded-full btn-gold px-2 py-0.5 text-[10px] font-black shadow-lg">
{t("common.level")} {profile.level}
</span>
</div>
{titleName && <div className="mt-2 text-xs font-bold text-gold-300">{titleName}</div>}
<h2 className="mt-1.5 text-xl font-black text-cream flex items-center justify-center gap-1.5">
{profile.displayName}
{profile.gender && GENDER_META[profile.gender] && (
<span
className="text-lg"
style={{ color: GENDER_META[profile.gender].color }}
title={locale === "fa" ? GENDER_META[profile.gender].faLabel : GENDER_META[profile.gender].enLabel}
>
{GENDER_META[profile.gender].symbol}
</span>
)}
{profile.plan === "pro" && (
<Crown className="size-4 text-gold-400 fill-gold-500" />
)}
</h2>
<div className="mt-2 flex items-center justify-center">
<RankBadge rating={profile.rating} showRating />
</div>
{/* social links (only present when the owner allows it) */}
{hasSocials(profile.socials) && (
<div className="mt-3 flex flex-wrap items-center justify-center gap-2">
{SOCIAL_PLATFORMS.map((p) => {
const val = profile.socials?.[p.key]?.trim();
if (!val) return null;
return (
<a
key={p.key}
href={socialUrl(p.key, val)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-full bg-navy-900/70 gold-border px-2.5 py-1 text-[11px] font-semibold text-cream/85 hover:bg-navy-800 transition"
>
<span style={{ color: p.color }}>{p.icon}</span>
{p.label}
</a>
);
})}
</div>
)}
</div>
{/* friend action */}
{!profile.isYou && (
<div className="mt-4">
{profile.isFriend ? (
<div className="rounded-xl bg-teal-500/15 border border-teal-500/30 text-teal-200 py-2.5 text-center text-sm font-bold flex items-center justify-center gap-1.5">
<Check className="size-4" />
{t("profile.alreadyFriend")}
</div>
) : send === "sent" ? (
<div className="rounded-xl bg-navy-900/70 gold-border text-gold-300 py-2.5 text-center text-sm font-bold flex items-center justify-center gap-1.5">
<Clock className="size-4" />
{t("profile.requestSent")}
</div>
) : (
<>
<button
onClick={sendRequest}
disabled={send === "sending"}
className="press-3d btn-gold w-full rounded-xl py-3 font-black flex items-center justify-center gap-2 disabled:opacity-70"
>
{send === "sending" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
{t("profile.sendRequest")}
</button>
{send === "error" && sendMsg && (
<p className="mt-2 text-rose-300 text-xs text-center">{sendMsg}</p>
)}
</>
)}
</div>
)}
{/* stats */}
<div className="mt-4 grid grid-cols-3 gap-2">
<Stat label={t("profile.games")} value={profile.stats.games} />
<Stat label={t("profile.wins")} value={profile.stats.wins} />
<Stat
label={t("profile.winrate")}
value={`${profile.stats.games > 0 ? Math.round((profile.stats.wins / profile.stats.games) * 100) : 0}%`}
/>
<Stat label={t("profile.kots")} value={profile.stats.kotsFor} />
<Stat label={t("profile.streak")} value={profile.stats.bestWinStreak} />
<Stat label={t("common.rating")} value={Math.round(profile.rating)} />
</div>
{/* achievement board */}
<div className="mt-5 flex items-center justify-between">
<h3 className="text-sm font-bold text-cream/80">{t("achv.title")}</h3>
<span className="text-xs text-gold-300 font-bold">
{unlockedCount}/{ACHIEVEMENTS.length}
</span>
</div>
<div className="flex gap-1.5 overflow-x-auto pb-2 mt-2 -mx-1 px-1">
{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 (
<button
key={c.id}
onClick={() => setTab(c.id)}
className={cn(
"shrink-0 rounded-full px-3 py-1.5 text-xs font-bold transition flex items-center gap-1",
active ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70"
)}
>
<span>{c.icon}</span>
<span className={cn("text-[10px]", active ? "text-[#2a1f04]/70" : "text-cream/40")}>
{done}/{total}
</span>
</button>
);
})}
</div>
<div className="grid grid-cols-4 gap-2 mt-1">
{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 (
<div
key={a.id}
title={locale === "fa" ? a.nameFa : a.nameEn}
className={cn(
"aspect-square rounded-xl flex flex-col items-center justify-center gap-1 border p-1 relative",
unlocked
? "bg-gold-500/15 border-gold-500/45"
: "bg-navy-900/50 border-navy-700/50"
)}
>
<span className={cn("text-2xl", !unlocked && "grayscale opacity-40")}>
{a.icon}
</span>
{unlocked ? (
<Check className="size-3 text-gold-400" />
) : (
<span className="text-[8px] text-cream/40 tabular-nums">{pct}%</span>
)}
</div>
);
})}
</div>
<div className="mt-3 flex items-center justify-center gap-1 text-[11px] text-cream/45">
<Coins className="size-3 text-gold-400/70" />
{t("profile.memberSince")}{" "}
{new Date(profile.createdAt).toLocaleDateString(locale === "fa" ? "fa-IR" : "en-US")}
</div>
</>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-navy-900/60 rounded-xl py-2.5 text-center">
<div className="text-lg font-black gold-text tabular-nums">{value}</div>
<div className="text-[10px] text-cream/55 mt-0.5">{label}</div>
</div>
);
}
+8 -1
View File
@@ -3,21 +3,26 @@
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 (
<div className="flex items-center justify-between gap-3 mb-5">
<>
<div className="flex items-center justify-between gap-3 mb-3">
<button
onClick={() => navBack(back)}
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
@@ -27,6 +32,8 @@ export function ScreenHeader({
<h1 className="gold-text text-2xl font-black">{title}</h1>
<div className="min-w-10 flex justify-end">{right}</div>
</div>
{showXp && <LevelXpBar className="w-full mb-5" />}
</>
);
}
+115
View File
@@ -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 (
<>
<rect x="4" y="26" width="92" height="48" rx="14" fill={bg} stroke={ring} strokeWidth="3" transform={`rotate(${rot} 50 50)`} />
<text
x="50"
y="58"
textAnchor="middle"
fontFamily="Vazirmatn, sans-serif"
fontWeight="900"
fontSize={fs}
fill={fg}
transform={`rotate(${rot} 50 50)`}
>
{text}
</text>
</>
);
}
/** A mini playing card (court/ace) sticker. */
function CourtCard({ corner, center, color = "#1b1b1b" }: { corner: string; center: string; color?: string }) {
return (
<>
<rect x="24" y="12" width="52" height="76" rx="8" fill="#fffdf7" stroke="#caa84a" strokeWidth="2.5" />
<text x="34" y="31" textAnchor="middle" fontSize="13" fontWeight="800" fill={color}>{corner}</text>
<text x="50" y="60" textAnchor="middle" fontSize="30" fill={color}>{center}</text>
<text x="66" y="82" textAnchor="middle" fontSize="13" fontWeight="800" fill={color} transform="rotate(180 66 76)">{corner}</text>
</>
);
}
const STICKERS: Record<string, React.ReactNode> = {
/* ----------------------------- faces ----------------------------- */
happy: (
@@ -262,6 +309,74 @@ const STICKERS: Record<string, React.ReactNode> = {
<text x="50" y="59" textAnchor="middle" fontFamily="Vazirmatn, sans-serif" fontWeight="900" fontSize="26" fill="#daffe4" transform="rotate(6 50 50)">بردیم!</text>
</>
),
/* ===================== کل‌کل / banter (text stamps) ===================== */
sukhti: <Stamp text="سوختی!" bg="#7a0f1a" ring="#ff6b81" fg="#ffd9de" rot={-8} fs={26} />,
"yad-begir": <Stamp text="یاد بگیر" bg="#3a2a4d" ring="#c77dff" fg="#e7d4ff" fs={22} />,
"nobate-man": <Stamp text="نوبت منه" bg="#0d4d6b" ring="#5ac8e0" fg="#d8f4ff" fs={22} />,
"naz-nakon": <Stamp text="ناز نکن" bg="#5a3c0a" ring="#ffd76a" fg="#ffe9a8" fs={23} />,
kojai: <Stamp text="کجایی؟!" bg="#4d2a0a" ring="#ffae5a" fg="#ffe2c2" rot={-6} fs={24} />,
"hool-nasho": <Stamp text="هول نشو" bg="#0d6b5e" ring="#2dd4bf" fg="#d8fff5" fs={22} />,
"didi-goftam": <Stamp text="دیدی گفتم؟" bg="#3a0a2a" ring="#ff5fa2" fg="#ffd6ea" fs={19} />,
"bendaz-dige": <Stamp text="بنداز دیگه!" bg="#1f2b4d" ring="#6aa6ff" fg="#dbe8ff" rot={5} fs={20} />,
"nakon-eddea": <Stamp text="ادعا نکن" bg="#4d0f1a" ring="#ff6b81" fg="#ffd9de" fs={22} />,
"shans-avordi": <Stamp text="شانس آوردی" bg="#3a2a04" ring="#e6c659" fg="#fff0c2" fs={18} />,
"biya-bebin": <Stamp text="بیا ببین!" bg="#0a3a4d" ring="#5ac8e0" fg="#d8f4ff" rot={-5} fs={23} />,
"kart-nadari": <Stamp text="کارت نداری" bg="#2a1a4d" ring="#a98bff" fg="#e7d4ff" fs={19} />,
/* ===================== Persian trends / praise ===================== */
eyval: <Stamp text="ایول!" bg="#136f3a" ring="#7fe3a0" fg="#daffe4" rot={-7} fs={26} />,
torkundi: <Stamp text="ترکوندی" bg="#7a3a00" ring="#ff9d3a" fg="#ffe0bf" fs={22} />,
"gol-kashti": <Stamp text="گل کاشتی" bg="#0d5a3a" ring="#4fe39a" fg="#d6ffe8" fs={21} />,
"harf-nadari": <Stamp text="حرف نداری" bg="#3a2a04" ring="#e6c659" fg="#fff0c2" fs={20} />,
"damet-garm-2": <Stamp text="دمت گرم" bg="#0d6b5e" ring="#2dd4bf" fg="#d8fff5" rot={4} fs={22} />,
"nush-jan": <Stamp text="نوش جان" bg="#5a3c0a" ring="#ffd76a" fg="#ffe9a8" fs={22} />,
"be-be": <Stamp text="به‌به!" bg="#3a0a2a" ring="#ff5fa2" fg="#ffd6ea" rot={-6} fs={26} />,
ghorbunet: <Stamp text="قربونت" bg="#4d1f3a" ring="#ff8fc2" fg="#ffd9ec" fs={22} />,
/* ===================== Victory / closers ===================== */
"jam-kon": <Stamp text="جمع کن!" bg="#4d0f1a" ring="#ff6b81" fg="#ffd9de" rot={7} fs={23} />,
"kish-mat": <Stamp text="کیش و مات" bg="#13314d" ring="#d4af37" fg="#ffe488" fs={20} />,
khdahafez: <Stamp text="خداحافظ" bg="#1f2b4d" ring="#8fb4ff" fg="#dbe8ff" rot={-5} fs={21} />,
/* ===================== Court cards (Hokm) ===================== */
"tak-khal": <CourtCard corner="A" center="♠" color="#1b1b1b" />,
"as-del": <CourtCard corner="A" center="♥" color="#d11a2a" />,
"shah-khesht": <CourtCard corner="K" center="♦" color="#d11a2a" />,
"bibi-gesht": <CourtCard corner="Q" center="♣" color="#1b1b1b" />,
/* ===================== Extra emotions ===================== */
laugh: (
<Face bg1="#ffe680" bg2="#f5b301">
<path d="M30 42 q6 6 12 0 M58 42 q6 6 12 0" fill="none" stroke="#3a2a00" strokeWidth="4" strokeLinecap="round" />
<path d="M30 58 Q50 84 70 58 Z" fill="#7a0b16" />
<path d="M30 58 Q50 84 70 58" fill="none" stroke="#3a2a00" strokeWidth="4" strokeLinejoin="round" />
<path d="M24 64 q-6 6 -4 12 M76 64 q6 6 4 12" stroke="#5aa6e0" strokeWidth="3" fill="none" strokeLinecap="round" />
</Face>
),
shocked: (
<Face bg1="#bfe3ff" bg2="#5aa6e0">
<circle cx="36" cy="44" r="7" fill="#fff" stroke="#13314d" strokeWidth="2.5" />
<circle cx="64" cy="44" r="7" fill="#fff" stroke="#13314d" strokeWidth="2.5" />
<circle cx="36" cy="44" r="3" fill="#13314d" />
<circle cx="64" cy="44" r="3" fill="#13314d" />
<ellipse cx="50" cy="68" rx="8" ry="11" fill="#13314d" />
</Face>
),
cry: (
<Face bg1="#bfe3ff" bg2="#5aa6e0">
<path d="M30 44 q6 -5 12 0 M58 44 q6 -5 12 0" fill="none" stroke="#13314d" strokeWidth="4" strokeLinecap="round" />
<path d="M34 72 Q50 60 66 72" fill="none" stroke="#13314d" strokeWidth="5" strokeLinecap="round" />
<path d="M34 52 q-3 12 0 20 q3 -8 0 -20" fill="#2f8fd6" />
<path d="M66 52 q-3 12 0 20 q3 -8 0 -20" fill="#2f8fd6" />
</Face>
),
smug: (
<Face bg1="#ffe680" bg2="#f5b301">
<path d="M30 44 h14 M56 44 h14" stroke="#3a2a00" strokeWidth="4" strokeLinecap="round" />
<path d="M34 62 Q50 70 64 58" fill="none" stroke="#3a2a00" strokeWidth="5" strokeLinecap="round" />
</Face>
),
};
export const STICKER_IDS = Object.keys(STICKERS);
+15 -3
View File
@@ -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 (
<div className="flex items-center justify-between gap-3">
<button
@@ -24,13 +29,20 @@ export function TopBar() {
<span className="relative size-9 rounded-full bg-navy-900 gold-border flex items-center justify-center overflow-hidden">
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 36 : 26} />
</span>
<span className="text-start leading-tight">
<span className="text-start leading-tight min-w-[96px]">
<span className="flex items-center gap-1 text-sm font-bold text-cream max-w-28 truncate">
{profile.displayName}
{profile.plan === "pro" && <Crown className="size-3 text-gold-400 fill-gold-500 shrink-0" />}
</span>
<span className="block text-[10px] text-gold-400/80">
{t("common.level")} {profile.level}
<span className="flex items-center justify-between text-[10px] text-gold-400/80 mt-0.5">
<span>{t("common.level")} {profile.level}</span>
<span className="text-cream/40 tabular-nums">{maxed ? "MAX" : `${xpPct}%`}</span>
</span>
<span className="mt-0.5 block h-1 w-full rounded-full bg-navy-900 overflow-hidden">
<span
className="block h-full rounded-full bg-gradient-to-r from-gold-500 to-gold-300"
style={{ width: `${xpPct}%` }}
/>
</span>
</span>
</button>
+46 -11
View File
@@ -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 (
<div className="w-full">
<div className="flex items-center justify-between text-[10px] text-cream/55 mb-1">
<span>
<div className="flex items-center justify-between text-[11px] mb-1.5">
<span className="flex items-center gap-1 font-bold text-gold-300">
{showBadge && (
<span className="inline-flex items-center justify-center size-4 rounded-full bg-gold-500/20">
<Star className="size-2.5 text-gold-400" fill="currentColor" />
</span>
)}
{t("common.level")} {level}
</span>
<span className="tabular-nums">
{xp} / {need} XP
<span className="tabular-nums text-cream/60">
{xp.toLocaleString()} / {need.toLocaleString()} XP
</span>
</div>
<div className="h-2.5 rounded-full bg-navy-900/80 overflow-hidden gold-border">
<div
className="h-full rounded-full transition-all"
style={{
width: `${pct}%`,
background: "linear-gradient(90deg, var(--gold-500), var(--gold-300))",
}}
className={cn(
"relative rounded-full bg-navy-900/80 overflow-hidden gold-border",
showBadge ? "h-3.5" : "h-2.5"
)}
>
<motion.div
className="h-full rounded-full relative"
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.9, ease: "easeOut" }}
style={{ background: "linear-gradient(90deg, var(--gold-600), var(--gold-400), var(--gold-300))" }}
>
{/* glossy sheen */}
<span className="absolute inset-x-0 top-0 h-1/2 rounded-full bg-white/25" />
</motion.div>
{/* animated shimmer sweep */}
{pct > 0 && pct < 100 && (
<motion.span
className="absolute top-0 h-full w-8 bg-white/30 blur-sm"
initial={{ left: "-10%" }}
animate={{ left: "110%" }}
transition={{ duration: 1.6, repeat: Infinity, repeatDelay: 1.4, ease: "easeInOut" }}
/>
)}
</div>
</div>
);
+68 -4
View File
@@ -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<CoinPack[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [gained, setGained] = useState<number | null>(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() {
</div>
)}
{msg && (
<div className="mb-4 text-center text-cream/80 text-sm glass rounded-xl py-2">{msg}</div>
)}
<div className="grid grid-cols-2 gap-3 pb-6">
{packs.map((p) => (
<button
+21 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight, Send } from "lucide-react";
import { ChevronLeft, ChevronRight, MessageCircle, Send } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useOnlineStore } from "@/lib/online-store";
import { useSessionStore } from "@/lib/session-store";
@@ -18,6 +18,7 @@ export function ChatScreen() {
const closeChat = useOnlineStore((s) => s.closeChat);
const isPro = useSessionStore((s) => s.profile?.plan === "pro");
const navBack = useUIStore((s) => s.back);
const viewProfile = useUIStore((s) => s.viewProfile);
const [text, setText] = useState("");
const endRef = useRef<HTMLDivElement>(null);
const prevLen = useRef(0);
@@ -51,11 +52,19 @@ export function ChatScreen() {
return (
<main className="persian-pattern relative h-dvh w-full flex flex-col">
{/* header */}
<header className="glass flex items-center gap-3 p-3 shrink-0 z-10">
<button onClick={back} className="rounded-full p-2 hover:bg-navy-800/80 transition">
<header className="glass flex items-center gap-2 p-3 shrink-0 z-10 safe-top">
<button
onClick={back}
className="tap grid place-items-center rounded-full hover:bg-navy-800/80 transition"
aria-label={t("common.back")}
>
<Chevron className="size-5 text-cream/80" />
</button>
<span className="text-2xl">{avatarEmoji(friend.avatar)}</span>
<button
onClick={() => viewProfile(friend.id)}
className="flex items-center gap-3 min-w-0 flex-1 text-start active:scale-[0.99] transition"
>
<span className="text-2xl shrink-0">{avatarEmoji(friend.avatar)}</span>
<div className="min-w-0">
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
<div className="text-[11px] text-teal-300">
@@ -66,12 +75,18 @@ export function ChatScreen() {
: t("friends.offline")}
</div>
</div>
</button>
</header>
{/* messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.length === 0 && (
<p className="text-center text-cream/40 mt-16">{t("chat.empty")}</p>
<div className="flex flex-col items-center text-center mt-20 gap-3">
<span className="grid size-16 place-items-center rounded-2xl bg-navy-900/60 gold-border">
<MessageCircle className="size-7 text-gold-400/70" />
</span>
<p className="text-cream/45 text-sm">{t("chat.empty")}</p>
</div>
)}
{messages.map((m) => (
<div
@@ -100,7 +115,7 @@ export function ChatScreen() {
/>
<button
onClick={send}
className="btn-gold rounded-full p-3 shrink-0"
className="btn-gold tap grid place-items-center rounded-full shrink-0"
aria-label={t("chat.send")}
>
<Send className="size-4 rtl:-scale-x-100" />
+327 -96
View File
@@ -1,12 +1,34 @@
"use client";
import { Check, MessageCircle, UserMinus, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Check,
Clock,
Loader2,
MessageCircle,
Search,
Sparkles,
UserMinus,
UserPlus,
Users,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { Avatar } from "@/components/online/Avatar";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { Friend, PresenceStatus, avatarEmoji } from "@/lib/online/types";
import { getService } from "@/lib/online/service";
import {
Conversation,
Friend,
PlayerSummary,
PresenceStatus,
avatarEmoji,
} from "@/lib/online/types";
import { GENDER_META } from "@/lib/social";
import { titleById } from "@/lib/online/gamification";
import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = {
@@ -15,74 +37,103 @@ const STATUS_COLOR: Record<PresenceStatus, string> = {
"in-game": "bg-gold-400",
};
type Tab = "friends" | "discover" | "messages";
export function FriendsScreen() {
const { t, locale } = useI18n();
const friends = useOnlineStore((s) => s.friends);
const { t } = useI18n();
const requests = useOnlineStore((s) => s.requests);
const load = useOnlineStore((s) => s.loadFriends);
const addFriend = useOnlineStore((s) => s.addFriend);
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 [query, setQuery] = useState("");
const [confirmId, setConfirmId] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("friends");
useEffect(() => {
load();
}, [load]);
return (
<ScreenShell>
<ScreenHeader title={t("social.title")} />
{/* tabs */}
<div className="glass rounded-2xl p-1 flex gap-1 mb-4">
<TabButton active={tab === "friends"} onClick={() => setTab("friends")} icon={<Users className="size-4" />} label={t("social.tabFriends")} badge={requests.length} />
<TabButton active={tab === "discover"} onClick={() => setTab("discover")} icon={<Search className="size-4" />} label={t("social.tabDiscover")} />
<TabButton active={tab === "messages"} onClick={() => setTab("messages")} icon={<MessageCircle className="size-4" />} label={t("social.tabMessages")} />
</div>
<AnimatePresence mode="wait">
<motion.div
key={tab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
{tab === "friends" && <FriendsTab />}
{tab === "discover" && <DiscoverTab />}
{tab === "messages" && <MessagesTab />}
</motion.div>
</AnimatePresence>
</ScreenShell>
);
}
function TabButton({
active, onClick, icon, label, badge,
}: {
active: boolean; onClick: () => void; icon: React.ReactNode; label: string; badge?: number;
}) {
return (
<button
onClick={onClick}
className={cn(
"relative flex-1 rounded-xl py-2 text-xs font-bold transition flex items-center justify-center gap-1.5",
active ? "btn-gold" : "text-cream/60 hover:text-cream"
)}
>
{icon}
<span className="truncate">{label}</span>
{!!badge && badge > 0 && (
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white grid place-items-center">
{badge > 9 ? "9+" : badge}
</span>
)}
</button>
);
}
/* ------------------------------ 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<string | null>(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 (
<ScreenShell>
<ScreenHeader title={t("friends.title")} />
{/* add */}
<div className="glass rounded-2xl p-3 flex gap-2">
<input
value={query}
onChange={(e) => 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"
/>
<button onClick={add} className="btn-gold rounded-xl px-4 flex items-center gap-1.5">
<UserPlus className="size-4" />
{t("friends.add")}
</button>
</div>
{/* requests */}
<>
{requests.length > 0 && (
<div className="mt-4">
<div className="mb-4">
<h3 className="text-xs text-cream/55 mb-2">{t("friends.requests")}</h3>
<div className="space-y-2">
{requests.map((r) => (
<div key={r.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<span className="text-2xl">{avatarEmoji(r.from.avatar)}</span>
<span className="flex-1 text-sm font-semibold text-cream">
{r.from.displayName}
</span>
<button
onClick={() => accept(r.id)}
className="size-8 rounded-lg bg-teal-600/80 flex items-center justify-center hover:bg-teal-600"
>
<button onClick={() => viewProfile(r.from.id)} className="text-2xl active:scale-95 transition">
{avatarEmoji(r.from.avatar)}
</button>
<span className="flex-1 text-sm font-semibold text-cream">{r.from.displayName}</span>
<button onClick={() => accept(r.id)} className="size-8 rounded-lg bg-teal-600/80 grid place-items-center hover:bg-teal-600">
<Check className="size-4 text-white" />
</button>
<button
onClick={() => decline(r.id)}
className="size-8 rounded-lg bg-rose-700/70 flex items-center justify-center hover:bg-rose-700"
>
<button onClick={() => decline(r.id)} className="size-8 rounded-lg bg-rose-700/70 grid place-items-center hover:bg-rose-700">
<X className="size-4 text-white" />
</button>
</div>
@@ -91,67 +142,36 @@ export function FriendsScreen() {
</div>
)}
{/* list */}
<div className="mt-4 space-y-2 pb-6">
{friends.length === 0 && (
<p className="text-center text-cream/40 py-10">{t("friends.empty")}</p>
)}
<div className="space-y-2 pb-6">
{friends.length === 0 && <EmptyState icon={<Users className="size-7 text-gold-400/70" />} text={t("friends.empty")} />}
{friends.map((f: Friend) => (
<div key={f.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<div className="relative">
<button onClick={() => viewProfile(f.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
<div className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span
className={cn(
"absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900",
STATUS_COLOR[f.status]
)}
/>
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate">{f.displayName}</div>
<div className="text-[11px] text-cream/45">
{statusLabel(f.status)} · {t("common.level")} {f.level}
<div className="text-[11px] text-cream/45">{statusLabel(f.status)} · {t("common.level")} {f.level}</div>
</div>
</div>
<span className="text-[11px] text-gold-300/80">{Math.round(f.rating)}</span>
</button>
{confirmId === f.id ? (
<>
<span className="text-[11px] text-cream/70">{t("friends.removeQ")}</span>
<button
onClick={() => {
remove(f.id);
setConfirmId(null);
}}
className="size-8 rounded-lg bg-rose-700/70 flex items-center justify-center hover:bg-rose-700"
title={t("common.yes")}
>
<button onClick={() => { remove(f.id); setConfirmId(null); }} className="size-8 rounded-lg bg-rose-700/70 grid place-items-center hover:bg-rose-700">
<Check className="size-4 text-white" />
</button>
<button
onClick={() => setConfirmId(null)}
className="size-8 rounded-lg bg-navy-700/70 flex items-center justify-center hover:bg-navy-700"
title={t("common.no")}
>
<button onClick={() => setConfirmId(null)} className="size-8 rounded-lg bg-navy-700/70 grid place-items-center hover:bg-navy-700">
<X className="size-4 text-cream/80" />
</button>
</>
) : (
<>
<button
onClick={async () => {
await openChat(f);
go("chat");
}}
className="size-8 rounded-lg hover:bg-teal-700/40 flex items-center justify-center text-teal-300/80 hover:text-teal-200"
title={t("friends.message")}
>
<button onClick={async () => { await openChat(f); go("chat"); }} className="size-8 rounded-lg hover:bg-teal-700/40 grid place-items-center text-teal-300/80 hover:text-teal-200" title={t("friends.message")}>
<MessageCircle className="size-4" />
</button>
<button
onClick={() => setConfirmId(f.id)}
className="size-8 rounded-lg hover:bg-navy-800 flex items-center justify-center text-cream/35 hover:text-cream/70"
title={t("friends.remove")}
>
<button onClick={() => setConfirmId(f.id)} className="size-8 rounded-lg hover:bg-navy-800 grid place-items-center text-cream/35 hover:text-cream/70" title={t("friends.remove")}>
<UserMinus className="size-4" />
</button>
</>
@@ -159,7 +179,218 @@ export function FriendsScreen() {
</div>
))}
</div>
<span className="sr-only">{locale}</span>
</ScreenShell>
</>
);
}
/* ------------------------------ Discover tab ----------------------------- */
function DiscoverTab() {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [results, setResults] = useState<PlayerSummary[] | null>(null);
const [suggested, setSuggested] = useState<PlayerSummary[] | null>(null);
const [loading, setLoading] = useState(false);
const debounce = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="pb-6">
{/* search */}
<div className="glass rounded-2xl p-2 flex items-center gap-2 mb-4">
<Search className="size-4 text-cream/40 ms-2 shrink-0" />
<input
value={query}
onChange={(e) => 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 && (
<button onClick={() => setQuery("")} className="grid size-7 place-items-center rounded-full hover:bg-navy-800/80">
<X className="size-4 text-cream/50" />
</button>
)}
</div>
<h3 className="text-xs text-cream/55 mb-2 flex items-center gap-1.5">
{results ? <Search className="size-3.5" /> : <Sparkles className="size-3.5 text-gold-400" />}
{results ? t("discover.results") : t("discover.suggested")}
</h3>
{loading && <div className="grid place-items-center py-8"><Loader2 className="size-6 text-gold-400 animate-spin" /></div>}
{!loading && list && list.length === 0 && (
<EmptyState icon={<Search className="size-7 text-gold-400/70" />} text={t("discover.noResults")} />
)}
<div className="space-y-2">
{!loading && list?.map((p) => <DiscoverRow key={p.id} player={p} />)}
</div>
</div>
);
}
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 (
<div className="glass rounded-xl p-2.5 flex items-center gap-3">
<button onClick={() => viewProfile(player.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
<div className="relative shrink-0 size-10 rounded-xl bg-navy-900 gold-border grid place-items-center overflow-hidden">
<Avatar id={player.avatar} image={player.avatarImage} size={player.avatarImage ? 40 : 26} />
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[player.status])} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate flex items-center gap-1.5">
{player.displayName}
{player.gender && GENDER_META[player.gender] && (
<span className="text-xs" style={{ color: GENDER_META[player.gender].color }}>
{GENDER_META[player.gender].symbol}
</span>
)}
</div>
{(() => {
const td = titleById(player.title);
return td ? (
<div className="text-[10px] font-bold gold-text leading-tight truncate">
{locale === "fa" ? td.nameFa : td.nameEn}
</div>
) : null;
})()}
<div className="text-[11px] text-cream/45">{t("common.level")} {player.level} · {Math.round(player.rating)}</div>
</div>
</button>
{state === "friend" ? (
<span className="text-[11px] text-teal-300 flex items-center gap-1 shrink-0 pe-1"><Check className="size-3.5" />{t("discover.friend")}</span>
) : state === "sent" ? (
<span className="text-[11px] text-gold-300 flex items-center gap-1 shrink-0 pe-1"><Clock className="size-3.5" />{t("profile.requestSent")}</span>
) : (
<button
onClick={add}
disabled={state === "sending"}
title={err}
className={cn(
"press-3d rounded-lg px-2.5 py-1.5 text-[11px] font-bold flex items-center gap-1 shrink-0",
state === "error" ? "bg-rose-500/80 text-white" : "btn-gold"
)}
>
{state === "sending" ? <Loader2 className="size-3.5 animate-spin" /> : <UserPlus className="size-3.5" />}
{state === "error" ? t("common.retry") : t("friends.add")}
</button>
)}
</div>
);
}
/* ------------------------------ Messages tab ----------------------------- */
function MessagesTab() {
const { t } = useI18n();
const openChat = useOnlineStore((s) => s.openChat);
const go = useUIStore((s) => s.go);
const [convs, setConvs] = useState<Conversation[] | null>(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 <div className="grid place-items-center py-10"><Loader2 className="size-6 text-gold-400 animate-spin" /></div>;
return (
<div className="space-y-2 pb-6">
{convs.length === 0 && <EmptyState icon={<MessageCircle className="size-7 text-gold-400/70" />} text={t("messages.empty")} />}
{convs.map((c) => (
<button key={c.friend.id} onClick={() => open(c)} className="w-full glass rounded-xl p-2.5 flex items-center gap-3 text-start active:scale-[0.99] transition">
<div className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(c.friend.avatar)}</span>
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[c.friend.status])} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-cream truncate">{c.friend.displayName}</span>
{c.lastMessage && <span className="text-[10px] text-cream/40 shrink-0">{timeAgo(c.lastMessage.ts)}</span>}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-cream/50 truncate">
{c.lastMessage ? (c.lastMessage.fromMe ? `${t("messages.you")}: ` : "") + c.lastMessage.text : t("chat.empty")}
</span>
{c.unread > 0 && (
<span className="min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white grid place-items-center shrink-0">
{c.unread > 9 ? "9+" : c.unread}
</span>
)}
</div>
</div>
</button>
))}
</div>
);
}
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="flex flex-col items-center text-center py-12 gap-3">
<span className="grid size-16 place-items-center rounded-2xl bg-navy-900/60 gold-border">{icon}</span>
<p className="text-cream/45 text-sm">{text}</p>
</div>
);
}
+27
View File
@@ -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();
}}
/>
+23 -3
View File
@@ -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 (
<ScreenShell>
<ScreenHeader title={t("lead.title")} />
{leaderboard.length === 0 && (
<div className="space-y-1.5 pb-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="glass rounded-xl p-2.5 flex items-center gap-2.5 animate-pulse">
<span className="size-6 rounded bg-navy-800/80" />
<span className="size-10 rounded-xl bg-navy-800/80 shrink-0" />
<span className="flex-1 space-y-1.5">
<span className="block h-3 w-2/5 rounded bg-navy-800/80" />
<span className="block h-1.5 w-full rounded bg-navy-800/60" />
</span>
<span className="h-5 w-14 rounded-full bg-navy-800/80" />
</div>
))}
</div>
)}
<div className="space-y-1.5 pb-6">
{leaderboard.map((e) => (
<div
<button
key={e.id}
onClick={() => 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() {
</div>
<RankBadge rating={e.rating} showRating />
</div>
</button>
))}
</div>
</ScreenShell>
+20 -1
View File
@@ -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")}
</h1>
{searching && (
<>
<div className="mt-2 text-3xl font-black gold-text tabular-nums">{elapsed}s</div>
<p className="text-cream/50 text-xs mt-1 max-w-[16rem]">{t("mm.fillHint")}</p>
</>
)}
<div className="grid grid-cols-4 gap-3 mt-8">
{slots.map((i) => {
const p = mm.players[i];
@@ -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() {
<ScreenShell>
<ScreenHeader title={t("notif.title")} />
{items.length === 0 && (
<p className="text-center text-cream/40 py-16">{t("notif.empty")}</p>
<div className="flex flex-col items-center text-center py-16 gap-3">
<span className="grid size-16 place-items-center rounded-2xl bg-navy-900/60 gold-border">
<BellOff className="size-7 text-gold-400/70" />
</span>
<p className="text-cream/45 text-sm">{t("notif.empty")}</p>
</div>
)}
<div className="space-y-2 pb-6">
{items.map((n) => (
+157 -19
View File
@@ -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() {
<ScreenHeader title={t("profile.title")} />
{/* identity */}
<div className="glass rounded-3xl p-5 text-center">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 22 }}
className="glass rounded-3xl p-5 text-center relative overflow-hidden"
>
{/* soft gold glow behind avatar */}
<div className="pointer-events-none absolute -top-10 left-1/2 -translate-x-1/2 size-40 bg-gold-500/10 blur-3xl rounded-full" />
<div className="relative size-20 mx-auto">
<div className="size-20 rounded-2xl bg-navy-900 gold-border flex items-center justify-center overflow-hidden">
<motion.div
initial={{ scale: 0.8, rotate: -6 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 18, delay: 0.1 }}
className="size-20 rounded-2xl bg-navy-900 gold-border flex items-center justify-center overflow-hidden"
>
<Avatar id={profile.avatar} image={profile.avatarImage} size={profile.avatarImage ? 80 : 56} />
</div>
</motion.div>
{/* level badge */}
<span className="absolute -top-2 ltr:-left-2 rtl:-right-2 rounded-full bg-navy-950 gold-border px-2 py-0.5 text-[10px] font-black text-gold-300 shadow-lg">
{t("common.level")} {profile.level}
</span>
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 320, delay: 0.25 }}
className="absolute -top-2 ltr:-left-2 rtl:-right-2 inline-flex items-center gap-0.5 rounded-full btn-gold px-2 py-0.5 text-[10px] font-black shadow-lg"
>
<Star className="size-2.5" fill="currentColor" />
{profile.level}
</motion.span>
<button
onClick={() => (canUpload ? fileRef.current?.click() : undefined)}
className={cn(
@@ -123,7 +145,7 @@ export function ProfileScreen() {
</div>
<div className="mt-4">
<XpBar level={profile.level} xp={profile.xp} />
<XpBar level={profile.level} xp={profile.xp} showBadge />
</div>
<div className="mt-4">
@@ -142,7 +164,7 @@ export function ProfileScreen() {
</button>
)}
</div>
</div>
</motion.div>
{/* avatar picker */}
<div className="glass rounded-2xl p-4 mt-4">
@@ -227,18 +249,22 @@ export function ProfileScreen() {
)}
>
<span
className="w-7 h-10 rounded-md border"
style={{
borderColor: `${c.accent}80`,
background: `repeating-linear-gradient(45deg, ${c.accent}55 0 4px, transparent 4px 8px), ${c.c2}`,
}}
/>
className="w-7 h-10 rounded-md border grid place-items-center"
style={{ borderColor: `${c.accent}80`, ...backVisualFromDef(c) }}
>
<span className="text-[10px]" style={{ color: `${c.accent}dd` }}>
{cardBackMotif(c.pattern, c.motif)}
</span>
</span>
<span className="text-xs text-cream/80 pe-1">{locale === "fa" ? c.nameFa : c.nameEn}</span>
</button>
))}
</div>
</div>
{/* gender + social links */}
<SocialSettings />
{/* audio settings */}
<SoundSettings />
@@ -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 (
<div
<motion.div
key={a.id}
initial={{ opacity: 0, x: locale === "fa" ? 16 : -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className={cn(
"rounded-xl p-3 flex items-center gap-3 border",
unlocked ? "bg-gold-500/10 border-gold-500/40" : "bg-navy-900/50 border-navy-700/50"
@@ -313,7 +342,7 @@ export function ProfileScreen() {
)}
</div>
{unlocked && <Check className="size-4 text-gold-400 shrink-0" />}
</div>
</motion.div>
);
})}
</div>
@@ -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: <Eye className="size-3.5" />, key: "profile.visPublic" },
{ id: "friends", icon: <Users className="size-3.5" />, key: "profile.visFriends" },
{ id: "hidden", icon: <EyeOff className="size-3.5" />, 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<Record<string, string>>(() => ({
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 (
<div className="glass rounded-2xl p-4 mt-4">
<h3 className="text-sm font-bold text-cream/80 mb-3">{t("profile.social")}</h3>
{/* gender */}
<div className="text-xs text-cream/55 mb-2">{t("profile.gender")}</div>
<div className="flex flex-wrap gap-2 mb-4">
{GENDERS.map((g) => {
const meta = g ? GENDER_META[g] : null;
const active = gender === g;
return (
<button
key={g || "none"}
onClick={() => updateProfile({ gender: g })}
className={cn(
"rounded-lg px-3 py-1.5 text-sm font-semibold transition flex items-center gap-1.5",
active ? "btn-gold" : "bg-navy-900/70 gold-border text-cream/70 hover:text-cream"
)}
>
{meta ? (
<>
<span style={{ color: active ? undefined : meta.color }}>{meta.symbol}</span>
{locale === "fa" ? meta.faLabel : meta.enLabel}
</>
) : (
t("profile.genderNone")
)}
</button>
);
})}
</div>
{/* social links */}
<div className="text-xs text-cream/55 mb-2">{t("profile.socialLinks")}</div>
<div className="space-y-2">
{SOCIAL_PLATFORMS.map((p) => (
<div key={p.key} className="flex items-center gap-2">
<span className="grid size-8 place-items-center rounded-lg bg-navy-900/70 text-base shrink-0" style={{ color: p.color }}>
{p.icon}
</span>
<input
value={links[p.key]}
onChange={(e) => 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"
/>
</div>
))}
</div>
{/* visibility */}
<div className="text-xs text-cream/55 mt-4 mb-2">{t("profile.socialsVisibility")}</div>
<div className="glass rounded-xl p-1 flex gap-1">
{VIS_OPTIONS.map((o) => (
<button
key={o.id}
onClick={() => updateProfile({ socialsVisibility: o.id })}
className={cn(
"flex-1 rounded-lg py-1.5 text-xs font-bold transition flex items-center justify-center gap-1",
vis === o.id ? "btn-gold" : "text-cream/60 hover:text-cream"
)}
>
{o.icon}
{t(o.key)}
</button>
))}
</div>
<p className="text-[10px] text-cream/40 mt-1.5">{t("profile.socialsHint")}</p>
<button onClick={saveLinks} className="press-3d btn-gold w-full rounded-xl py-2.5 mt-3 text-sm font-bold flex items-center justify-center gap-1.5">
{saved ? <><Check className="size-4" />{t("profile.saved")}</> : t("profile.saveLinks")}
</button>
</div>
);
}
function SoundSettings() {
const { t } = useI18n();
const { sfx, music, toggleSfx, toggleMusic } = useSoundStore();
+11 -7
View File
@@ -208,26 +208,30 @@ function SeatCard({
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
) : (
role !== "you" && (
<button onClick={onClear} className="text-[10px] text-rose-300/70 hover:text-rose-300 flex items-center gap-1">
<X className="size-3" />
<button
onClick={onClear}
aria-label={t("friends.remove")}
className="grid place-items-center min-h-9 min-w-9 rounded-full text-rose-300/70 hover:text-rose-300 hover:bg-rose-500/10 transition"
>
<X className="size-4" />
</button>
)
)}
</>
) : (
<div className="flex flex-col gap-1.5 w-full">
<div className="flex flex-col gap-2 w-full">
<button
onClick={onInvite}
className="rounded-lg bg-navy-900/70 gold-border py-1.5 text-xs text-cream/80 hover:bg-navy-800 flex items-center justify-center gap-1.5"
className="rounded-xl bg-navy-900/70 gold-border py-2.5 text-xs text-cream/80 hover:bg-navy-800 active:scale-[0.98] transition flex items-center justify-center gap-1.5"
>
<UserPlus className="size-3.5" />
<UserPlus className="size-4" />
{t("room.invite")}
</button>
<button
onClick={onBot}
className="rounded-lg bg-navy-900/70 gold-border py-1.5 text-xs text-cream/80 hover:bg-navy-800 flex items-center justify-center gap-1.5"
className="rounded-xl bg-navy-900/70 gold-border py-2.5 text-xs text-cream/80 hover:bg-navy-800 active:scale-[0.98] transition flex items-center justify-center gap-1.5"
>
<Bot className="size-3.5" />
<Bot className="size-4" />
{t("room.addBot")}
</button>
</div>
+54 -8
View File
@@ -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 }) {
</span>
);
case "cardback":
case "cardback": {
const back = cardBackById(item.id);
return (
<span
className="rounded-md border"
className="rounded-md border grid place-items-center"
style={{
width: size * 0.72,
height: size,
borderColor: `${item.preview}80`,
background: `repeating-linear-gradient(45deg, ${item.preview}55 0 4px, transparent 4px 8px), #0a142e`,
borderColor: `${back.accent}80`,
...backVisualFromDef(back),
}}
/>
>
<span style={{ fontSize: size * 0.3, color: `${back.accent}dd`, textShadow: "0 1px 2px rgba(0,0,0,0.4)" }}>
{cardBackMotif(back.pattern, back.motif)}
</span>
</span>
);
}
case "title":
return (
<span
className="rounded-full btn-gold px-2.5 py-1 font-black leading-none whitespace-nowrap"
style={{ fontSize: Math.max(9, size * 0.22) }}
>
🏷
</span>
);
default: // avatar, reactionpack, xp → emoji glyph
return <span style={{ fontSize: size * 0.82, lineHeight: 1 }}>{item.kind === "xp" ? "⚡" : item.preview}</span>;
@@ -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() {
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
)}
{items.length === 0 && (
<div className="space-y-5">
{Array.from({ length: 2 }).map((_, s) => (
<div key={s}>
<div className="h-4 w-24 rounded bg-navy-800/80 animate-pulse mb-3" />
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="glass rounded-2xl p-3 flex flex-col items-center gap-2 animate-pulse">
<div className="size-12 rounded-xl bg-navy-800/80" />
<div className="h-2.5 w-3/4 rounded bg-navy-800/80" />
<div className="h-6 w-full rounded-lg bg-navy-800/60" />
</div>
))}
</div>
</div>
))}
</div>
)}
{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 (
<motion.button
whileTap={{ scale: 0.96 }}
onClick={onOpen}
className="press-3d glass rounded-2xl p-3 flex flex-col items-center gap-2 relative"
className={cn(
"press-3d glass rounded-2xl p-3 flex flex-col items-center gap-2 relative",
luxury && !owned && "ring-1 ring-gold-400/50"
)}
>
{luxury && !owned && (
<span className="absolute top-1.5 ltr:left-1.5 rtl:right-1.5 inline-flex items-center gap-0.5 rounded-full bg-gradient-to-r from-gold-500 to-gold-300 text-[#2a1f04] text-[8px] font-black px-1.5 py-0.5 shadow">
{t("shop.luxury")}
</span>
)}
{owned && (
<span className="absolute top-1.5 ltr:right-1.5 rtl:left-1.5 grid size-5 place-items-center rounded-full bg-teal-500 text-navy-950">
<Check className="size-3.5" strokeWidth={3} />
+87
View File
@@ -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);
}
+42 -21
View File
@@ -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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((set, get) => {
sound.play("kot");
}
scheduleAuto();
}, TIMING.trickPause);
}, fast(TIMING.trickPause));
break;
case "round-over":
@@ -299,7 +310,7 @@ export const useGameStore = create<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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,
+102
View File
@@ -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",
};
+81 -14
View File
@@ -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;
+221 -12
View File
@@ -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<string>();
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<UserProfile, "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack">
>
) {
async updateProfile(patch: Parameters<OnlineService["updateProfile"]>[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<PublicProfile> {
// 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<PlayerSummary[]> {
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<PresenceStatus>(["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<PlayerSummary[]> {
const me = this.profile;
const lvl = me?.level ?? 10;
return Array.from({ length: 12 }, () => {
const f = makeFriend(pick<PresenceStatus>(["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 1218s) 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);
// 03 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<number> {
// 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" };
+21 -1
View File
@@ -14,6 +14,8 @@ import {
Friend,
FriendRequest,
LeaderboardEntry,
PlayerSummary,
PublicProfile,
MatchSummary,
MatchmakingState,
RewardResult,
@@ -51,7 +53,11 @@ export interface OnlineService {
getProfile(): Promise<UserProfile>;
updateProfile(
patch: Partial<
Pick<UserProfile, "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack">
Pick<
UserProfile,
| "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack"
| "gender" | "socials" | "socialsVisibility"
>
>
): Promise<UserProfile>;
upgradePlan(): Promise<UserProfile>;
@@ -60,6 +66,14 @@ export interface OnlineService {
listFriends(): Promise<Friend[]>;
listRequests(): Promise<FriendRequest[]>;
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<PublicProfile>;
/** Search players by display name (for the "find friends" discovery tab). */
searchPlayers(query: string): Promise<PlayerSummary[]>;
/** Suggested players to befriend (online / not-yet-friends). */
suggestedPlayers(): Promise<PlayerSummary[]>;
acceptRequest(id: string): Promise<void>;
declineRequest(id: string): Promise<void>;
removeFriend(id: string): Promise<void>;
@@ -129,6 +143,12 @@ export interface OnlineService {
getCoinPacks(): Promise<CoinPack[]>;
/** 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";
+25
View File
@@ -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<PublicProfile> {
return this.getJson<PublicProfile>(`/api/profile/${encodeURIComponent(userId)}/public`);
}
searchPlayers(query: string): Promise<PlayerSummary[]> {
return this.getJson<PlayerSummary[]>(`/api/players/search?q=${encodeURIComponent(query)}`);
}
suggestedPlayers(): Promise<PlayerSummary[]> {
return this.getJson<PlayerSummary[]>("/api/players/suggested");
}
async acceptRequest(id: string) { await this.send<unknown>("POST", "/api/friends/accept", { id }); }
async declineRequest(id: string) { await this.send<unknown>("POST", "/api/friends/decline", { id }); }
async removeFriend(id: string) { await this.send<unknown>("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 };
}
}
}
+95
View File
@@ -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<string, number>; // 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<string, number>;
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 {
+7 -1
View File
@@ -22,7 +22,13 @@ interface SessionStore {
signOut: () => Promise<void>;
updateProfile: (
patch: Partial<Pick<UserProfile, "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack">>
patch: Partial<
Pick<
UserProfile,
| "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack"
| "gender" | "socials" | "socialsVisibility"
>
>
) => Promise<void>;
upgradePlan: () => Promise<void>;
}
+36
View File
@@ -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<Gender, "">,
{ 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);
}
+102
View File
@@ -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<void>;
}
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<PurchaseStart> {
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 };
}
+10
View File
@@ -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<UIStore>((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<UIStore>((set, get) => ({
openDaily: () => set({ dailyModalOpen: true }),
closeDaily: () => set({ dailyModalOpen: false }),
viewProfile: (userId) => set({ viewProfileId: userId }),
closeProfile: () => set({ viewProfileId: null }),
}));