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:
+21
-7
@@ -60,16 +60,27 @@ npm run build # next static export
|
||||
## 3. Feature status (DONE)
|
||||
|
||||
- Full offline vs-AI game (engine, AI, turn timer + auto-play, disconnect/reconnect sim).
|
||||
- Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards.
|
||||
- Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards. **Matchmaking waits ~15s (randomized 12–18s) for humans, then bots fill** (`GameManager.NextQueueWaitMs`; mock mirrors it in `beginSearch`). MatchmakingScreen shows the elapsed timer + a bot-fill hint.
|
||||
- **Per-league turn time** (think faster in higher leagues): Starter/vs-AI/private → **15s**, Pro (stake ≥500) → **10s**, Expert (stake ≥1000) → **7s**. Single source: `turnMsForStake(stake, speed?)` in `gamification.ts`; the live server mirrors it in `GameRoom.TurnMs`. The turn-timer bar reads it from `matchMeta.stake`.
|
||||
- **Speed (Blitz) mode** — CLIENT-ONLY (vs-AI + private rooms; ranked stays standard). Flat **5s** turn clock (`SPEED_TURN_MS`), races to **5** points (`SPEED_TARGET_SCORE`), and ~½ pacing on animations/pauses (the `fast()` scaler in `game-store.scheduleAuto`). Threaded via `matchMeta.speed` + `GameSettings.speed`/`OnlineMatchConfig.speed`. Toggle on Home's vs-Computer card; a `SpeedBadge` (⚡) shows on the table HUD. No server change needed — private rooms are client-driven even in live mode, and ranked is intentionally excluded.
|
||||
- **Economy:** coins; ranked entry = stake (win +stake [+kot 40], lose −stake); free vs-computer/private rooms. Buy-coins via **ZarinPal sandbox** (merchant `299685fb-cadf-4dfc-98e2-d4af5d81528d`, config-driven). Coin packs: starter 50k/95,000﷼, … Stores (Bazaar/Myket) must use their **IAB** (`/api/coins/iab/verify` scaffolded; token verification TODO).
|
||||
- **XP/levels:** every game grants XP, **winner ×2**; **premium (pro) ×1.5**; max level 100; curve `100*lvl + 15*lvl²`. **Store sells XP packs** (xp1 +200/5k, xp2 +600/12k, xp3 +1500/25k coins; consumable; unlocks level achievements).
|
||||
- **Achievements:** ~100, metric-driven generator (categories: victory/kot/streak/hakem/level/rank/veteran), incl. "7× hakem", "7–0 sweep". Dedicated **AchievementsScreen** (tabbed) + Profile summary. Some unlock **sticker packs**.
|
||||
- **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, sticker packs (custom SVG art incl. crown/seven-zip/streak-fire). Profile **photo upload gated at level ≥ 25**.
|
||||
- **Social:** friends + chat **server-persisted** (`Social/SocialService`, REST + hub `friendRequest`/`social`/`chat`); friend remove needs confirm. **Premium chat = animated gold bubbles**.
|
||||
- **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, **16 sticker packs** (all custom inline-SVG art in `Sticker.tsx`). **✨ Luxury tier** (premium giftable items): luxury avatars (🦢🎩💎💰🏆 + 💠 rank-gated), luxury card backs (Diamond/Black Gold/Platinum/Peacock/Rose-Gold) + fronts (Diamond/Black Gold), luxury titles (Hokm Sultan/Emperor/Grandmaster). Shop tags items priced ≥2000 with a gold **«ویژه/Luxury»** badge + ring.
|
||||
- **Card backs are pattern-distinct** (not just recoloured): each `CardBackDef` carries a `pattern` (`stripes/argyle/grid/dots/rays/scales/crosshatch/royal/filigree/gem`) + optional `motif` glyph. Rendering lives in `src/lib/cardBack.ts` (`cardBackVisual`/`cardBackMotif`/`backVisualFromDef`), used by `PlayingCard`, the shop preview, and the profile picker so all three match.
|
||||
- **Purchasable titles:** `TitleDef.price` makes a title buyable; shop **Titles** section (`ShopItemKind` now includes `"title"`, server `ShopBuy` handles `title → OwnedTitles`, mock mirrors it). The equipped title shows **under your name on the table** (`SeatPlayer.title` → `SeatAvatar`, seat 0 from your profile in every mode incl. live via `applyServerState`) and in the **Discover/find list** (`PlayerSummary.title`, server `ToSummary`). Localize via `titleById(id)`. New themed packs: **کلکل/banter** (kolkol, tikeh, shakkak, raghib), **Persian trends/praise** (trends, tashvigh), **court cards** (khanevadeh: تکخال/آس دل/شاه خشت/بیبی گشنیز), moods (ehsasat). Banter uses the `Stamp` helper (rounded badge + Persian phrase); court cards use `CourtCard`. Profile **photo upload gated at level ≥ 25**. New sticker packs are **client-only** (server `ShopBuy` is generic) — add art to `Sticker.tsx` + an entry in `STICKER_PACKS`.
|
||||
- **Daily rewards** (boosted): `[300, 500, 750, 1000, 1500, 2500, 7500]` — **must stay in sync** between client `gamification.ts DAILY_REWARDS` and server `ProfileService.DailyRewards` (server is authoritative for the claim). Day 7 is the gold "special" tier.
|
||||
- **Social:** friends + chat **server-persisted** (`Social/SocialService`, REST + hub `friendRequest`/`social`/`chat`); friend remove needs confirm. **Premium chat = animated gold bubbles**. **Friend-request rate limit = 10 / rolling hour** per user (server `SocialService.TryRecordRequest`, static in-memory; mirrored in the mock). Both `addFriend(query)` and `addFriendById(userId)` funnel through it.
|
||||
- **Public profiles:** tap any player in the **leaderboard / friends list / end-of-game roster** → `PublicProfileModal` (global, `ui-store.viewProfile(id)`) shows their identity, stats, and **achievement board** + a rate-limited **Add-friend** button. Server `GET /api/profile/{id}/public` → `PublicProfileDto` (no coins/phone/email); mock synthesizes deterministic stats seeded from the id. Client `OnlineService.getPublicProfile(id)` / `addFriendById(id)`.
|
||||
- **Social hub:** `FriendsScreen` is now tabbed — **Friends / Discover / Messages**. **Discover** = find-friends search (debounced) + suggested players, each row taps to the public profile and has a rate-limited Add button. **Messages** = conversation inbox (`listConversations`, unread badges, relative time) → opens `ChatScreen`. New: `OnlineService.searchPlayers(q)` / `suggestedPlayers()` → `PlayerSummary[]`; server `GET /api/players/search?q=` + `/api/players/suggested` (`SocialService.SearchPlayers`/`Suggested`, online-first, excludes friends/self). Mock synthesizes results.
|
||||
- **Profile gender + social links:** `UserProfile.gender` (`""|male|female|other`, shown as ♂/♀/⚧ in Discover + public profile + edited in `ProfileScreen`'s `SocialSettings`), `socials` (instagram/telegram/x/youtube handles or URLs, rendered as tappable chips), and `socialsVisibility` (**public / friends / hidden**). Helpers in `src/lib/social.ts` (`GENDER_META`, `SOCIAL_PLATFORMS`, `socialUrl`, `hasSocials`). **Privacy is server-enforced:** `SocialService.GetPublicProfile` only includes `Socials` when `public`, or `friends` && the viewer is a friend, or it's you; `hidden` → never. `PlayerSummary.gender` carried in discovery. Server fields on `ProfileDto` (`Gender`/`Socials`/`SocialsVisibility`); `ProfileService.Update` parses them; `updateProfile` patch widened (client interface + session-store + mock + signalr).
|
||||
- **Forfeit:** request + teammate-confirm (server `GameRoom` forfeit flow); penalty = **lose 2× coins + 0 XP** (NO kot, and never mention kot); confirm dialog alerts the penalty.
|
||||
- **End-of-game roster:** `MatchPlayersList` on the final screen (reward modal + AI match-over) lists everyone; **Add-friend** button for real non-bot players (seat `userId` threaded from server).
|
||||
- **Celebrations:** `celebration-store` + `CelebrationOverlay` — animated XP count-up, level-up pop, achievement unlock; fires from shop purchases (XP/cosmetic) and unlocks. Reusable via `celebrate({...})`.
|
||||
- **UX/UI:** "Persian luxury" palette (navy/teal/gold, glass) + **UNO-style tactile UX** rolled out to Home (hero Play), Shop (detail sheets), Lobby, Matchmaking, Profile, Leaderboard. Primitives in `globals.css`: `.press-3d` (tactile press), `.safe-top/.safe-bottom/.safe-x` (notch), `.hud-shadow`, `.premium-chat`. Online count floored at **≥50**. Match stays alive on exit (minimize/resume + ResumeGameBar). **No fake/periodic notifications** (removed as spam).
|
||||
- **Celebrations:** `celebration-store` + `CelebrationOverlay` — animated XP count-up, level-up pop, achievement unlock; fires from shop purchases (XP/cosmetic) and unlocks. Reusable via `celebrate({...})`. Achievement rows (here + `PostMatchRewardsModal`) now reveal the **sticker pack** an achievement unlocks (`stickerPackForAchievement`).
|
||||
- **Persistent level + XP bar:** `LevelXpBar` (avatar + Lv + progress, taps to profile) shows on Home (`TopBar`) and atop every inner screen (`ScreenHeader`, `showXp` default on) so level/XP is always visible.
|
||||
- **Buy-coins gateway** opens in a **new tab** (`window.open(_blank)`, same-tab fallback if popup-blocked) so a slow/blocked ZarinPal page can't dead-end the SPA; balance refreshes on window focus. (Fixes the old `window.location.href` "page couldn't load" crash.)
|
||||
- **UX/UI:** "Persian luxury" palette (navy/teal/gold, glass) + **UNO-style tactile UX** rolled out to Home (hero Play), Shop (detail sheets), Lobby, Matchmaking, Profile, Leaderboard. Primitives in `globals.css`: `.press-3d` (tactile press), `.safe-top/.safe-bottom/.safe-x` (notch), `.hud-shadow`, `.premium-chat`, **`.tap`** (44px min hit area). Online count floored at **≥50**. Match stays alive on exit (minimize/resume + ResumeGameBar). **No fake/periodic notifications** (removed as spam).
|
||||
- **Accessibility pass:** global **`:focus-visible`** gold ring (keyboard/controller/switch nav — no pointer required); **reduced-motion** honored app-wide via a `@media (prefers-reduced-motion)` block (kills decorative CSS loops) **and** `<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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (12–18s) so the queue doesn't feel
|
||||
// robotically identical every time.
|
||||
private const int QueueWaitMinMs = 12000;
|
||||
private const int QueueWaitMaxMs = 18000;
|
||||
private int NextQueueWaitMs() => _rng.Next(QueueWaitMinMs, QueueWaitMaxMs + 1);
|
||||
|
||||
private static readonly string[] BotNames =
|
||||
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
|
||||
@@ -53,7 +57,7 @@ public sealed class GameManager
|
||||
lock (_mmLock)
|
||||
{
|
||||
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
|
||||
var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
|
||||
var timer = new Timer(_ => FlushTicket(p.UserId), null, NextQueueWaitMs(), Timeout.Infinite);
|
||||
_waiting.Add((p, timer));
|
||||
_ = _hub.Clients.User(p.UserId).SendAsync("matchmaking",
|
||||
new MatchmakingStateDto("searching", _waiting.Count, null));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
_db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow });
|
||||
await _db.SaveChangesAsync();
|
||||
await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid));
|
||||
}
|
||||
// Already pending → idempotent success, doesn't consume the hourly quota.
|
||||
if (await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId))
|
||||
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
|
||||
if (!TryRecordRequest(uid, out var mins))
|
||||
return (false,
|
||||
$"در هر ساعت حداکثر {FriendReqLimit} درخواست دوستی میتوانید بفرستید. {mins} دقیقه دیگر تلاش کنید.",
|
||||
$"You can send at most {FriendReqLimit} friend requests per hour. Try again in {mins} min.");
|
||||
_db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow });
|
||||
await _db.SaveChangesAsync();
|
||||
await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid));
|
||||
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
|
||||
}
|
||||
|
||||
/* --------------------------- discovery ----------------------------- */
|
||||
|
||||
private PlayerSummaryDto ToSummary(ProfileDto p, HashSet<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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+318
-104
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TURN_MS, useGameStore } from "@/lib/game-store";
|
||||
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff, Zap } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { useSoundStore } from "@/lib/sound-store";
|
||||
import { legalMoves } from "@/lib/hokm/engine";
|
||||
import { sortHand } from "@/lib/hokm/deck";
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@/lib/hokm/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { cardBackById, cardFrontById, ownedReactions, ownedStickers } from "@/lib/online/gamification";
|
||||
import { cardBackById, cardFrontById, ownedReactions, ownedStickers, titleById, turnMsForStake } from "@/lib/online/gamification";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PlayingCard } from "./PlayingCard";
|
||||
@@ -43,7 +43,7 @@ function useCardSkins() {
|
||||
const b = cardBackById(backId);
|
||||
return {
|
||||
front: { bg1: f.bg1, bg2: f.bg2, border: f.border },
|
||||
back: { c1: b.c1, c2: b.c2, accent: b.accent },
|
||||
back: { c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,11 +68,48 @@ export function GameTable({
|
||||
const trickScale = vw < 360 ? 0.5 : vw < 460 ? 0.64 : 1;
|
||||
const { phase, players, hakem, trump, turn, currentTrick } = game;
|
||||
|
||||
const legalIds = new Set(
|
||||
phase === "playing" && turn === 0
|
||||
? legalMoves(game, 0).map((c) => c.id)
|
||||
: []
|
||||
const legalMovesList = useMemo(
|
||||
() => (phase === "playing" && turn === 0 ? legalMoves(game, 0) : []),
|
||||
[phase, turn, game]
|
||||
);
|
||||
const legalIds = new Set(legalMovesList.map((c) => c.id));
|
||||
|
||||
// Keyboard shortcuts (desktop): 1–9 / 0 play the Nth playable card in hand
|
||||
// order, Space/Enter play the first playable card, M mutes, F forfeits,
|
||||
// Esc/Q quits. A floating hint lists them.
|
||||
const playHuman = useGameStore((s) => s.playHuman);
|
||||
const chooseTrump = useGameStore((s) => s.chooseTrump);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
|
||||
// Hakem choosing trump: 1–4 pick a suit.
|
||||
if (phase === "choosing-trump" && players[hakem!]?.isHuman) {
|
||||
const idx = "1234".indexOf(e.key);
|
||||
if (idx >= 0) { e.preventDefault(); chooseTrump(SUITS[idx]); return; }
|
||||
}
|
||||
|
||||
if (phase === "playing" && turn === 0) {
|
||||
const playable = sortHand(game.players[0].hand).filter((c) => legalIds.has(c.id));
|
||||
if (k === " " || k === "enter") {
|
||||
if (playable[0]) { e.preventDefault(); playHuman(playable[0]); }
|
||||
return;
|
||||
}
|
||||
// 1-9 then 0 → 10th
|
||||
const digit = e.key === "0" ? 9 : "123456789".indexOf(e.key);
|
||||
if (digit >= 0 && playable[digit]) { e.preventDefault(); playHuman(playable[digit]); return; }
|
||||
}
|
||||
|
||||
if (k === "m") { e.preventDefault(); toggleAll(); }
|
||||
else if (k === "f" && onForfeit) { e.preventDefault(); setAskFf(true); }
|
||||
else if (k === "escape" || k === "q") { e.preventDefault(); exit(); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, turn, hakem, game.players, legalMovesList]);
|
||||
|
||||
return (
|
||||
<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" : "flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{cards.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
|
||||
>
|
||||
<PlayingCard faceDown size="sm" back={back} />
|
||||
</div>
|
||||
))}
|
||||
<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
|
||||
key={i}
|
||||
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="1–9 / 0" v={t("keys.play")} />
|
||||
<Row k="Space" v={t("keys.first")} />
|
||||
<Row k="1–4" 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 ? t("turn.you") : t("turn.other", { name })}
|
||||
</div>
|
||||
{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)",
|
||||
}}
|
||||
>
|
||||
✨ {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
|
||||
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: -15, scale: 0 }}
|
||||
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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
✦
|
||||
</div>
|
||||
<div
|
||||
className="h-full w-full flex items-center justify-center"
|
||||
style={{ borderRadius: s.radius }}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
const red = SUIT_IS_RED[card.suit];
|
||||
const color = red ? "text-rose-600" : "text-slate-900";
|
||||
const symbol = SUIT_SYMBOL[card.suit];
|
||||
/* ── Face-up ────────────────────────────────────────────────────── */
|
||||
const red = SUIT_IS_RED[card.suit];
|
||||
const symbol = SUIT_SYMBOL[card.suit];
|
||||
const label = rankLabel(card.rank);
|
||||
|
||||
// UNO-style: suit-aware background
|
||||
const cardBg = front
|
||||
? `linear-gradient(160deg,${front.bg1},${front.bg2})`
|
||||
: red
|
||||
? "linear-gradient(160deg,#fff8f7,#fdecea)"
|
||||
: "linear-gradient(160deg,#fefefe,#f4f2ec)";
|
||||
|
||||
const borderColor = front?.border ?? (red ? "rgba(200,70,70,0.22)" : "rgba(50,50,80,0.15)");
|
||||
|
||||
// Bold suit colours (UNO-style vivid)
|
||||
const inkColor = red ? "#c0202a" : "#1c1c38";
|
||||
const pipColor = red ? "#e03540" : "#2a2a50";
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useCelebrationStore } from "@/lib/celebration-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { sound } from "@/lib/sound";
|
||||
import { stickerPackForAchievement } from "@/lib/online/gamification";
|
||||
import { Sticker } from "./Sticker";
|
||||
|
||||
function useCountUp(target: number, ms = 900, run = true) {
|
||||
const [v, setV] = useState(0);
|
||||
@@ -125,27 +127,36 @@ function Card() {
|
||||
{/* achievements */}
|
||||
{current.achievements && current.achievements.length > 0 && (
|
||||
<div className="relative mt-4 space-y-2">
|
||||
{current.achievements.map((a, i) => (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 + i * 0.15, type: "spring", stiffness: 200 }}
|
||||
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
|
||||
>
|
||||
<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}
|
||||
{current.achievements.map((a, i) => {
|
||||
const pack = stickerPackForAchievement(a.id);
|
||||
return (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 24 : -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 + i * 0.15, type: "spring", stiffness: 200 }}
|
||||
className="glass rounded-xl px-3 py-2 flex items-center gap-2 text-start"
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
<span className="text-xs text-gold-300 flex items-center gap-1 shrink-0">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
<span className="text-xs text-gold-300 flex items-center gap-1 shrink-0">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -12,12 +12,28 @@ import { sound } from "@/lib/sound";
|
||||
import { DailyRewardState } from "@/lib/online/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Per-day themed icons and colour accents
|
||||
const DAY_META = [
|
||||
{ icon: "🎁", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "💰", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "⭐", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "💎", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "🔥", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "🏆", label: null, accent: "from-navy-800/90 to-navy-900/90" },
|
||||
{ icon: "👑", label: "special", accent: "from-gold-600/30 to-gold-500/10" },
|
||||
] as const;
|
||||
|
||||
// Coin-rise particles shown when claiming day 7
|
||||
const MEGA_COINS = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i, left: 8 + (i * 9) % 84, delay: i * 0.12,
|
||||
}));
|
||||
|
||||
export function DailyRewardModal() {
|
||||
const open = useUIStore((s) => s.dailyModalOpen);
|
||||
const close = useUIStore((s) => s.closeDaily);
|
||||
const open = useUIStore((s) => s.dailyModalOpen);
|
||||
const close = useUIStore((s) => s.closeDaily);
|
||||
const refreshProfile = useSessionStore((s) => s.refreshProfile);
|
||||
const { t } = useI18n();
|
||||
const [state, setState] = useState<DailyRewardState | null>(null);
|
||||
const [state, setState] = useState<DailyRewardState | null>(null);
|
||||
const [claimed, setClaimed] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -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 day = i + 1;
|
||||
const meta = DAY_META[i];
|
||||
const isToday = state?.day === day && state?.available;
|
||||
const isPast = state ? day < state.day : false;
|
||||
const isPast = state ? day < state.day : false;
|
||||
const isMega = day === 7;
|
||||
|
||||
return (
|
||||
<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" />
|
||||
</span>
|
||||
</div>
|
||||
)}>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,21 +38,42 @@ 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">
|
||||
<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}
|
||||
{isMe && <span className="text-gold-300 font-normal"> ({t("match.you")})</span>}
|
||||
{p.isBot && <span className="text-cream/35 font-normal"> ({t("match.bot")})</span>}
|
||||
</span>
|
||||
{p.level > 0 && (
|
||||
<span className="block text-[10px] text-cream/45">
|
||||
{t("common.level")} {p.level}
|
||||
{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>
|
||||
<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>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-semibold text-cream truncate">
|
||||
{p.name}
|
||||
{isMe && <span className="text-gold-300 font-normal"> ({t("match.you")})</span>}
|
||||
{p.isBot && <span className="text-cream/35 font-normal"> ({t("match.bot")})</span>}
|
||||
</span>
|
||||
{p.level > 0 && (
|
||||
<span className="block text-[10px] text-cream/45">
|
||||
{t("common.level")} {p.level}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{canAdd &&
|
||||
(sent[p.id!] ? (
|
||||
<span className="text-[11px] text-teal-300 flex items-center gap-1 shrink-0">
|
||||
|
||||
@@ -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) => (
|
||||
<motion.div
|
||||
key={a.id}
|
||||
initial={{ opacity: 0, x: locale === "fa" ? 20 : -20 }}
|
||||
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"
|
||||
>
|
||||
<span className="text-xl">{a.icon}</span>
|
||||
<span className="flex-1">
|
||||
<span className="block text-[10px] text-gold-400">
|
||||
{t("reward.newAchievement")}
|
||||
<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" ? 24 : -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
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-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>
|
||||
<span className="block text-sm text-cream font-semibold">
|
||||
{locale === "fa" ? a.nameFa : a.nameEn}
|
||||
<span className="text-xs text-gold-300 flex items-center gap-1 shrink-0 font-bold">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-gold-300 flex items-center gap-1">
|
||||
+{a.coinReward}
|
||||
<Coins className="size-3" />
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,30 +3,37 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useUIStore, type Screen } from "@/lib/ui-store";
|
||||
import { LevelXpBar } from "./LevelXpBar";
|
||||
|
||||
export function ScreenHeader({
|
||||
title,
|
||||
back = "home",
|
||||
right,
|
||||
showXp = true,
|
||||
}: {
|
||||
title: string;
|
||||
back?: Screen;
|
||||
right?: React.ReactNode;
|
||||
/** Show the persistent level + XP chip beneath the header (default on). */
|
||||
showXp?: boolean;
|
||||
}) {
|
||||
const navBack = useUIStore((s) => s.back);
|
||||
const { locale } = useI18n();
|
||||
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 mb-5">
|
||||
<button
|
||||
onClick={() => navBack(back)}
|
||||
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
|
||||
>
|
||||
<Chevron className="size-5 text-cream/80" />
|
||||
</button>
|
||||
<h1 className="gold-text text-2xl font-black">{title}</h1>
|
||||
<div className="min-w-10 flex justify-end">{right}</div>
|
||||
</div>
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
<Chevron className="size-5 text-cream/80" />
|
||||
</button>
|
||||
<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" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,27 +52,41 @@ 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>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
|
||||
<div className="text-[11px] text-teal-300">
|
||||
{friend.status === "online"
|
||||
? t("friends.online")
|
||||
: friend.status === "in-game"
|
||||
? t("friends.inGame")
|
||||
: t("friends.offline")}
|
||||
<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">
|
||||
{friend.status === "online"
|
||||
? t("friends.online")
|
||||
: friend.status === "in-game"
|
||||
? t("friends.inGame")
|
||||
: t("friends.offline")}
|
||||
</div>
|
||||
</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" />
|
||||
|
||||
@@ -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">
|
||||
<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]
|
||||
)}
|
||||
/>
|
||||
</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}
|
||||
<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])} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-gold-300/80">{Math.round(f.rating)}</span>
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 12–18s) for "online" players to show up; whoever
|
||||
// hasn't joined by then is filled with a bot when the match forms. The exact
|
||||
// wait varies so it never feels robotically identical.
|
||||
const searchMs = randInt(12000, 18000);
|
||||
// 0–3 humans actually appear; the rest of the table fills with bots.
|
||||
const humansFound = randInt(0, 3);
|
||||
|
||||
const reveal = (delay: number, isBot: boolean) =>
|
||||
this.after(delay, () => {
|
||||
if (this.matchmaking.phase !== "searching") return;
|
||||
this.matchmaking.players.push({
|
||||
id: rid("p"),
|
||||
id: rid(isBot ? "bot" : "p"),
|
||||
displayName: pick(PERSIAN_NAMES),
|
||||
avatar: pick(AVATARS).id,
|
||||
level: randInt(1, 50),
|
||||
@@ -711,11 +898,16 @@ export class MockOnlineService implements OnlineService {
|
||||
this.emitMM();
|
||||
});
|
||||
|
||||
reveal(900);
|
||||
reveal(1900);
|
||||
reveal(2900);
|
||||
// Real players trickle in across the search window…
|
||||
for (let i = 0; i < humansFound; i++) {
|
||||
reveal(Math.round(searchMs * (0.25 + i * 0.22)), false);
|
||||
}
|
||||
// …then bots fill the remaining seats just before the match forms.
|
||||
for (let i = 0; i < 3 - humansFound; i++) {
|
||||
reveal(searchMs - 600 + i * 120, true);
|
||||
}
|
||||
|
||||
this.after(3500, () => {
|
||||
this.after(searchMs, () => {
|
||||
if (this.matchmaking.phase !== "searching") return;
|
||||
this.matchmaking.phase = "found";
|
||||
this.emitMM();
|
||||
@@ -787,6 +979,11 @@ export class MockOnlineService implements OnlineService {
|
||||
return { ok: true, profile: this.profile, coins: added };
|
||||
}
|
||||
|
||||
async verifyIab(_store: string, productId: string, _token: string) {
|
||||
// Offline/dev: no real store to verify against — credit the matching pack.
|
||||
return this.buyCoins(productId);
|
||||
}
|
||||
|
||||
private onlineCount = 60 + Math.floor(Math.random() * 110);
|
||||
async getOnlineCount(): Promise<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" };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user