Compare commits

..

101 Commits

Author SHA1 Message Date
soroush.asadi 1af8e395ac fix(ui): home bottom nav matches inner screens
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m39s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m13s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 35s
Wrap the home-screen NavRail in the same centered max-w-md container + notch-
safe bottom padding that ScreenShell uses, so the pill is identical in width and
position across home and every other screen. Home still drops its redundant Home
tab (5 vs 6) by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:57:23 +03:30
soroush.asadi 5f07a0580e chore(build): add googleplay APK/AAB build scripts
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 50s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m28s
build:googleplay / cap:googleplay / apk:googleplay mirror the bazaar/myket
flavors for the Google Play (NEXT_PUBLIC_STORE=googleplay) appeal build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:16:32 +03:30
soroush.asadi 5f43392de2 feat(billing): Google Play build shows "not implemented" for coin buys
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m7s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m4s
Add a gated `googleplay` store flavor (NEXT_PUBLIC_STORE=googleplay) so the
appeal/Play build ships a payment-free coin shop. isPurchaseDisabled() makes
BuyCoinsScreen short-circuit to a "not implemented" notice instead of starting
ZarinPal or Iranian IAB (both rejected on Play). Default web/Bazaar/Myket
builds are unaffected. New i18n key buy.notImplemented (fa+en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:00:15 +03:30
soroush.asadi b12a7c7813 fix(matchmaking): reset phase synchronously to stop stale-ready fast-join
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m27s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m16s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m2s
Leaving a match left mm.phase stuck on "ready". On the next league tap,
playLeague navigated to the matchmaking screen before svc.startMatchmaking
(which awaits connect+profile) could emit "searching" — so the screen mounted
with the old "ready" phase and its auto-enter effect instantly dropped the
player into a stale game with no lobby. Reset matchmaking to a fresh "searching"
state synchronously in the store before the async work to close the race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 10:55:56 +03:30
soroush.asadi 856fbab701 fix(game): move all hooks above early return to fix React error #310
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m36s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m4s
The connecting overlay used an early return before useState/useSoundStore/
useViewportWidth/useMemo/useEffect were called. On the first render with
seatPlayers=[] those hooks were skipped; on the next render (state arrived)
React tried to call more hooks than before → error #310 crashed all clients.

Fix: hoist all hook declarations above the connecting guard so the count
is always identical regardless of which branch renders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 08:35:20 +03:30
soroush.asadi 3875141f46 fix(game): prevent green-felt freeze — loading spinner + retry resync
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m52s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 57s
Three changes:
1. GameTable shows a spinner instead of an empty table when mode=online
   and seatPlayers is empty (waiting for first state broadcast).
2. enterServerMatch schedules a 3s interval that calls service.resync()
   until seatPlayers is populated, guaranteeing the state always lands.
3. resync() added to OnlineService interface + both implementations so
   the game store can call it without casting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 20:05:16 +03:30
soroush.asadi 4fb5a1776f fix(matchmaking): broadcast player list so queue avatars appear for all waiting players
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 1m18s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 3m32s
BroadcastQueueLocked now sends the full waiting-player list (name/avatar/level)
alongside the count. The client maps it onto mm.players so every queued player's
avatar shows in the 4-slot grid for all waiting users, not just the slot-0 viewer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:46:28 +03:30
soroush.asadi 940e2af6d2 feat(online): live queue count — friends see each other waiting
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 1m24s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m52s
The server only sent the queue size to the player who just joined, and the
client dropped the count entirely (emitMM ignored s.players). So two friends
queuing together never saw each other, even though the server does seat 2+
waiting humans together within ~25s.

- Server: BroadcastQueueLocked() pushes the current queue size to EVERY waiting
  player on join/cancel (not just the joiner).
- Client: thread the count through emitMM → MatchmakingState.waiting.
- MatchmakingScreen shows "N players in queue" (mm.inQueue) when ≥2 humans wait,
  so friends can tell they're queued together before bots fill the empty seats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:26:13 +03:30
soroush.asadi fe3bedc631 fix(online): trump chooser only shows to the hakem, not every player
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m25s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m5s
The "pick the hokm" overlay gated on players[hakem].isHuman — true on EVERY
human client when the hakem is human, so all players saw the chooser at the
start of a round. After the seat-rotation fix the viewer is always local seat 0,
so the correct check is hakem===0 ("I am the hakem"). Same fix for the 1–4
suit keyboard shortcut. Non-hakem players now see a "{name} is choosing trump…"
waiting overlay (new TrumpWaiting component) instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:06:39 +03:30
soroush.asadi 2aac6257d6 fix(online): rotate server state to viewer's seat — non-seat-0 players can play
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 1m1s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m7s
The server is authoritative with ABSOLUTE seats and tells each client its own
seat via mySeat, but the client copied seats verbatim — so any player not at
absolute seat 0 had their hand at players[mySeat] while the table read players[0]
and "your turn" checked turn===0. Result: they couldn't play (server auto-played
after the timeout → "hang"), and the turn highlight was identical for everyone
instead of rotating per viewer.

Fix (client-only; server was correct): new viewerRot(mySeat) rotates every
seat-indexed value into the viewer's frame (viewer → local seat 0): players/hands,
turn, hakem, leadSeat, lastTrickWinner, currentTrick, hakemDraw, seat roster,
disconnectedSeat, and the team arrays (matchScore/roundTricks/lastRoundResult/
matchWinner — odd seats swap team order). Store mySeat and rotate reaction bubbles
too (they carried absolute seats).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:59:03 +03:30
soroush.asadi 0790ad6fe0 chore(prod): real leaderboard, prod guards, payment hardening
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m4s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 2m11s
Production-readiness pass — remove mock-in-prod and harden the server:
- leaderboard: new DB-backed LeaderboardService + /api/leaderboard (ranked by
  rating, 30s cache, bounded scan); client now calls it instead of mock fake data.
- online count: client uses real /api/stats/online (dropped the fabricated ≥50 floor).
- boot guards (Production): refuse to start if Sms:ApiKey is missing (OTP would
  run in dev mode = fixed code for any phone) or Iab:AllowUnverified is true
  (forged tokens could mint coins).
- payments: ZarinPal + IAB HttpClients get 15s timeouts; ZarinPal/FlatPay gateway
  failures are now logged instead of silently swallowed.
- OTP: periodic prune of expired codes + stale rate-limit logs (was an unbounded
  in-memory leak over a long-running process).
- DB: EnableRetryOnFailure for Postgres (transient-fault resilience).
- docker-compose: ZarinPal sandbox now defaults to false (real payments).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:03:12 +03:30
soroush.asadi 4739018488 feat(avatars): show the uploaded profile photo everywhere
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m35s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m17s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m12s
Previously the uploaded profile photo only appeared in a few places (profile,
top bar, leaderboard, public profile); chat, friends, game table, match intro,
post-match roster and private rooms showed the emoji avatar only.

- carry avatarImage end-to-end:
  - server DTOs: FriendDto, SeatPlayerDto, RoomPlayerDto, MatchmakeRequest +
    Player/SeatSlot/PSeat; ResolveProfile now returns avatarImage; FriendDtoFor
    fills it from the profile.
  - client types: Friend, RoomSeat.player, MatchmakingState.players,
    ServerSeatPlayer, SeatPlayer (adds avatarId + avatarImage).
  - signalr-service: send my avatarImage on StartMatchmaking/CreatePrivateRoom;
    carry it through mapRoom.
  - game-store: applyServerState + newOnlineMatch + offline match now populate
    avatarId/avatarImage (seat 0 uses your own profile photo).
- render every avatar through the shared <Avatar> component (image → emoji
  fallback): ChatScreen, FriendsScreen (requests/friends/chats), GameTable
  seats, MatchIntroOverlay, MatchPlayersList, RoomScreen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:17:27 +03:30
soroush.asadi e5b48ecb26 feat(audio): music off by default — sound effects only
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m33s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 2m14s
Background music now defaults to OFF; the default experience is SFX only.
Players can still enable music (santoor track) in Profile → settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:36:23 +03:30
soroush.asadi 23b3713b44 fix(online): green-felt freeze — replay initial state to late subscriber
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m55s
CI/CD / CI - Web (tsc + next build) (push) Has been cancelled
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
Root cause: the server sends matchFound then immediately broadcasts the first
state, but the client only subscribes to state inside enterServerMatch, which
runs a React effect later — so the ordered "state" message is dispatched while
there are no subscribers and is dropped. The server then waits for the human
hakem's trump choice that can never come → permanent freeze on the green felt.

- signalr-service: cache lastState; replay it to a late onState subscriber on a
  microtask (after enterServerMatch resets its store); clear the cache on every
  fresh-match entry (startMatchmaking / createRoom / acceptInvite) so a finished
  game's final state is never replayed into a new match.
- safety net: if no state lands within 2.5s of matchFound, the client invokes
  the new Resync hub method; server re-sends the current state to that player.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:33:07 +03:30
soroush.asadi f97354167d tune(mm): cap the solo wait at 25s (was 75s)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m13s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m5s
- full table of 4 humans still starts instantly at any time
- at the 15s checkpoint, 2+ humans start together (bots fill empty seats)
- a lone player now waits until a precise 25s deadline, then AI fills and starts
- lower the client "connection stuck" hint to 40s to match the shorter wait

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:18:49 +03:30
soroush.asadi c0e3fdb046 feat(mm): wait longer for a real opponent; add "start with bots now"
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 34s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m8s
- server: a lone player in the online-league queue now keeps waiting (re-checking
  every 15s) up to 75s so an online opponent has a real chance to join; the moment
  a 2nd human queues they're matched together, and a full 4 still forms instantly.
  Add PlayNow hub method to force-start with bots on demand.
- client: matchmaking screen shows a "شروع با ربات / Start with bots" button after
  a few seconds so the player can skip the wait; waiting copy updated; raise the
  "connection stuck" hint threshold to 90s so it no longer fires during normal waits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:12:48 +03:30
soroush.asadi 9901c5e6d4 feat(audio,site): calm santoor default music + card-fan logo site redesign
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m0s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m18s
- audio: default background music is now the santoor track (calm Persian),
  rebuilt as a real plucked-santoor loop — fast metallic attack, shimmer
  overtones, soft tonic drone, longer Dastgah-e-Shur phrase
- site: marketing logo is now the app's card-fan icon (Logo.tsx + icon.svg);
  hero features the big logo with gold halo, floating suit motifs, and
  polished section dividers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:48:59 +03:30
soroush.asadi 6aa4f37642 fix(mm): pro players also wait the 15s queue; compact post-match roster
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 4m45s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m33s
- server: remove pro instant-start so all players queue for up to 15s,
  giving real players a chance to seat together before bot-fill
- post-match: render the 4 seats as a horizontal strip so every player
  is visible at once without scrolling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:31:18 +03:30
soroush.asadi 60d44100a2 ui(post-match): compact the result modal so it fits mobile without scrolling
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 37s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m23s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m1s
The end-of-match modal (title + reward rows + XP bar + full roster + button)
was too tall on phones and scrolled. Shrink the mobile sizes (padding, emoji,
title, hero-coins, spacing) with sm: bumping back up on larger screens, and
tighten the player roster rows. Fits a portrait phone in one view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:43:36 +03:30
soroush.asadi d932dbbb52 feat(game): drag a card up to the board center to play it
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 25s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 56s
Playable hand cards are now draggable (Framer drag + dragSnapToOrigin): drag one
up toward the table center and release to play it; release short and it snaps
back. Tapping still plays as before. touch-action:none so the drag gesture works
on mobile without scrolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:52:40 +03:30
soroush.asadi e1e3a716a4 ui(game): minimal, smaller scoreboard
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 5m46s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m5s
Compact the in-game scoreboard to a single small pill: team label + score with
trick count inline in parens (e.g. "0 (3)"), a thin dot separator, and a tiny
target number — dropping the tall 3-line columns and large fonts. Frees more
HUD room and reads at a glance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:18:37 +03:30
soroush.asadi d05cce6550 feat(payments): route coin purchases through FlatRender Pay broker
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 56s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 3m38s
ZarinPal only accepts callbacks on pay.flatrender.ir, so bargevasat
pays through the shared broker and is credited via a signed webhook.

- FlatPayService: broker client (HMAC-signed /v1/pay/request) + webhook
  signature verification + in-memory idempotency guard.
- Program.cs: /api/coins/pay/request prefers the broker when configured
  (FlatPay__ApiKey/Secret set), else the legacy direct ZarinPal path;
  new public POST /api/coins/pay/webhook verifies the HMAC and credits
  coins from the echoed metadata (idempotent).
- appsettings + docker-compose: FlatPay config (empty ⇒ legacy path).
- web: recognise the broker's ?status=Paid return + re-refresh profile
  (coins are credited server-side via webhook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:36:12 +03:30
soroush.asadi 8262fa79b3 chore: trigger redeploy (long-poll hub transport)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 55s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 35s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:10:40 +03:30
soroush.asadi fefa9e2e3a fix(signalr): force Long-Polling transport so the hub connects through nginx
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 47s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m11s
Server logs showed REST working but ZERO hub activity — the SignalR WebSocket
upgrade isn't getting through the nginx/CDN stack and auto-fallback wasn't
recovering, so StartMatchmaking never reached the server (matchmaking spun
forever). Force the HttpTransportType.LongPolling transport — plain HTTP that
already works (same path as REST); SignalR holds the poll open so it's
effectively real-time for a turn-based game. Revertable once the api block
proxies WS upgrades.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:37:04 +03:30
soroush.asadi f059065d4b ui(matchmaking): always show your own avatar in the first seat
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 26s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 56s
While searching, seat 0 now shows the current player's avatar/name/level
immediately (server matchmaking only sent a count, so all seats showed "?").
Matched opponents fill the remaining seats as they arrive; the rest stay "?".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:00:51 +03:30
soroush.asadi 99b9ee5c91 fix(game): center played cards — bake -50% into Framer transform (RTL)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 51s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 56s
Root cause: the trick cards used a Tailwind -translate-x-1/2 -translate-y-1/2 to
center on the felt, but Framer Motion owns `transform` (from x/y/scale), so that
centering class was clobbered. In RTL the auto-positioned card then anchored to
the right edge and the whole trick cross drifted left of center.

Fix: drop the size-0 anchor; position each card at left-1/2 top-1/2 and use
Framer `transformTemplate` to prepend translate(-50%,-50%) before the animated
translate(x,y) scale — so centering survives and the pile sits dead-center in
both LTR and RTL. Burst particles re-centered too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:34:43 +03:30
soroush.asadi 7c6c9fcd90 fix(game): center the trick area in RTL (felt no longer overflows its container)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 38s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m3s
The felt was w-[min(96vw,560px)] inside a p-3 flex container; on phones 96vw
exceeds the padded width, and an overflowing flex item under justify-center in
an RTL layout pins to the start (right) and overflows left — so the trick area
(centered on the felt) drifted off-center. Added max-w-full to cap the felt to
its container so justify-center truly centers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:03:57 +03:30
soroush.asadi c287c7d62c ui(game): compact trump/speed badges on mobile so the scoreboard fits
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 50s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m3s
On narrow phones the top HUD crowded the scoreboard against the trump/speed
badges + action buttons. The badges now show icon/symbol-only on mobile (labels
hidden < sm) with tighter padding, freeing horizontal space so the scoreboard
renders cleanly. Buttons stay shrink-0; scoreboard keeps shrink min-w-0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:27:27 +03:30
soroush.asadi 868bef0c56 revert(signalr): restore negotiate + auto-transport (CDN now bypassed)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 42s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 54s
api.bargevasat.ir is now CDN-bypassed (origin answers directly), so the
negotiate POST works again. Drop the WS-only skipNegotiation workaround and use
the standard negotiate flow, which auto-falls back WS → SSE → long-poll if a
WebSocket upgrade isn't available.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:07:58 +03:30
soroush.asadi 21fd5c123e fix(signalr): skip negotiate, connect WebSockets-only (CDN 404s the POST)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 50s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s
WCDN rejects the SignalR negotiate POST (404, wcdn-nfc-reason: Http_Method), so
the hub never connects and online matchmaking never starts. Connect directly via
WebSockets with skipNegotiation so there's no negotiate POST; the JWT rides the
?access_token query the server already accepts for /hub. The proper fix remains
bypassing the CDN for api.bargevasat.ir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:53:16 +03:30
soroush.asadi 76c4b68a74 auth: store-review test login + matchmaking no-hang/watchdog
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 56s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m7s
- OtpService: a designated test phone (default 09120000000 / code 453115,
  overridable via Sms__TestPhone/Sms__TestCode) skips real SMS and always
  verifies — for Google Play / Bazaar / Myket reviewers. Give them these creds.
- Matchmaking UX: tapping a league now navigates to the matchmaking screen
  BEFORE awaiting the SignalR handshake, so the button can't freeze. Added a
  watchdog hint after 28s ("connection took too long, cancel & retry") so it
  never spins forever when the hub doesn't connect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:40:01 +03:30
soroush.asadi a35acea7e4 feat(rooms): real server-side private games with friend invites (no bot swap)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 27s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m16s
Private rooms were 100% client-simulated (the "friend" auto-accepted then bots
filled invited seats). Now they're server-authoritative over SignalR:

Server (GameManager.PrivateRooms + GameHub):
- Room registry with create/invite/accept/decline/addBot/clearSeat/start/leave.
- Invite pushes a `roomInvite` to that user (Clients.User); the seat stays
  "invited" (a pending guest with their real profile, resolved server-side) — it
  is NEVER replaced by a bot.
- StartPrivate refuses while any invite is pending; only EMPTY seats fill with
  bots. Then it spins up a live GameRoom and matchFound → both devices enter.
- Host leave / disconnect closes the room (roomClosed); members free their seat.

Client:
- signalr-service implements the room methods over the hub (+ room/roomInvite/
  roomClosed events, room mapping, onRoomInvite); mock keeps offline no-ops.
- online-store accept/declineInvite; RoomScreen blocks "Start" while an invite
  is pending and auto-enters the live game on matchFound (host + friend).
- New global InviteModal (accept/decline) + i18n (invite.*, room.waitAccept).

Addresses: (1) no bot replacement, (2) game waits for acceptance, (3) invited
friend shown as a pending guest with their name/avatar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:59:28 +03:30
soroush.asadi 6530096994 music: re-enable background loop (default = playful) + profile on/off; coins 2000
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 28s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m8s
- sound.ts: restored startMusic (was a no-op stub) playing the selected track
  through musicGain (in-game mute still applies); default track switched to
  "playful" (per user). Music auto-starts on init when enabled.
- Profile → Audio: re-added a music on/off toggle (so players can disable it
  outside the game too); SFX toggle unchanged.
- Economy tune: starting coins 1000 → 2000 (mock defaultProfile + server
  ProfileDto) so new players start a bit richer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:23:08 +03:30
soroush.asadi 6502b17356 balance(achievements): strictly-escalating milestone coin rewards
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 32s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s
The old reward formula rounded (40+6g)/50 on the raw goal, so adjacent
milestones could pay the same (e.g. "1 win" and "5 wins" both 50) and the curve
was lumpy — not a standard escalating-reward ladder. Reward now scales by tier
index: 50, 150, 250 … capped at 1500, strictly increasing per milestone.
Mirrored client (gamification.ts) + server (Gamification.cs) so live grants match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:28:08 +03:30
soroush.asadi 974a6bf0ae feat: shop buy-coins CTA, pin chats (max 3), surrender cooldown, OTP hardening
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 29s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m6s
- Shop: when short on coins the detail sheet now shows "{n} more coins" + a
  "Get coins" CTA that opens the buy-coins page (was a dead disabled button).
- Chat: pin/unpin conversations (max 3, persisted to localStorage); pinned float
  to the top with a gold pin. i18n chat.pin/unpin/pinLimit.
- Surrender: server now rate-limits forfeit asks at a human teammate
  (45s per-user cooldown) so it can't be spammed. (Bot teammate still ends
  immediately; teammate confirm dialog already existed.)
- OTP login hardening: Kavenegar send now parses the API status from the body
  (HTTP 200 can still be a failure) + logs it + 12s timeout; client auth fetch
  gets a 20s AbortController timeout so a lost response surfaces an error
  instead of freezing on "sending…".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:01:14 +03:30
soroush.asadi 97d3a02a3c feat: new "card fan" app icon — web favicon/PWA + Android adaptive
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 41s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m15s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m6s
- Master SVGs + generator in scripts/icon/ (icon.svg full design, icon-foreground.svg
  cards-only for the Android adaptive layer, gen-icons.mjs via sharp).
- Web/PWA: regenerated favicon.ico (16/32/48), src/app/apple-icon.png, public/icon.svg,
  icon-192/512, icon-maskable-512; manifest now lists png + maskable icons.
- Android (Capacitor): ic_launcher / ic_launcher_round / ic_launcher_foreground for all
  densities + ic_launcher-playstore 512; adaptive background switched from flat white
  to a navy radial-gradient drawable (matches the icon), foreground = the gold card fan.

Design: navy field, gold rounded frame, three fanned cards with a gold spade —
on-brand with the in-app "Persian luxury" look.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 04:30:34 +03:30
soroush.asadi bc695bc8e9 feat: OTP rate limit, private-room invite UX, in-game UI fixes
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 54s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m11s
Auth / security
- Rate-limit real SMS OTP sends (dev mode unlimited): 60s resend cooldown,
  5 per phone/hour, 300/hour global backstop. OtpService.CheckAndRecordRate;
  POST /api/auth/otp/request returns 429 {error,retryAfter}; AuthScreen shows
  auth.rateLimited. Knobs in appsettings Sms (Sms__* env).

Private rooms (invite)
- Cancel-invite button on pending seats; friend picker shows presence
  (online/offline/in-game, sorted online-first) and flags in-game players.
- Mock invite stays pending ~3.5s and a cancel truly stops the auto-accept
  (was a bug that re-seated cancelled invites).

In-game UI
- Scoreboard is compact + shrink-safe (no overflow on narrow screens).
- Played trick cards land dead-center (were ~2px off the corner anchor).

Plus the in-flight typing-indicator work (GameHub, ChatScreen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:30:20 +03:30
soroush.asadi 78878efc22 fix(auth): fully clear profile on logout (no stale name/gender after sign-out)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 25s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m4s
The mock service intentionally KEPT the persisted profile (hokm.profile) on
signOut, and getProfile() reloads it — so after logout the previous user's
name/gender/avatar resurrected from localStorage. Now signOut clears the
in-memory + persisted profile, and the SignalR service also clears its mock
fallback so the post-logout guest profile is fresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 09:17:49 +03:30
soroush.asadi 53759be8b7 ui: raise in-game emoji button above the hand + gender = male/female/unknown
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 29s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m13s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m4s
- GameTable reactions button (and its tray) moved up from the bottom-right so it
  no longer overlaps the player's cards on mobile portrait.
- Gender options are now Male / Female / Unknown — removed "other" from the
  Gender type, GENDER_META, and the profile picker; the empty value renders as
  «نامشخص» / "Unknown".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:59:15 +03:30
soroush.asadi 1954992203 fix(auth): advance to OTP code step in production + clear profile on logout
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 39s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 59s
- AuthScreen gated the code-entry step on devCode != null, so with real SMS
  (no devCode) it got stuck after "send". Gate on a `sent` flag instead; add
  sending state, send-failure message, "code sent" hint, change-number, and
  raise the code input cap to 6 (codes are 5 digits).
- signOut now resets the store to a fresh guest profile, and the SignalR
  service clears its cachedProfile — so the previous user's name/avatar no
  longer linger after logout.
- i18n: auth.sending / sendFailed / codeSent / invalidPhone / changeNumber.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:21:20 +03:30
soroush.asadi fdf4235fbd feat(auth): real SMS OTP via Kavenegar (replaces the mock 1234 code)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 50s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 34s
- OtpService: generates a 5-digit code, stores it (in-memory, 120s TTL, max 5
  tries, single-use), and sends it via Kavenegar verify/lookup
  (template "hokmotp", %token = code). Normalizes +98/98 → 09xxxxxxxxx.
- /api/auth/otp/request + /verify now use it. No SMS_API_KEY ⇒ dev mode
  (accepts a fixed code, returns devCode for local testing).
- Config: Sms section (appsettings) + Sms__* compose mapping + SMS_* in the
  ENV_FILE template.

Security: sanitized deploy/ENV_FILE.example back to placeholders (it had picked
up real secrets) and added /deploy/ENV_FILE.local to .gitignore as the real
master copy (never committed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:50:33 +03:30
soroush.asadi 83d9c1c7d0 fix(iab): correct Myket purchase verification to the documented POST /verify API
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 29s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m6s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 41s
Myket's server-to-server validation is POST
/api/partners/applications/{pkg}/purchases/products/{sku}/verify with the
purchase token in the JSON body ({"tokenId": ...}) + X-Access-Token header —
not a GET with the token in the path. purchaseState 0 = valid.
Ref: https://myket.ir/kb/pages/server-to-server-payment-validation-api/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:12:57 +03:30
soroush.asadi 9cce016b90 config: fix IAB package name + flatten Production Iab example
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 28s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m7s
- appsettings.json + docker-compose default: PackageName com.bargevasat.hokm →
  com.bargevasat.app (the validate API URL embeds it; wrong value breaks
  Bazaar/Myket token verification).
- appsettings.Production.json.example: Iab keys were nested (Iab.Bazaar.*,
  Iab.Myket.*) which don't bind to the flat IabOptions; flattened to
  PackageName / BazaarClientId / ... / MyketAccessToken.

MyketAccessToken is already wired end to end: ENV_FILE IAB_MYKET_ACCESS_TOKEN →
compose Iab__MyketAccessToken → IabOptions.MyketAccessToken → VerifyMyket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:33:58 +03:30
soroush.asadi d1bd279eba feat(iap): native Myket in-app billing plugin (AIDL) + wire purchase/consume
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 24s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m1s
Implements real Myket IAB for the Capacitor app (Myket has no purchase
deep-link like Bazaar — it uses the classic Google Play IAB v3 AIDL bound to
the Myket app):

- AIDL: com.android.vending.billing.IInAppBillingService (Myket-compatible).
- MyketBillingPlugin (Capacitor): binds ir.mservices.market via
  "ir.mservices.market.InAppBillingService.BIND", runs getBuyIntent →
  startIntentSenderForResult, verifies INAPP_DATA_SIGNATURE with the RSA key
  (Security.java, SHA1withRSA), returns the purchaseToken; consume() too.
- MainActivity registers the plugin + forwards the purchase activity result.
- Manifest: ir.mservices.market.BILLING permission + <queries> for Android 11+
  package visibility.
- build.gradle: enable buildFeatures.aidl (AGP 8 disables it by default).
- storeBilling: Myket goes through the plugin (RSA key embedded); after server
  verify, BuyCoins consumes the purchase so coins can be re-bought.

Bazaar (deep-link) and web (ZarinPal) paths unchanged. Needs on-device testing
with the Myket app installed + published products.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 20:59:56 +03:30
soroush.asadi 7dbadee406 release: bump to v1.1 (versionCode 2) + record store billing public keys
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 28s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 35s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:57:01 +03:30
soroush.asadi 05945f215d add 9:16 store-screenshot capture script (Myket)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 1m53s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:43:23 +03:30
soroush.asadi 8ffdc6a5b1 iap: per-release payment flavors (web=ZarinPal, bazaar, myket)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 38s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m28s
Payment provider is baked at build time via NEXT_PUBLIC_STORE and selected by
storeBilling.getStore(). Add cross-env + flavor build scripts:

  npm run build:web | build:bazaar | build:myket     # web bundle per flavor
  npm run cap:bazaar | cap:myket                      # build flavor + cap sync
  npm run aab:bazaar | aab:myket                      # signed AAB (bundleRelease)
  npm run apk:bazaar | apk:myket                      # release APK (assembleRelease)

web → ZarinPal gateway, bazaar → Cafe Bazaar IAB (deep-link), myket → Myket IAB
(native bridge). A plain native build with no flavor still falls back to bazaar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:56:32 +03:30
soroush.asadi cd5742d623 iap: wire coin packs to Cafe Bazaar SKUs + auto-select Bazaar billing in the APK
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 32s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m7s
- Pack ids now equal the Bazaar/Myket SKUs (Coin5K / Coin12K / Coin28K /
  Coin65K) in both the server source-of-truth (ProfileService.Packs) and the
  mock, so the IAB credit path (Id == ProductId) grants the right coins.
  Coin totals + prices already matched the registered products.
- storeBilling.getStore(): when running inside the Capacitor Android shell and
  no explicit NEXT_PUBLIC_STORE flavor is set, default to "bazaar" so the APK
  uses Cafe Bazaar IAB (the web build stays on the ZarinPal gateway). Myket's
  native bridge still overrides when present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:37:13 +03:30
soroush.asadi bf5b07962d add promo-video generator script (Playwright record → ffmpeg mp4)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 52s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 59s
scripts/promo.js builds an animated portrait promo from the store screenshots
(branded slides + Persian captions + Ken Burns), records it with Playwright,
and is encoded to store-assets/promo.mp4 via the system ffmpeg. Output dir
gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:08:33 +03:30
soroush.asadi 66c83991d4 portrait-only: drop landscape rotate prompt + lock to portrait
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 38s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m6s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m24s
- Remove RotatePrompt (the "rotate to landscape" overlay) — the app is portrait
  now, so it only blocked the UI.
- page.tsx: best-effort orientation lock switched landscape → portrait.
- Add Playwright-based store-screenshot + icon scripts (scripts/shots.js,
  game.js, icon.js); generated images are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:33:01 +03:30
soroush.asadi 7f08249fa7 fix(iab): correct package name to com.bargevasat.app + slot for Bazaar RSA key
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 36s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m5s
- storeBilling.ts and IabService PackageName defaulted to com.bargevasat.hokm,
  but the real app id is com.bargevasat.app (capacitor + android applicationId).
  The mismatch would break Bazaar deep-link purchases and server validation.
- Add IabOptions.BazaarRsaPublicKey to hold the Bazaar in-app billing RSA public
  key (documented; for the Poolakey local-signature flow, unused by the current
  deep-link + server pardakht verification).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 08:55:17 +03:30
soroush.asadi 6c431fee3e portrait: lock orientation + portrait-optimized felt table
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 38s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 57s
- Lock the app to portrait: AndroidManifest screenOrientation="portrait" and PWA
  manifest orientation "portrait".
- GameTable felt now occupies the middle band (between top HUD and the hand) with
  portrait proportions (w<=560, tall) so the you/partner/opponents diamond fits a
  tall screen comfortably instead of a wide landscape ellipse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 07:51:04 +03:30
soroush.asadi a7c0900c3b ui: unified rounded navbar everywhere, vertical home actions, no bot disconnect spam
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 8m9s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 57s
- NavRail: one rounded "pill" tab bar on every screen (matches home). ScreenShell
  lays out as a portrait column and floats the nav with margins + safe-area;
  dropped the landscape side-rail variant.
- Home: the three mode cards now stack vertically as full-width rows (portrait
  friendly) instead of a 3-up landscape row.
- Disconnect: removed the simulated random opponent "disconnect" in local games
  (DISCONNECT_CHANCE) and the in-game DisconnectBanner — bots/filled seats just
  auto-play their turn; no message, no pause. (Live reconnect grace still tracked
  internally but no longer shows a banner.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:12:26 +03:30
soroush.asadi 55c0407d73 build(android): release signing + mirror/JDK setup; native-feel CSS
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 4m5s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m6s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m1s
- Release signing via android/keystore.properties (git-ignored); build.gradle
  signs release builds when the props file is present, stays unsigned otherwise.
- android/mirror-init.gradle: injects the myket.ir Maven mirror into every
  project's buildscript (dl.google.com is unreachable here) and pins Build-Tools
  to the installed 36.0.0. Build with:
    gradlew assembleRelease bundleRelease -I mirror-init.gradle
  (JAVA_HOME must point at a JDK 21 — Capacitor 8 compiles against Java 21.)
- gitignore keystores, keystore.properties, and /dist artifacts.
- Native-app feel: kill tap-highlight, long-press callout, and stray text
  selection (inputs/messages opt back in); touch-action: manipulation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:34:15 +03:30
soroush.asadi 857287fa84 mobile: fullscreen (immersive Android + PWA) + auto-hide reported nudity avatars
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 23s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m3s
Fullscreen on mobile:
- Android (Capacitor): MainActivity now runs edge-to-edge and hides the status +
  navigation bars (immersive, transient-on-swipe), re-asserted on focus.
- PWA: manifest display -> "fullscreen" with display_override fallback chain;
  viewport gains viewport-fit: cover for proper safe-area/edge-to-edge handling.

Moderation auto-hide:
- ProfileService.ReportUser now de-dupes nudity reports per reporter and, once
  NudityHideThreshold (3) distinct players flag a target's avatar as nudity,
  auto-removes their custom photo (reverts to default avatar). Counted from the
  ledger, so still no schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:32:49 +03:30
soroush.asadi 6641741669 feat: photo upload at level 3 + report a player (nudity avatar / chat insult)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m58s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s
Photo upload:
- Lower the custom profile-photo gate from level 25 to level 3 (client const +
  i18n hint + server gate in ProfileService.Update). The level-25 "Expert" title
  is unrelated and unchanged.

Report a player:
- New ReportReason type + service.reportUser(targetId, reason, details?).
- Report entry points: a "گزارش تخلف" button + reason picker (nudity / insult /
  other) in the public-profile modal, and a flag button in the chat header
  (reports the peer for an insulting chat) with a confirmation toast.
- Mock records reports to localStorage; SignalR POSTs /api/report.
- Server: POST /api/report → ProfileService.ReportUser stores the report in the
  write-only ledger (kind="report", ref="{targetId}|{reason}|{details}") so no
  schema change is needed (server uses EnsureCreated, not migrations).
- i18n: report.* keys (fa + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:12:02 +03:30
soroush.asadi 8033023a1f matchmaking: deterministic 15s wait before bots fill empty seats
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 22s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m4s
Both the mock and the .NET server already waited then bot-filled, but used a
random 12-18s window. Make it exactly 15s on both sides so the rule is clear:
wait 15s for real online players to join, then replace any unfilled seats with
bots and start.

- client: new MATCH_QUEUE_WAIT_MS = 15000 in gamification.ts; mock beginSearch
  uses it instead of randInt(12000,18000).
- server: GameManager QueueWaitMs = 15000 (was randomized 12-18s per ticket).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:27:46 +03:30
soroush.asadi ad5b42db06 feat(profile): "set your city" gamification box → one-time 500-coin reward
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 28s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m6s
- New searchable city picker (src/lib/iran-cities.ts, ~60 Iranian cities,
  fa/en search) shown as a gold reward card at the top of the profile Basic tab.
- First time a non-empty city is set, the player earns 500 coins (CITY_REWARD),
  granted server-authoritatively. Collapses to a compact summary afterwards with
  a "change city" option (no re-reward).
- Frontend: UserProfile.city + cityRewardClaimed; mock-service grants on first
  set; session/service updateProfile accept `city`; celebratory toast + sfx.
- Backend (.NET): ProfileDto.City/CityRewardClaimed (JSON blob → no migration);
  ProfileService.Update grants +500 once and writes a "city" ledger entry.
- i18n: city.* keys (fa + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:11:45 +03:30
soroush.asadi efefbcec3d Lobby: leagues are play buttons w/ arrow; remove background music feature
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 35s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m14s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 58s
- OnlineLobbyScreen: each league row is now a tappable play button (queues a
  ranked match at that league's stake) with a forward arrow; the cheapest
  enterable league is highlighted gold. Drops the redundant separate "ranked
  random" CTA and the select-then-play step.
- Remove the background-music feature entirely: deleted the floating MusicToggle,
  the TopBar music button, and the Profile audio music toggle + style picker.
  sound.startMusic() is now an inert no-op so music never plays (sfx unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:23:26 +03:30
soroush.asadi deb83cf77c UX: landscape result screen, chat emojis, unread badges, remove XP text
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 4m33s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 55s
- PostMatchRewardsModal: short-height (landscape) compaction so the win/forfeit
  result fits without overflow (smaller emoji/coins/padding, max-h 94dvh, wider).
- Chat: emoji/sticker picker (owned reactions) — tap to send; hidden on focus.
- Unread messages: online-store now tracks a total `unread` (from
  listConversations); NavRail Friends icon shows a badge (unread + requests),
  refreshed every 12s on every screen; Friends «پیام‌ها» tab badged too.
  (Per-conversation unread badges already existed.)
- Remove "XP گران است" / "XP is expensive" from shop.xpHint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 14:58:43 +03:30
soroush.asadi 24a2c251ad UX batch 2: room landscape-fit, rank vs league naming
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m43s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m6s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 58s
- Room: teams side-by-side in landscape so all 4 seats fit (still scrolls).
- Achievements: rename the 5 rating tiers from «لیگ» (league) to «رتبه» (rank)
  + category «رتبه» — so "league" only means the 3 playable match leagues.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:21:28 +03:30
soroush.asadi 494683b63b UX batch: lobby trim, private stake, coin shop, minimal toast
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m13s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m6s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 58s
- Lobby: remove private-room CTA (it's on Home now) → fits without scroll.
- Home: private rooms now cost 150 coins/player (stake 150).
- Buy Coins: drop the "secure payment" note; redesign packs as game-shop coin
  boxes (coin pile + amount + gold buy-price CTA), 2/3/4-col responsive.
- Notifications: minimal single-line corner toast, explicit ✕ close, hidden
  during play so it never disturbs the game.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:09:19 +03:30
soroush.asadi 3d3241b976 UNO polish: center nav-rail items, drop per-page XP bar, shop category tabs
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m33s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m6s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s
- NavRail: vertically center items in the side rail (was top-aligned).
- ScreenHeader: showXp defaults off — the level/XP bar no longer clutters every
  sub-page (it lives on Home's chip + the Profile page).
- Shop: category tabs (avatars / fronts / backs / reactions / stickers / titles
  / XP) so only one category shows at a time — no more endless scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:53:10 +03:30
soroush.asadi 34678c4e0e Home: center the content in a max-width stage (fixes desktop right-stacking)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m46s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 58s
The Home screen wasn't centered like the sub-pages, so on desktop/tablet its
content drifted to the RTL start edge with dead felt on the left. Wrap it in a
centered max-w-3xl/landscape:max-w-5xl stage, vertically center the mode cards,
and size them up for tablet/desktop (min-h + larger max-w).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:17:59 +03:30
soroush.asadi 5e726e88ba UNO refactor: panel-ize Auth card + Room friend-picker modal
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m24s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 59s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:42:49 +03:30
soroush.asadi ac05a7b679 UNO refactor (stage 2): responsive list/grid screens + chat
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 46s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 51s
Make all menu screens use the width on desktop/landscape and the UNO panels:
- Shop item grid 3→up to 6 cols; BuyCoins packs 2→4 cols on lg.
- Lobby: panel league pick (2-col) + 2-col CTA buttons.
- Achievements / Notifications / Leaderboard / Friends lists → responsive
  grids (1 col mobile, 2 cols on lg); glass→panel on section containers.
- Chat: centered max-w-3xl column on desktop, green send button.
All responsive for mobile + desktop. tsc + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:35:56 +03:30
soroush.asadi 5c00f44fdc UNO refactor (stage 2): Profile → tabbed 2-panel layout
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 4m6s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 59s
Restructure ProfileScreen UNO-style: tabs (نمایه / مجموعه / تنظیمات).
- Basic: player card (avatar/level/name/rank/coins/XP/VIP) BESIDE a stats grid
  + «زندگیِ حکم» ribbon + achievements summary (landscape 2-column).
- Collection: avatar / title / card-front / card-back pickers in panels.
- Settings: social + audio + sign-out.
All glass cards → .panel; every handler/feature preserved. New profile.tab*/
lifeRibbon i18n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:29:11 +03:30
soroush.asadi 5b2fddee4a UNO home: mode cards + bottom nav bar
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 23s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m4s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 58s
Rebuild HomeScreen to UNO's home layout: top bar (avatar+coins) + 3 big glossy
3D mode cards in the center (Online[gold,live-count badge] / vs-Computer[teal] /
Private Room[violet]) + a bottom icon nav bar (NavRail bottom variant, drops the
redundant home item). Speed toggle + language sit in a slim controls row. Online
card shows live player count; room card creates a private room then enters it.
New menu.room/menu.roomDesc i18n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 06:59:15 +03:30
soroush.asadi 8efd357289 UNO refactor (stage 1): emerald felt theme + kit + full Home redesign
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 32s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m5s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 58s
- Theme: retint to lit emerald card-table felt + gold (body radial felt, green
  glass panels). New component kit in globals.css: glossy chunky 3D btn-gold +
  btn-green, .panel, .ribbon. Card backs pinned to classic navy.
- Home fully redesigned UNO-style: nav rail + branding + two big 3D play
  buttons (gold online / green-glass vs-computer) + speed toggle; dropped the
  redundant 4 tiles (the rail covers them). Fits landscape (short: variants).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 04:21:50 +03:30
soroush.asadi 08d81cba65 UNO refactor (stage 1): hub shell with nav rail + internal-scroll panel
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 22s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m4s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 58s
Rebuild ScreenShell into a UNO-style app shell: a persistent NavRail (vertical
side rail in landscape, bottom tab bar in portrait — Home/Profile/Shop/Friends/
Leaderboard/Achievements, active highlighted gold) + a content panel that owns
its own scroll so the page never scrolls as a whole and uses the width in
landscape. Reskins all 10 menu screens at once. Transient screens (auth,
matchmaking, room) opt out via hideNav. New nav.home i18n key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:56:52 +03:30
soroush.asadi 78dea770d7 Landscape: add short-height variant; fix Home column overflow on landscape phones
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 32s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m5s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 58s
Root cause: a landscape phone is wide (>=640px) but short, so width-based sm:
roominess inflated the title/buttons while the screen height was small -> the
right column overflowed (vs-Computer card cut off). Add a height-based
`short:` variant (@media max-height:520px) and compact Home's branding +
action cards under it so the column fits short landscape viewports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:21:21 +03:30
soroush.asadi cc63312305 site: drop PWA manifest from marketing site (SEO site, not an app)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m24s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m5s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 51s
Remove the web-app manifest link + manifest.ts route so bargevasat.ir no longer
triggers an "install/add to home screen" prompt. It's a plain marketing/SEO
site now. Only the game app (app.bargevasat.ir) remains a PWA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:44:31 +03:30
soroush.asadi 3e37085d18 Landscape: whole-app landscape-first + Home 2-column landscape layout
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 47s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m0s
- Move orientation lock + RotatePrompt to app root → whole app is landscape-
  first now (UNO-style), not just the game. Generalized rotate copy.
- Home: portrait unchanged; in landscape it becomes a 2-column app layout
  (col A = branding + play actions, col B = tiles + footer) that fits the
  short height with no scroll (landscape: Tailwind variants, overflow-hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:33:21 +03:30
soroush.asadi e8b3172197 Game: landscape-first table with rotate-phone prompt + orientation lock
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 1m14s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 59s
Hokm plays best wide (UNO-style). On phones held in portrait, the game screen
shows a "rotate your phone" overlay (with a play-anyway escape hatch so OS
rotation-lock can't trap anyone). Best-effort screen.orientation.lock('landscape')
on Android/PWA; iOS/desktop reject it harmlessly. i18n rotate.* (fa+en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:53:21 +03:30
soroush.asadi c1ecdff729 Mobile: remove floating MusicToggle overlay (overlapped cards/tiles)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m16s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m2s
The fixed-position music button covered content on mobile. Removed it from
the global overlay; mute now lives in the TopBar icon group (Home), and the
in-game HUD + Profile settings already have their own audio controls.
Tightened the TopBar icon row (p-1.5, gap-1, profile max-w-44%) so the extra
button still fits 360px phones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:36:19 +03:30
soroush.asadi 7e9d83e79a Mobile: single-row logo+title on Home; add Sign Out to Profile
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m1s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 59s
- Home: logo and «برگ وسط» now sit on one row (prevents overflow), with
  «بازی حکم آنلاین» as a small subtitle beneath the title next to the logo.
- Profile: add a خروج (Sign Out) button at the bottom (when signed in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:20:37 +03:30
soroush.asadi 48460c6282 Mobile: redesign TopBar profile chip + trim oversized Home actions
CI/CD / CI - API (dotnet build + engine sim) (push) Has been cancelled
CI/CD / CI - Web (tsc + next build) (push) Has been cancelled
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
- Profile chip: replace cramped fixed-width (w-[88px]) 3-line stack with a
  clean 2-line layout (name on top; level · xp-bar · % on a flexing row),
  capped at max-w-[46%] so it never crowds the icon group or overflows.
- Hero "Play online" title text-2xl→text-xl on phones (sm:text-2xl), truncate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:16:20 +03:30
soroush.asadi 6ed9279ac8 site: pin deps to Nexus-available versions + regenerate lockfile via Nexus
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 8m41s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 11m29s
The Docker build 404'd on @types/react@19.2.17 (not in the Nexus mirror; npmjs
is blocked upstream). Pin shared deps to the exact versions the main app uses
(@types/react 19.2.16, etc.) and regenerate package-lock.json against the Nexus
registry so every resolved tarball is one Nexus can serve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:56:43 +03:30
soroush.asadi af3274ae9f Mobile: compact Home vertical rhythm so footer fits without scroll
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m32s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 26s
Logo/title/hero/tiles/footer spacing now scales down on small screens
(sm: breakpoints) so the menu fits common phone viewports — the sign-in/
language footer was being pushed below the fold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:59:07 +03:30
soroush.asadi 29b410eefc Mobile sweep: fix matchmaking slot overflow + profile avatar picker art
CI/CD / CI - API (dotnet build + engine sim) (push) Has been cancelled
CI/CD / CI - Web (tsc + next build) (push) Has been cancelled
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
- MatchmakingScreen: the 4 fixed w-16 slots (~292px) overflowed 320px phones;
  now grid-fluid (w-full, gap-2 sm:gap-3, max-w-xs) so they always fit.
- ProfileScreen avatar picker now renders <Avatar id> (god/legend medallions)
  instead of raw emoji — consistent with the displayed avatar and shop.

Swept Achievements/Leaderboard/BuyCoins/Auth/Shop/Profile/Lobby/Room — already
responsive (ScreenShell + min-w-0/truncate/shrink-0 throughout); no other
overflow found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:55:08 +03:30
soroush.asadi c4513f7b0c Mobile: make in-game/post-match overlays scroll-safe on short screens
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m27s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 27s
- GameTable Backdrop (Hakem/Trump/Round/Match-over): scroll when taller than the
  viewport via overflow-y-auto + min-h-full centering — no more clipped panels.
- DailyRewardModal: cap height + scroll (was overflow-hidden, clipped the 7-day grid).
- PostMatchRewardsModal: max-h uses dvh (mobile chrome safe).
- ScreenShell: add overflow-x-hidden so a too-wide child can't scroll horizontally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:46:20 +03:30
soroush.asadi 5d38312ef0 Marketing site (bargevasat.ir) + admin-editable store links + subdomain split
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 4m40s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 41s
- New standalone Next.js marketing site under site/ (static export, SEO):
  landing, download/install guide (Bazaar/Myket/iOS-PWA/web), FAQ (JSON-LD),
  privacy, terms, support, /admin link editor. fa RTL, sitemap/robots/manifest.
- Backend: SiteLinksService (JSON-file persisted) + GET /api/site/links (public)
  + POST /api/admin/site/links (X-Admin-Token). ADMIN_TOKEN + Site__DataDir via env.
- compose: hokm-site service (:1520) + hokm_data volume for links JSON.
- CI deploy job builds + deploys the site container.
- deploy/SUBDOMAIN_SPLIT.md: nginx blocks, cert reissue, DNS, ENV split.
- Exclude site/ from root tsc + web docker context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:19:43 +03:30
soroush.asadi 8d0d4dc991 Notifications: deep-link on tap + swipe-to-dismiss
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 5m49s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 49s
Each notification now navigates to its related screen when tapped (toast or
list): friend_request/invite -> Friends, achievement/reward -> Achievements,
daily -> opens the daily-reward modal, coin-purchase success -> Shop. An
explicit per-notification 'route' overrides the kind default.

List rows are swipeable (drag aside) and have an X to dismiss individually,
plus a Clear-all button; the toast can be flicked up to dismiss or tapped to
open. New store actions: markRead/remove/clearAll + openNotification navigator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:38:43 +03:30
soroush.asadi 72efc03e2d Shop: every item is coin-priced; level/rank/achievement only gate the purchase
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m29s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
No more earned-only (rank/wins) cosmetics — every avatar, card back/front,
reaction & sticker pack now has a coin price. Rank/wins/achievement become
purchase requirements (coin · coin+rank · coin+rank+achievement), enforced
client (mock + ShopScreen lock label) and server (ProfileService.ItemGate,
keyed by kind:id). Ownership = default + purchased only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:27:25 +03:30
soroush.asadi ccfc9b0536 Redesign avatars as a gods/legends pantheon (custom SVG medallions)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m7s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 2m24s
Replaced the childish animal emoji avatars with custom inline-SVG "deity
medallions" (gradient disc + gold ring + heraldic emblem) — Athena, Zeus,
Poseidon, Horus, Odin, Thor, Cyrus, Simorgh, Ishtar, Nike, etc. IDs unchanged
so owned avatars keep working; Avatar renders the art (emoji fallback for legacy
ids). Shop now shows the art + the god name (was generic "Avatar").

Files: components/online/avatarArt.tsx (new art + pantheon map), Avatar.tsx
(render art), ShopScreen Preview (avatar → <Avatar/>), mock-service avatar shop
names from AVATAR_ART.

Verified: tsc + next build clean; web rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:16:17 +03:30
soroush.asadi fd7bef36d8 fix: never cache HTML shell (no more stale bundles); tidy trick offsets
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m33s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m6s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 2m21s
- nginx: serve the HTML shell with Cache-Control no-store so a new deploy (new
  chunk hashes) is picked up immediately — fixes the recurring stale-bundle
  "page couldn't load" at the source. Hashed /_next/static stays immutable.
- Trick offsets set to a clean symmetric cross.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:04:14 +03:30
soroush.asadi 3dd22aee1e fix: post-purchase crash in CelebrationOverlay (read of null current)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m22s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m5s
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
Card read useCelebrationStore(s=>s.current)! but AnimatePresence keeps it
mounted through the exit animation; after dismiss() sets current=null it
re-rendered and threw "Cannot read properties of null (reading 'levelAfter')",
crashing the page after every purchase/XP/daily celebration. Pass the
celebration as a prop so the exiting card keeps its data.

Verified: tsc + next build clean; web rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:48:45 +03:30
soroush.asadi b0668e6e31 fix: center trick pile; add error boundary (surface post-buy crash)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 1m51s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m4s
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
- Trick area: smaller offsets (±50/52) + retuned scale so the played pile sits
  centered in the felt instead of flung out to the side seats.
- ErrorBoundary around screens + overlays: a render error now shows a recoverable
  in-app message with the cause (and logs componentStack) instead of the browser's
  blank "page couldn't load" — helps pinpoint the post-purchase crash.

Verified: tsc + next build clean; web rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:27:34 +03:30
soroush.asadi 12177d2a33 fix(mobile): smaller trick cards on phones; drop duplicate XP bar on Profile
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 52s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m4s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 2m21s
- Game table: played-card pile now uses sm cards on phones (vw<480) + slightly
  tighter scale, so the center trick no longer crowds/overlaps the side seats'
  avatars on a tall portrait screen.
- Profile: the screen showed two XP bars (the global header bar + the identity
  card's detailed bar). Hide the header bar on Profile (showXp=false).

Verified: tsc + next build clean; web rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:08:12 +03:30
soroush.asadi 3e0c0ed876 fix(topbar): coin balance was clipped — compact large numbers + shrink bar
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 50s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m4s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 2m22s
The Home top bar overflowed on narrow screens; in RTL the coins pill is the
far-left item so its leading digits got clipped (showed "04,240" for 104,240).
- CoinsPill: compact big balances (104,240 → 104K, 1.2M), shrink-0 +
  whitespace-nowrap; exact value in the tooltip.
- TopBar: tighter gaps, profile pill min-w-0 (shrinks/truncates first), icon+coins
  group shrink-0 so it never gets squeezed.

Verified: tsc + next build clean; web rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 06:57:45 +03:30
soroush.asadi 1fba9c2f96 fix(mobile): reward + shop-detail modals scroll on short phones
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 3m0s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 2m21s
Deep-dive responsive audit. The post-match rewards modal used overflow-hidden
and the shop detail sheet had no height cap — both could clip content (long
reward lists / sticker packs) on short or landscape phones. Added
max-h-[90vh]/[88vh] + overflow-y-auto. Audit confirmed Leaderboard, Lobby,
PublicProfile rows/modals already handle min-w-0/truncate/scroll correctly.

Verified: tsc + next build clean; web rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 04:32:14 +03:30
soroush.asadi dcea0bc87c fix: auto-recover from stale-bundle chunk errors; responsive touch-ups
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m15s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 4m39s
- The "This page couldn't load" after a redeploy was a stale bundle: a tab open
  across a deploy requests JS chunks that no longer exist (ChunkLoadError). Added
  a global error/unhandledrejection guard that reloads once to fetch the fresh
  bundle (sessionStorage-guarded against loops, cleared after a healthy run).
- Reaction tray width → w-[min(270px,86vw)] so it never overflows narrow phones.

Verified: tsc + next build pass; web image rebuilt on :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:51:36 +03:30
soroush.asadi 0847d2c7cf fix(deploy): don't let docker compose build require runtime JWT_KEY
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 5m58s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m5s
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
docker compose build interpolates the whole file, so the ${JWT_KEY:?} guard
failed the build step when ENV_FILE lacked JWT_KEY. Default it empty (${JWT_KEY:-})
so build/db steps succeed, and enforce the secret at runtime instead: the server
throws on boot in Production if Jwt:Key is missing/dev/<32 chars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:41:26 +03:30
soroush.asadi ed3e11b64b Music mute everywhere + card-draw SFX
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m17s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 0s
- MusicToggle: global floating button (enable/disable music from any screen;
  hidden on the table, which has its own audio control in its HUD). Uses
  sound-store toggleMusic.
- Card sounds now use a synthesized card-draw "swish" (filtered noise burst with
  a downward sweep) for cardPlay (+ soft landing tap) and deal (a flurry),
  replacing the old beep tones.

Verified: tsc + next build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:21:27 +03:30
soroush.asadi 36600fa494 docs: HANDOFF — one-game, music, prod config, 100 gated gifts
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 6m40s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:04:03 +03:30
soroush.asadi 38ac8b06d1 100 gated gifts (level/rating-locked) + requirement system
CI/CD / CI - API (dotnet build + engine sim) (push) Has been cancelled
CI/CD / CI - Web (tsc + next build) (push) Has been cancelled
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
Adds ~100 new purchasable gifts that are LOCKED until a level/rating gate is met,
then buyable with coins — value scales with the gate:
- 45 gift avatars (types.ts), 35 gift titles + 20 gift card backs (gamification.ts),
  all reusing existing renderers. Tier (1-5) encoded in the id (-t<n>-).
- Gate model: GIFT_TIERS (shared) → reqLevel/reqRating on AvatarDef/TitleDef/
  CardBackDef + ShopItem. Tiers: t1 free, t2 Lv10, t3 Lv20, t4 Lv35, t5 Rating1700.
- Shop UI: locked cards dim + show the requirement (Lock + "Level 20"), buy
  disabled until met; mock buyItem enforces it offline.
- Server enforces generically — ProfileService parses the tier from the id and
  checks the player's level/rating (no 100-entry mirror). Mirrors GIFT_TIERS.
- i18n shop.reqLevel/reqRating (fa+en).

Verified: tsc + sim + next build + dotnet build all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:02:28 +03:30
soroush.asadi e49df07c0f Prod hardening: one-game-per-player, selectable music, bargevasat.ir config
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m47s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s
- One running game per player: server rejects a 2nd matchmake while in a live
  room (re-syncs the existing game); client guards Home vs-computer + Lobby
  random/create — resumes the running match + notifies instead of starting another
  (game-store hasActiveMatch()).
- Background music is now selectable: santoor (سنتی, calm Persian loop) and
  playful (bouncy UNO-like) — sound.ts TRACKS + setMusicTrack (persisted),
  sound-store musicTrack, picker in Profile → Audio. i18n added.
- Production config for bargevasat.ir (prepare-only; no live deploy):
  appsettings.Production.example (CORS + ZarinPal + IAB to the domain),
  docker-compose.caddy.yml + Caddyfile (auto-HTTPS reverse proxy
  bargevasat.ir→web, api.bargevasat.ir→server), ENV_FILE PRODUCTION block,
  PRODUCTION.md go-live + Cafe Bazaar publish/IAB checklist. Fixed IAB package
  name to match Capacitor appId (com.bargevasat.app).

Verified: tsc + next build + dotnet build all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:05:52 +03:30
soroush.asadi 265d878f22 docs: mark match-intro + chat/daily polish DONE in HANDOFF
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m27s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:27:57 +03:30
soroush.asadi 82b2bc0648 Polish: daily reward via celebration overlay + premium chat to recipient
CI/CD / CI - API (dotnet build + engine sim) (push) Has been cancelled
CI/CD / CI - Web (tsc + next build) (push) Has been cancelled
CI/CD / Deploy - local stack (db + server + web) (push) Has been cancelled
- Daily reward now routes through the global CelebrationOverlay: new "daily"
  variant + coins count-up; claiming closes the daily modal and fires
  celebrate({variant:"daily", coins}). Unifies the "you earned X" moment.
- Premium (pro) gold chat is now visible to the OTHER player: ChatMessage gains
  senderPro; server resolves each participant's plan once (SocialService.IsPro)
  and stamps it on ChatMessageDto; ChatScreen styles incoming bubbles with
  .premium-chat when senderPro. Mock marks ~half its friends pro so it's visible
  offline too.

Verified: tsc + next build + dotnet build all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:26:28 +03:30
soroush.asadi 03dfbe1e67 Match intro "players joining" loading screen + i18n fix; checkpoint
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m38s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s
- MatchIntroOverlay: UNO-style pre-game reveal — the 4 seats animate into the
  table (with "?" placeholders until each player's data streams in for live
  matches), a 3-2-1-GO countdown, then the table shows. Wired via game-store
  matchIntroPending/consumeIntro, rendered online-only in GameScreen.
- Fix: intro.found / intro.getReady / intro.go existed only in the Persian dict;
  added the English strings (would have shown raw keys to EN users).
- Checkpoint of the in-progress UI/social batch (CoinsPill, shop titles section,
  friend-request rate limit, etc.) — all green.

Verified: tsc + next build + scripts/sim.ts + dotnet build server/Hokm.slnx all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:58:54 +03:30
soroush.asadi cb27a16dc1 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>
2026-06-06 18:39:24 +03:30
163 changed files with 18102 additions and 1967 deletions
+1
View File
@@ -3,6 +3,7 @@ node_modules
out
android
server
site
.git
.gitea
*.md
+17 -1
View File
@@ -132,7 +132,7 @@ jobs:
if [ -n "$CURRENT" ]; then docker tag "$CURRENT" hokm-server:rollback && echo "rollback tag = $CURRENT"; fi
- name: Build images
run: docker compose build --parallel server web
run: docker compose build --parallel server web site
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
@@ -182,6 +182,22 @@ jobs:
sleep 5
done
- name: Deploy marketing site (stop + rm + up, no force-recreate)
run: |
docker stop hokm-site 2>/dev/null || true
docker rm hokm-site 2>/dev/null || true
docker compose up -d --no-deps site
- name: Wait for site healthy
run: |
for i in $(seq 1 18); do
S=$(docker inspect --format='{{.State.Health.Status}}' hokm-site 2>/dev/null || echo missing)
echo " [$i/18] site: $S"
[ "$S" = "healthy" ] && { echo "OK hokm-site healthy"; break; }
[ "$i" = "18" ] && { echo "TIMEOUT hokm-site"; docker compose logs --tail=40 site; exit 1; }
sleep 5
done
- name: Prune dangling images
if: success()
run: docker image prune -f
+15
View File
@@ -1,5 +1,9 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# local secrets master copy (real ENV_FILE values — NEVER commit)
/deploy/ENV_FILE.local
*.env.local
# dependencies
/node_modules
/.pnp
@@ -25,6 +29,12 @@
# production
/build
# built mobile artifacts (APK/AAB) + release signing secrets
/dist
/android/keystore.properties
*.jks
*.keystore
# misc
.DS_Store
*.pem
@@ -54,3 +64,8 @@ next-env.d.ts
*.db-shm
*.db-wal
# store screenshot artifacts
/scripts/shots/
/store-assets/
/scripts/promo/
+11
View File
@@ -0,0 +1,11 @@
# Caddy reverse proxy for production (bargevasat.ir). Auto HTTPS via Let's Encrypt.
bargevasat.ir, www.bargevasat.ir {
encode zstd gzip
reverse_proxy web:80
}
api.bargevasat.ir {
encode zstd gzip
# SignalR (WebSockets) proxies transparently through Caddy.
reverse_proxy server:5005
}
+28 -9
View File
@@ -60,18 +60,33 @@ 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.
- **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).
- Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards. **Matchmaking waits ~15s (randomized 1218s) for humans, then bots fill** (`GameManager.NextQueueWaitMs`; mock mirrors it in `beginSearch`). MatchmakingScreen shows the elapsed timer + a bot-fill hint.
- **Per-league turn time** (think faster in higher leagues): Starter/vs-AI/private → **15s**, Pro (stake ≥500) → **10s**, Expert (stake ≥1000) → **7s**. Single source: `turnMsForStake(stake, speed?)` in `gamification.ts`; the live server mirrors it in `GameRoom.TurnMs`. The turn-timer bar reads it from `matchMeta.stake`.
- **Speed (Blitz) mode** — CLIENT-ONLY (vs-AI + private rooms; ranked stays standard). Flat **5s** turn clock (`SPEED_TURN_MS`), races to **5** points (`SPEED_TARGET_SCORE`), and ~½ pacing on animations/pauses (the `fast()` scaler in `game-store.scheduleAuto`). Threaded via `matchMeta.speed` + `GameSettings.speed`/`OnlineMatchConfig.speed`. Toggle on Home's vs-Computer card; a `SpeedBadge` (⚡) shows on the table HUD. No server change needed — private rooms are client-driven even in live mode, and ranked is intentionally excluded.
- **Economy:** coins; ranked entry = stake (win +stake [+kot 40], lose stake); free vs-computer/private rooms. Buy-coins via **ZarinPal sandbox** (merchant `299685fb-cadf-4dfc-98e2-d4af5d81528d`, config-driven) + store IAB. **⚠️ Economy is balanced as one system — keep ALL of these in sync client↔server when tuning** (`gamification.ts``Profiles/Gamification.cs` + `ProfileService`): coin packs (`p1` 5,000c/99k﷼ · `p2` 12,000c/199k · `p3` 28,000c/399k · `p4` 65,000c/799k — a starter pack ≈ one premium cosmetic, NOT "buy everything"), item prices (cosmetics 6006,000c, XP packs 1,500/4,000/8,000c), achievement coin reward `min(1500, max(50, round((40+goal·6)/50)·50))`, rank rewards 150/300/500/900/1500, daily `[100,150,200,300,400,600,1500]`. Stores (Bazaar/Myket) **IAB** — see §6.
- **XP/levels:** every game grants XP, **winner ×2**; **premium (pro) ×1.5**; max level 100; curve `100*lvl + 15*lvl²`. **Store sells XP packs** (xp1 +200/5k, xp2 +600/12k, xp3 +1500/25k coins; consumable; unlocks level achievements).
- **Achievements:** ~100, metric-driven generator (categories: victory/kot/streak/hakem/level/rank/veteran), incl. "7× hakem", "70 sweep". Dedicated **AchievementsScreen** (tabbed) + Profile summary. Some unlock **sticker packs**.
- **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, sticker packs (custom SVG art incl. crown/seven-zip/streak-fire). Profile **photo upload gated at level ≥ 25**.
- **Social:** friends + chat **server-persisted** (`Social/SocialService`, REST + hub `friendRequest`/`social`/`chat`); friend remove needs confirm. **Premium chat = animated gold bubbles**.
- **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, **16 sticker packs** (all custom inline-SVG art in `Sticker.tsx`). **✨ Luxury tier** (premium giftable items): luxury avatars (🦢🎩💎💰🏆 + 💠 rank-gated), luxury card backs (Diamond/Black Gold/Platinum/Peacock/Rose-Gold) + fronts (Diamond/Black Gold), luxury titles (Hokm Sultan/Emperor/Grandmaster). Shop tags items priced ≥2000 with a gold **«ویژه/Luxury»** badge + ring.
- **Card backs are pattern-distinct** (not just recoloured): each `CardBackDef` carries a `pattern` (`stripes/argyle/grid/dots/rays/scales/crosshatch/royal/filigree/gem`) + optional `motif` glyph. Rendering lives in `src/lib/cardBack.ts` (`cardBackVisual`/`cardBackMotif`/`backVisualFromDef`), used by `PlayingCard`, the shop preview, and the profile picker so all three match.
- **Purchasable titles:** `TitleDef.price` makes a title buyable; shop **Titles** section (`ShopItemKind` now includes `"title"`, server `ShopBuy` handles `title → OwnedTitles`, mock mirrors it). The equipped title shows **under your name on the table** (`SeatPlayer.title``SeatAvatar`, seat 0 from your profile in every mode incl. live via `applyServerState`) and in the **Discover/find list** (`PlayerSummary.title`, server `ToSummary`). Localize via `titleById(id)`. New themed packs: **کل‌کل/banter** (kolkol, tikeh, shakkak, raghib), **Persian trends/praise** (trends, tashvigh), **court cards** (khanevadeh: تک‌خال/آس دل/شاه خشت/بی‌بی گشنیز), moods (ehsasat). Banter uses the `Stamp` helper (rounded badge + Persian phrase); court cards use `CourtCard`. Profile **photo upload gated at level ≥ 25**. New sticker packs are **client-only** (server `ShopBuy` is generic) — add art to `Sticker.tsx` + an entry in `STICKER_PACKS`.
- **Daily rewards** (boosted): `[300, 500, 750, 1000, 1500, 2500, 7500]`**must stay in sync** between client `gamification.ts DAILY_REWARDS` and server `ProfileService.DailyRewards` (server is authoritative for the claim). Day 7 is the gold "special" tier.
- **Social:** friends + chat **server-persisted** (`Social/SocialService`, REST + hub `friendRequest`/`social`/`chat`); friend remove needs confirm. **Premium chat = animated gold bubbles**. **Friend-request rate limit = 10 / rolling hour** per user (server `SocialService.TryRecordRequest`, static in-memory; mirrored in the mock). Both `addFriend(query)` and `addFriendById(userId)` funnel through it.
- **Public profiles:** tap any player in the **leaderboard / friends list / end-of-game roster**`PublicProfileModal` (global, `ui-store.viewProfile(id)`) shows their identity, stats, and **achievement board** + a rate-limited **Add-friend** button. Server `GET /api/profile/{id}/public``PublicProfileDto` (no coins/phone/email); mock synthesizes deterministic stats seeded from the id. Client `OnlineService.getPublicProfile(id)` / `addFriendById(id)`.
- **Social hub:** `FriendsScreen` is now tabbed — **Friends / Discover / Messages**. **Discover** = find-friends search (debounced) + suggested players, each row taps to the public profile and has a rate-limited Add button. **Messages** = conversation inbox (`listConversations`, unread badges, relative time) → opens `ChatScreen`. New: `OnlineService.searchPlayers(q)` / `suggestedPlayers()``PlayerSummary[]`; server `GET /api/players/search?q=` + `/api/players/suggested` (`SocialService.SearchPlayers`/`Suggested`, online-first, excludes friends/self). Mock synthesizes results.
- **Profile gender + social links:** `UserProfile.gender` (`""|male|female|other`, shown as ♂/♀/⚧ in Discover + public profile + edited in `ProfileScreen`'s `SocialSettings`), `socials` (instagram/telegram/x/youtube handles or URLs, rendered as tappable chips), and `socialsVisibility` (**public / friends / hidden**). Helpers in `src/lib/social.ts` (`GENDER_META`, `SOCIAL_PLATFORMS`, `socialUrl`, `hasSocials`). **Privacy is server-enforced:** `SocialService.GetPublicProfile` only includes `Socials` when `public`, or `friends` && the viewer is a friend, or it's you; `hidden` → never. `PlayerSummary.gender` carried in discovery. Server fields on `ProfileDto` (`Gender`/`Socials`/`SocialsVisibility`); `ProfileService.Update` parses them; `updateProfile` patch widened (client interface + session-store + mock + signalr).
- **Forfeit:** request + teammate-confirm (server `GameRoom` forfeit flow); penalty = **lose 2× coins + 0 XP** (NO kot, and never mention kot); confirm dialog alerts the penalty.
- **End-of-game roster:** `MatchPlayersList` on the final screen (reward modal + AI match-over) lists everyone; **Add-friend** button for real non-bot players (seat `userId` threaded from server).
- **Celebrations:** `celebration-store` + `CelebrationOverlay` — animated XP count-up, level-up pop, achievement unlock; fires from shop purchases (XP/cosmetic) and unlocks. Reusable via `celebrate({...})`.
- **UX/UI:** "Persian luxury" palette (navy/teal/gold, glass) + **UNO-style tactile UX** rolled out to Home (hero Play), Shop (detail sheets), Lobby, Matchmaking, Profile, Leaderboard. Primitives in `globals.css`: `.press-3d` (tactile press), `.safe-top/.safe-bottom/.safe-x` (notch), `.hud-shadow`, `.premium-chat`. Online count floored at **≥50**. Match stays alive on exit (minimize/resume + ResumeGameBar). **No fake/periodic notifications** (removed as spam).
- **Celebrations:** `celebration-store` + `CelebrationOverlay` — animated XP count-up, level-up pop, achievement unlock; fires from shop purchases (XP/cosmetic) and unlocks. Reusable via `celebrate({...})`. Achievement rows (here + `PostMatchRewardsModal`) now reveal the **sticker pack** an achievement unlocks (`stickerPackForAchievement`).
- **Persistent level + XP bar:** `LevelXpBar` (avatar + Lv + progress, taps to profile) shows on Home (`TopBar`) and atop every inner screen (`ScreenHeader`, `showXp` default on) so level/XP is always visible.
- **Buy-coins gateway** opens in a **new tab** (`window.open(_blank)`, same-tab fallback if popup-blocked) so a slow/blocked ZarinPal page can't dead-end the SPA; balance refreshes on window focus. (Fixes the old `window.location.href` "page couldn't load" crash.)
- **UX/UI:** "Persian luxury" palette (navy/teal/gold, glass) + **UNO-style tactile UX** rolled out to Home (hero Play), Shop (detail sheets), Lobby, Matchmaking, Profile, Leaderboard. Primitives in `globals.css`: `.press-3d` (tactile press), `.safe-top/.safe-bottom/.safe-x` (notch), `.hud-shadow`, `.premium-chat`, **`.tap`** (44px min hit area). Online count floored at **≥50**. Match stays alive on exit (minimize/resume + ResumeGameBar). **No fake/periodic notifications** (removed as spam).
- **Accessibility pass:** global **`:focus-visible`** gold ring (keyboard/controller/switch nav — no pointer required); **reduced-motion** honored app-wide via a `@media (prefers-reduced-motion)` block (kills decorative CSS loops) **and** `<MotionConfig reducedMotion="user">` in `page.tsx` (tames all Framer Motion). Cramped 32px icon buttons (Friends accept/decline/msg/remove, Chat back/send, Room invite/bot/clear) bumped to **44px**. Empty states (Friends/Chat/Notifications) and loading **skeletons** (Leaderboard/Shop) added.
- **Capacitor Android APK** builds (Myket maven mirror at root `https://maven.myket.ir`; init script pins buildTools 36 + JDK17). See `ANDROID.md`.
- **CI/CD** (Gitea Actions + Nexus mirror) + Docker stack. See `DEPLOY.md`.
- **Production prep (bargevasat.ir):** `docker-compose.caddy.yml` + `Caddyfile` (auto-HTTPS: `bargevasat.ir`→web, `api.bargevasat.ir`→server), prod `appsettings`/`ENV_FILE` blocks pointing at the domain, and **`PRODUCTION.md`** (go-live + Cafe Bazaar publish/IAB checklist). Prepare-only — deploy/DNS is manual.
- **One running game per player:** server `GameManager.StartMatchmaking` re-syncs an existing live room instead of starting a 2nd; client (`game-store.hasActiveMatch()`) guards Home vs-computer + Lobby random/create → resumes the running match + notifies. Clears on forfeit/finish.
- **Selectable background music:** `santoor` (سنتی) + `playful` (UNO-like), procedural in `sound.ts` (`TRACKS`, `setMusicTrack`, persisted), `sound-store.musicTrack`, picker in Profile → Audio.
- **100 gated gifts:** purchasable cosmetics LOCKED until a level/rating gate (45 avatars in types.ts, 35 titles + 20 card backs in gamification.ts). Tier (1-5) is encoded in the id (`-t<n>-`); `GIFT_TIERS` is the shared gate table (t1 free → t5 rating 1700). Shop shows locked + requirement; **server enforces generically** by parsing the tier (`ProfileService.GiftGateFor`, mirrors GIFT_TIERS) — no per-item catalog mirror. Items reuse the existing avatar/title/card-back renderers + owned lists.
---
@@ -87,10 +102,11 @@ 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.
5. Optional polish — **DONE**: premium gold chat is visible to the recipient (`ChatMessage.senderPro`, server `SocialService.IsPro` stamps it; mock marks ~half its friends pro); daily reward routes through the global `CelebrationOverlay` (`"daily"` variant + coins count-up).
6. **Match intro / "players joining" loading screen — DONE** (`MatchIntroOverlay`: seats slide in with "?" placeholders until live data arrives, 3-2-1-GO countdown; online-only via `matchIntroPending`/`consumeIntro`).
---
@@ -101,3 +117,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).
+57
View File
@@ -0,0 +1,57 @@
# Production go-live — bargevasat.ir + Cafe Bazaar
Companion to `HANDOFF.md` / `DEPLOY.md`. Domain: **bargevasat.ir** (web) +
**api.bargevasat.ir** (.NET SignalR API). Android via **Cafe Bazaar**.
## 1. DNS + firewall (you do this)
- A-records → your server IP: `bargevasat.ir`, `www.bargevasat.ir`, `api.bargevasat.ir`.
- Open ports **80** + **443** (`ufw allow 80 && ufw allow 443`).
## 2. Production env (Gitea `ENV_FILE` secret)
Use the **PRODUCTION block** in `deploy/ENV_FILE.example`:
- `NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir` (baked at web build → needs a CI rebuild to change)
- `CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir`
- `JWT_KEY` = `openssl rand -hex 32`, strong `POSTGRES_PASSWORD`
- ZarinPal **live**: `ZARINPAL_SANDBOX=false`, live merchant id, callback `https://api.bargevasat.ir/api/coins/pay/callback`, return `https://bargevasat.ir`
- ZarinPal panel: register the callback domain.
## 3. Deploy with HTTPS (Caddy)
The deploy job (or you, on the server) runs the stack **with the Caddy overlay**:
```bash
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
```
Caddy auto-provisions Let's Encrypt certs and proxies `bargevasat.ir → web`,
`api.bargevasat.ir → server`. SignalR WebSockets pass through transparently.
(To wire this into CI, add `-f docker-compose.caddy.yml` to the deploy job's
compose commands once DNS resolves.)
## 4. Database (Supabase or the bundled Postgres)
- Bundled `db` service works for launch. For Supabase: set `Database__Provider=postgres`
+ the Supabase `ConnectionStrings__Default`, and **generate EF migrations** first
(`HANDOFF.md` §5.1) so the server runs `Migrate()` instead of `EnsureCreated()`.
- **Back up before every deploy** (the deploy job already `pg_dump`s).
## 5. Cafe Bazaar (Android) publish
1. **Build a signed release APK/AAB**`NEXT_PUBLIC_STORE=bazaar`,
`NEXT_PUBLIC_APP_PACKAGE=com.bargevasat.app`, `NEXT_PUBLIC_USE_SERVER=1`,
`NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir`, then `npm run cap:sync` +
build in Android Studio / gradle (see `ANDROID.md`). App id **com.bargevasat.app**.
2. Upload to **pardakht.cafebazaar.ir**, fill the listing (icon, screenshots, fa
description), submit for review.
3. **In-app billing (after approval):** in the Bazaar dev panel create the coin
SKUs (`p1``p4`, matching `ProfileService.Packs`), create the **Pardakht API**
OAuth client, do the one-time consent to get a **refresh token**, and put
`IAB_BAZAAR_CLIENT_ID/SECRET/REFRESH_TOKEN` (+ `IAB_PACKAGE_NAME=com.bargevasat.app`)
into `ENV_FILE`; set `IAB_ALLOW_UNVERIFIED=false`. The web client deep-links
`bazaar://in_app?...` and the server verifies the returned `purchaseToken`
before crediting (see `HANDOFF.md` §5.3 / `src/lib/storeBilling.ts`).
4. Set `Zarinpal__Sandbox=false` only for the **web/PWA** payment path; the
**store build uses IAB, not ZarinPal** (store policy).
## 6. Pre-launch hardening checklist
- [ ] `JWT_KEY` is a real 32+ char secret (compose `${JWT_KEY:?}` fails if unset).
- [ ] `IAB_ALLOW_UNVERIFIED=false`, `ZARINPAL_SANDBOX=false`.
- [ ] CORS = the real domains only (no localhost).
- [ ] DB backups confirmed (`/opt/hokm-backups`), volumes named (no orphan data — see DEPLOY.md incident rules).
- [ ] CI green: tsc + next build + dotnet build + Hokm.Sim.
- [ ] Smoke test on https://bargevasat.ir: OTP login, vs-AI game, ranked match, buy-coins redirect, friends/chat.
+5
View File
@@ -99,3 +99,8 @@ app/src/main/assets/public
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
# Release signing — NEVER commit (back these up separately!)
keystore.properties
*.jks
*.keystore
+31
View File
@@ -0,0 +1,31 @@
# Inapp billing public keys (RSA)
These are the **public** RSA keys issued by each store, used to verify the
signature of a purchase payload. They are not secret. Keep them with the app so
the native billing layer (and/or server signature verification) can reference
them.
## Cafe Bazaar
Used by the Poolakey / ondevice verification path (the current Bazaar flow uses
the `bazaar://in_app` deeplink + server token verification, so this key is only
needed if/when ondevice signature verification is added).
```
MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwCQ6/F2F0yNSXULayEDzFPSse07K7q70pcxZrE+lzKyw8N4vx3yZKqj/rrYbe1JvS9iYDZy3q5G3x5tdi45Ggjer5uP1EP3oq/liONVcLXU206PTe0AfWQtruvA045iPn9aRv3ZaZBz9dniSA8rrX53+YxgGiENC9TShQ3uItQe12utsUcHO5Xj0av+ZufWkL5w/Mr1dQLlvHY8QT+R2uYv8sLBgcgOc9E8BKnOIO0CAwEAAQ==
```
## Myket
Required by the (tobebuilt) native Myket billing plugin to verify the
`INAPP_DATA_SIGNATURE` of each purchase before crediting coins.
```
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbUBKRU4g1AQrbOO8GkcBn79ol0hbs5PZVd5vPP6za98BTc9leqvyGE+DwSg7lbsXTZxCzPRBS3m0qB9LShe70WG+RQapG9Q2lodszYkauicPkJSpbXWh/nfrziTWNqEHqUfCsC4+lkKSEkxDNa1Po7uZzbwaJ+Kf1+d8wSWYpxwIDAQAB
```
## Inapp product SKUs (both stores)
| SKU | Coins | Price |
|-----|-------|-------|
| Coin5K | 5,000 | 99,000 تومان |
| Coin12K | 12,000 | 199,000 تومان |
| Coin28K | 28,000 | 399,000 تومان |
| Coin65K | 65,000 | 799,000 تومان |
+28 -2
View File
@@ -1,14 +1,27 @@
apply plugin: 'com.android.application'
// Release signing is read from android/keystore.properties (git-ignored). When it
// is absent (e.g. fresh checkout / CI without secrets) the release build stays
// unsigned instead of failing the configuration.
def keystorePropsFile = rootProject.file("keystore.properties")
def keystoreProps = new Properties()
if (keystorePropsFile.exists()) {
keystoreProps.load(new FileInputStream(keystorePropsFile))
}
android {
namespace = "com.bargevasat.app"
compileSdk = rootProject.ext.compileSdkVersion
// AGP 8 disables AIDL by default; the Myket billing service needs it.
buildFeatures {
aidl true
}
defaultConfig {
applicationId "com.bargevasat.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 2
versionName "1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -16,8 +29,21 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
if (keystorePropsFile.exists()) {
release {
storeFile file(keystoreProps['storeFile'])
storePassword keystoreProps['storePassword']
keyAlias keystoreProps['keyAlias']
keyPassword keystoreProps['keyPassword']
}
}
}
buildTypes {
release {
if (keystorePropsFile.exists()) {
signingConfig signingConfigs.release
}
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
+11
View File
@@ -15,6 +15,7 @@
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:exported="true">
<intent-filter>
@@ -38,4 +39,14 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Myket in-app billing -->
<uses-permission android:name="ir.mservices.market.BILLING" />
<!-- Android 11+ package visibility: allow binding to the Myket billing service -->
<queries>
<package android:name="ir.mservices.market" />
<intent>
<action android:name="ir.mservices.market.InAppBillingService.BIND" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,17 @@
// Google Play In-App Billing v3 interface. Myket implements the SAME interface
// (bound to the Myket app via ir.mservices.market.InAppBillingService.BIND).
package com.android.vending.billing;
import android.os.Bundle;
interface IInAppBillingService {
int isBillingSupported(int apiVersion, String packageName, String type);
Bundle getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle);
Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, String developerPayload);
Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken);
int consumePurchase(int apiVersion, String packageName, String purchaseToken);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -1,5 +1,51 @@
package com.bargevasat.app;
import android.content.Intent;
import android.os.Bundle;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.bargevasat.app.billing.MyketBillingPlugin;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
/**
* Runs the game edge-to-edge and hides the Android status + navigation bars for
* a fullscreen, console-like experience (they reappear transiently on a swipe).
*/
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
// Register native plugins before the bridge starts.
registerPlugin(MyketBillingPlugin.class);
super.onCreate(savedInstanceState);
enableImmersive();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
// Re-assert immersive mode whenever the window regains focus (e.g. after a
// system dialog, or the bars were swiped in).
if (hasFocus) enableImmersive();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
// Forward the Myket purchase intent result to the billing plugin.
if (requestCode == MyketBillingPlugin.RC_BUY) {
MyketBillingPlugin.onPurchaseActivityResult(resultCode, data);
}
}
private void enableImmersive() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
if (controller != null) {
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
}
}
@@ -0,0 +1,181 @@
package com.bargevasat.app.billing;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import com.android.vending.billing.IInAppBillingService;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONObject;
/**
* Myket in-app billing for the Capacitor WebView. Myket implements the classic
* Google Play IAB v3 AIDL (IInAppBillingService), bound to the Myket app via
* "ir.mservices.market.InAppBillingService.BIND". The purchase intent result is
* delivered to MainActivity.onActivityResult, which forwards to
* {@link #onPurchaseActivityResult}.
*/
@CapacitorPlugin(name = "MyketBilling")
public class MyketBillingPlugin extends Plugin {
private static final String TAG = "MyketBilling";
private static final String MARKET_PACKAGE = "ir.mservices.market";
private static final String BIND_ACTION = "ir.mservices.market.InAppBillingService.BIND";
private static final int API_VERSION = 3;
public static final int RC_BUY = 11001;
private static final int RESULT_OK_CODE = 0; // BILLING_RESPONSE_RESULT_OK
private static MyketBillingPlugin instance;
private IInAppBillingService service;
private ServiceConnection conn;
private boolean bound = false;
private String rsaKey = "";
private PluginCall pendingPurchase;
@Override
public void load() {
instance = this;
}
/** Forwarded from MainActivity.onActivityResult for RC_BUY. */
public static void onPurchaseActivityResult(int resultCode, Intent data) {
if (instance != null) instance.handlePurchaseResult(resultCode, data);
}
// ----------------------------- JS methods -----------------------------
@PluginMethod
public void isAvailable(PluginCall call) {
JSObject ret = new JSObject();
ret.put("available", isPackageInstalled(MARKET_PACKAGE));
call.resolve(ret);
}
@PluginMethod
public void connect(PluginCall call) {
rsaKey = call.getString("rsaPublicKey", rsaKey);
if (!isPackageInstalled(MARKET_PACKAGE)) { call.reject("myket_not_installed"); return; }
bind(call::resolve, call);
}
@PluginMethod
public void purchase(final PluginCall call) {
final String sku = call.getString("sku");
final String key = call.getString("rsaPublicKey", rsaKey);
if (key != null) rsaKey = key;
if (sku == null) { call.reject("missing_sku"); return; }
if (!isPackageInstalled(MARKET_PACKAGE)) { call.reject("myket_not_installed"); return; }
bind(() -> {
try {
Bundle buy = service.getBuyIntent(API_VERSION, getContext().getPackageName(), sku, "inapp", "");
int rc = buy.getInt("RESPONSE_CODE");
if (rc != RESULT_OK_CODE) { call.reject("buy_intent_failed_" + rc); return; }
PendingIntent pi = buy.getParcelable("BUY_INTENT");
if (pi == null) { call.reject("no_buy_intent"); return; }
pendingPurchase = call;
getActivity().startIntentSenderForResult(pi.getIntentSender(), RC_BUY, new Intent(), 0, 0, 0);
} catch (Exception e) {
call.reject("purchase_error", e);
}
}, call);
}
@PluginMethod
public void consume(final PluginCall call) {
final String token = call.getString("token");
if (token == null) { call.reject("missing_token"); return; }
bind(() -> {
try {
int rc = service.consumePurchase(API_VERSION, getContext().getPackageName(), token);
if (rc == RESULT_OK_CODE) call.resolve();
else call.reject("consume_failed_" + rc);
} catch (RemoteException e) {
call.reject("consume_error", e);
}
}, call);
}
// ----------------------------- internals -----------------------------
private void handlePurchaseResult(int resultCode, Intent data) {
PluginCall call = pendingPurchase;
pendingPurchase = null;
if (call == null) return;
if (data == null || resultCode != Activity.RESULT_OK) { call.reject("purchase_cancelled"); return; }
int rc = data.getIntExtra("RESPONSE_CODE", 0);
if (rc != RESULT_OK_CODE) { call.reject("purchase_failed_" + rc); return; }
String purchaseData = data.getStringExtra("INAPP_PURCHASE_DATA");
String signature = data.getStringExtra("INAPP_DATA_SIGNATURE");
if (purchaseData == null) { call.reject("no_purchase_data"); return; }
if (rsaKey != null && !rsaKey.isEmpty()
&& !Security.verifyPurchase(rsaKey, purchaseData, signature)) {
call.reject("invalid_signature");
return;
}
try {
JSONObject o = new JSONObject(purchaseData);
JSObject ret = new JSObject();
ret.put("purchaseToken", o.optString("purchaseToken"));
ret.put("productId", o.optString("productId"));
ret.put("orderId", o.optString("orderId"));
ret.put("purchaseData", purchaseData);
ret.put("signature", signature == null ? "" : signature);
call.resolve(ret);
} catch (Exception e) {
call.reject("parse_error", e);
}
}
private void bind(final Runnable onReady, final PluginCall failCall) {
if (bound && service != null) { if (onReady != null) onReady.run(); return; }
conn = new ServiceConnection() {
@Override public void onServiceConnected(ComponentName name, IBinder binder) {
service = IInAppBillingService.Stub.asInterface(binder);
bound = true;
if (onReady != null) onReady.run();
}
@Override public void onServiceDisconnected(ComponentName name) { service = null; bound = false; }
};
Intent intent = new Intent(BIND_ACTION);
intent.setPackage(MARKET_PACKAGE);
try {
boolean ok = getContext().bindService(intent, conn, Context.BIND_AUTO_CREATE);
if (!ok && failCall != null) failCall.reject("myket_unavailable");
} catch (Exception e) {
Log.e(TAG, "bindService failed", e);
if (failCall != null) failCall.reject("myket_unavailable", e);
}
}
private boolean isPackageInstalled(String pkg) {
try {
getContext().getPackageManager().getPackageInfo(pkg, 0);
return true;
} catch (Exception e) {
return false;
}
}
@Override
protected void handleOnDestroy() {
if (bound && conn != null) {
try { getContext().unbindService(conn); } catch (Exception ignored) {}
}
bound = false;
service = null;
if (instance == this) instance = null;
super.handleOnDestroy();
}
}
@@ -0,0 +1,71 @@
package com.bargevasat.app.billing;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.SignatureException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
/**
* Verifies that a Myket purchase payload was signed by the store, using the
* app's RSA public key (from the Myket developer panel). Mirrors Google Play
* IAB v3 "Security" — Myket uses the same SHA1withRSA signing.
*/
public final class Security {
private static final String TAG = "MyketSecurity";
private static final String KEY_FACTORY_ALGORITHM = "RSA";
private static final String SIGNATURE_ALGORITHM = "SHA1withRSA";
private Security() {}
/** @return true if signedData was signed by the private key matching base64PublicKey. */
public static boolean verifyPurchase(String base64PublicKey, String signedData, String signature) {
if (TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey) || TextUtils.isEmpty(signature)) {
Log.w(TAG, "Purchase verification failed: missing data.");
return false;
}
try {
PublicKey key = generatePublicKey(base64PublicKey);
return verify(key, signedData, signature);
} catch (Exception e) {
Log.e(TAG, "Verification error", e);
return false;
}
}
private static PublicKey generatePublicKey(String encodedPublicKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM);
return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
}
private static boolean verify(PublicKey publicKey, String signedData, String signature) {
byte[] signatureBytes;
try {
signatureBytes = Base64.decode(signature, Base64.DEFAULT);
} catch (IllegalArgumentException e) {
Log.e(TAG, "Base64 decoding failed.");
return false;
}
try {
java.security.Signature sig = java.security.Signature.getInstance(SIGNATURE_ALGORITHM);
sig.initVerify(publicKey);
sig.update(signedData.getBytes());
if (!sig.verify(signatureBytes)) {
Log.e(TAG, "Signature verification failed.");
return false;
}
return true;
} catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
Log.e(TAG, "Signature exception", e);
}
return false;
}
}
@@ -1,170 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Adaptive-icon background: navy radial gradient with a subtle teal glow,
matching the web/app icon (scripts/icon/icon.svg). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="54"
android:centerY="40"
android:gradientRadius="82"
android:startColor="#16284F"
android:centerColor="#0A142E"
android:endColor="#060C1F" />
</aapt:attr>
</path>
<path android:pathData="M54,42m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0z">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="54"
android:centerY="42"
android:gradientRadius="46"
android:startColor="#1A2DD4BF"
android:endColor="#002DD4BF" />
</aapt:attr>
</path>
</vector>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+24
View File
@@ -0,0 +1,24 @@
// Injects the myket.ir Maven mirror into every project's buildscript + normal
// repositories. Needed because dl.google.com is unreachable here and some
// Capacitor subprojects declare only google()/mavenCentral() in node_modules.
// Pass on the command line with: gradlew -I mirror-init.gradle <task>
allprojects {
buildscript {
repositories {
maven { url 'https://maven.myket.ir' }
maven { url 'https://mirror.abrha.net/repository/maven/' }
}
}
repositories {
maven { url 'https://maven.myket.ir' }
maven { url 'https://mirror.abrha.net/repository/maven/' }
}
// Build-Tools 35.0.0 isn't installed (and can't be fetched — Google is
// blocked here). Pin every Android module to the installed 36.0.0.
afterEvaluate { proj ->
def android = proj.extensions.findByName('android')
if (android != null) {
android.buildToolsVersion = '36.0.0'
}
}
}
+34 -27
View File
@@ -1,42 +1,49 @@
# ──────────────────────────────────────────────────────────────────────────
# Barg-e Vasat — ENV_FILE
# Paste the contents of this file (filled in) into the Gitea repo secret:
# https://git.soroushasadi.com/soroushdes/HokmPlay/settings/secrets → ENV_FILE
# The deploy job writes it verbatim to `.env`, which docker compose reads.
#
# NOTE: NEXT_PUBLIC_SERVER_URL is baked into the web bundle at BUILD time —
# changing it requires a new CI run (push a commit) to take effect.
# Barg-e Vasat — ENV_FILE TEMPLATE (placeholders only — NO real secrets here)
# Copy to deploy/ENV_FILE.local (git-ignored), fill real values, and paste the
# WHOLE thing into the Gitea repo secret ENV_FILE. Saving the secret REPLACES
# the entire file — always paste the complete contents.
# ──────────────────────────────────────────────────────────────────────────
# Host ports (15001600 range so the stack coexists with manual dev on 3000/5005)
# Ports
WEB_PORT=1500
API_PORT=1505
DB_PORT=1510
SITE_PORT=1520
# Database (postgres container)
POSTGRES_PASSWORD=change-me-strong-password
# Database — MUST match the existing postgres volume's password
POSTGRES_PASSWORD=<strong-password>
# JWT — generate with: openssl rand -hex 32
JWT_KEY=CHANGE-ME-to-a-32+char-random-secret
JWT_KEY=<32+char-random-secret>
JWT_ISSUER=hokm
JWT_AUDIENCE=hokm-clients
# Browser-facing API origin (host-mapped api port).
# If the browser is NOT on the deploy host, use the host LAN IP instead of
# localhost, e.g. http://172.28.144.1:1505 (localhost can be VPN-hijacked).
NEXT_PUBLIC_SERVER_URL=http://localhost:1505
# URLs / CORS
NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir
NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir
NEXT_PUBLIC_SITE_URL=https://bargevasat.ir
CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir,https://app.bargevasat.ir
# Origins allowed by the API's CORS (comma-separated). Must include the web URL.
CORS_ORIGINS=http://localhost:1500
# ZarinPal
ZARINPAL_MERCHANT_ID=<your-merchant-id>
ZARINPAL_SANDBOX=false
ZARINPAL_CALLBACK_URL=https://api.bargevasat.ir/api/coins/pay/callback
ZARINPAL_CLIENT_RETURN_URL=https://app.bargevasat.ir
# Package mirrors used during Docker builds. Default to the plain-HTTP Nexus
# (no SSL) because the HTTPS mirror serves a partial cert chain that fresh
# container trust stores reject. Override only if your Nexus moves.
# NUGET_INDEX=http://171.22.25.73:8081/repository/nuget-group/index.json
# NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/
# Admin panel token (openssl rand -hex 24)
ADMIN_TOKEN=<admin-token>
# ZarinPal (sandbox for now — switch in admin/panel later)
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
# In-app billing (Cafe Bazaar / Myket) — fill from the developer panels.
IAB_PACKAGE_NAME=com.bargevasat.app
IAB_BAZAAR_CLIENT_ID=<bazaar-client-id>
IAB_BAZAAR_CLIENT_SECRET=<bazaar-client-secret>
IAB_BAZAAR_REFRESH_TOKEN=<bazaar-refresh-token>
IAB_MYKET_ACCESS_TOKEN=<myket-access-token>
IAB_ALLOW_UNVERIFIED=false
# SMS OTP (Kavenegar). Template "hokmotp" has a %token placeholder we fill with
# the code. Leave SMS_API_KEY empty for dev mode (no SMS sent, code = 1234).
SMS_PROVIDER=kavenegar
SMS_API_KEY=<kavenegar-api-key>
SMS_TEMPLATE=hokmotp
+118
View File
@@ -0,0 +1,118 @@
# Subdomain split: marketing site + game + API
After this change there are **three** public hosts (all → edge nginx `185.239.1.100`):
| Host | Serves | Upstream (on 171.22.25.73) |
|---|---|---|
| `bargevasat.ir`, `www.bargevasat.ir` | Marketing site (`hokm-site`) | `:1520` |
| `app.bargevasat.ir` | The game (`hokm-web`) | `:1500` |
| `api.bargevasat.ir` | API + SignalR (`hokm-server`) | `:1505` (CDN **bypass**) |
## 1. DNS
Add/confirm Arecords (all → `185.239.1.100`):
```
bargevasat.ir A 185.239.1.100 (CDN ok)
www.bargevasat.ir A 185.239.1.100 (CDN ok)
app.bargevasat.ir A 185.239.1.100 (CDN ok)
api.bargevasat.ir A 185.239.1.100 (CDN BYPASS / DNS-only)
```
## 2. TLS cert — reissue to include `app`
The current cert covers `bargevasat.ir, www, api` — add `app`:
```bash
sudo certbot certonly --webroot -w /var/www/certbot \
-d bargevasat.ir -d www.bargevasat.ir -d app.bargevasat.ir -d api.bargevasat.ir \
--agree-tos --no-eff-email --email you@example.com
# then copy/symlink fullchain.pem + privkey.pem into /etc/ssl/bargevasat/
```
(Or DNS01 if behind the CDN — see SSL notes.)
## 3. nginx (edit /root/mirror-server/nginx/nginx.conf)
Replace the single Barge Vasat web block with these three:
```nginx
# Redirect http → https for all three
server {
listen 80;
server_name bargevasat.ir www.bargevasat.ir app.bargevasat.ir api.bargevasat.ir;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
# Marketing site → hokm-site :1520
server {
listen 443 ssl;
http2 on;
server_name bargevasat.ir www.bargevasat.ir;
ssl_certificate /etc/ssl/bargevasat/fullchain.pem;
ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:1520;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Game (Next static SPA) → hokm-web :1500
server {
listen 443 ssl;
http2 on;
server_name app.bargevasat.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/bargevasat/fullchain.pem;
ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:1500;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# API + SignalR → hokm-server :1505 (WebSocket; keep CDN bypassed for this host)
server {
listen 443 ssl;
http2 on;
server_name api.bargevasat.ir;
client_max_body_size 50m;
ssl_certificate /etc/ssl/bargevasat/fullchain.pem;
ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:1505;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
```
Reload: `docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload`
## 4. ENV_FILE secret (Gitea) — add/confirm
```
SITE_PORT=1520
NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir
NEXT_PUBLIC_SITE_URL=https://bargevasat.ir
NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir
CORS_ORIGINS=https://bargevasat.ir,https://www.bargevasat.ir,https://app.bargevasat.ir
ADMIN_TOKEN=<openssl rand -hex 24>
```
## 5. Deploy
`docker compose build site web server && docker compose up -d`
(Add the `site` service to the CI deploy job's build/up + healthwait, same pattern as web.)
## 6. Verify
```bash
curl -I https://bargevasat.ir # marketing (200)
curl -I https://app.bargevasat.ir # game (200)
curl -I https://api.bargevasat.ir # API (405 to HEAD is fine)
```
Admin: open `https://bargevasat.ir/admin`, enter `ADMIN_TOKEN`, set Bazaar/Myket links → Save.
+433
View File
@@ -0,0 +1,433 @@
events { worker_connections 1024; }
http {
upstream nexus_http { server nexus:8081; }
upstream nexus_docker { server nexus:5000; }
upstream nexus_ghcr { server nexus:5001; }
upstream nexus_docker_group { server nexus:8082; }
upstream nexus_docker_host { server nexus:8083; }
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
proxy_read_timeout 300s;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
client_max_body_size 1g;
# =========================================================
# Nexus UI — nexus.soroushasadi.com
# =========================================================
server {
listen 80;
server_name nexus.soroushasadi.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name nexus.soroushasadi.com;
client_max_body_size 1g;
ssl_certificate /etc/ssl/soroushasadi/fullchain.pem;
ssl_certificate_key /etc/ssl/soroushasadi/privateKey.pem;
location / {
proxy_pass http://nexus_http;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# =========================================================
# Docker registry — mirror.soroushasadi.com
# =========================================================
server {
listen 80;
server_name mirror.soroushasadi.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name mirror.soroushasadi.com;
ssl_certificate /etc/ssl/soroushasadi/fullchain.pem;
ssl_certificate_key /etc/ssl/soroushasadi/privateKey.pem;
client_max_body_size 0;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_request_buffering off;
location /v2/token {
proxy_pass http://nexus_docker_group;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
}
location /v2/docker-host/ {
proxy_pass http://nexus_docker_host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_hide_header WWW-Authenticate;
add_header WWW-Authenticate "Bearer realm=\"https://mirror.soroushasadi.com/v2/token\",service=\"mirror.soroushasadi.com\"" always;
}
location /v2/ {
proxy_pass http://nexus_docker_group;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_hide_header WWW-Authenticate;
add_header WWW-Authenticate "Bearer realm=\"https://mirror.soroushasadi.com/v2/token\",service=\"mirror.soroushasadi.com\"" always;
}
location / {
proxy_pass http://nexus_http;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
# =========================================================
# Gitea — git.soroushasadi.com
# =========================================================
server {
listen 80;
server_name git.soroushasadi.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name git.soroushasadi.com;
client_max_body_size 300m;
ssl_certificate /etc/ssl/soroushasadi/fullchain.pem;
ssl_certificate_key /etc/ssl/soroushasadi/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# =========================================================
# Docker Hub proxy (port 5000) — legacy
# =========================================================
server {
listen 5000;
server_name _;
location / {
proxy_pass http://nexus_docker;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
# =========================================================
# GHCR proxy (port 5001) — legacy
# =========================================================
server {
listen 5001;
server_name _;
location / {
proxy_pass http://nexus_ghcr;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
# =========================================================
# DrAletaha — draletaha.ir
# =========================================================
server {
listen 80;
server_name draletaha.ir;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name draletaha.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/draletaha/fullchain.pem;
ssl_certificate_key /etc/ssl/draletaha/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:5010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
# =========================================================
# Barg-e Vasat
# bargevasat.ir / www → marketing site (hokm-site :1520)
# app.bargevasat.ir → game (hokm-web :1500)
# api.bargevasat.ir → API + SignalR (hokm-server :1505) [CDN bypass]
# All four A-records → 185.239.1.100
# =========================================================
server {
listen 80;
server_name bargevasat.ir www.bargevasat.ir app.bargevasat.ir api.bargevasat.ir;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
# Marketing site → hokm-site :1520
server {
listen 443 ssl;
http2 on;
server_name bargevasat.ir www.bargevasat.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/bargevasat/fullchain.pem;
ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:1520;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Game (Next static SPA) → hokm-web :1500
server {
listen 443 ssl;
http2 on;
server_name app.bargevasat.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/bargevasat/fullchain.pem;
ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:1500;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# API + SignalR → hokm-server :1505 (WebSocket; keep this host's CDN bypassed)
server {
listen 443 ssl;
http2 on;
server_name api.bargevasat.ir;
client_max_body_size 50m;
ssl_certificate /etc/ssl/bargevasat/fullchain.pem;
ssl_certificate_key /etc/ssl/bargevasat/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:1505;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
# =========================================================
# Meezi — meezi.ir + subdomains
# =========================================================
server {
listen 80;
server_name meezi.ir app.meezi.ir admin.meezi.ir koja.meezi.ir api.meezi.ir admin-api.meezi.ir;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name meezi.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/meezi/fullchain.pem;
ssl_certificate_key /etc/ssl/meezi/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:3010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 443 ssl;
http2 on;
server_name app.meezi.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/meezi/fullchain.pem;
ssl_certificate_key /etc/ssl/meezi/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:3101;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 443 ssl;
http2 on;
server_name admin.meezi.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/meezi/fullchain.pem;
ssl_certificate_key /etc/ssl/meezi/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:3102;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 443 ssl;
http2 on;
server_name koja.meezi.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/meezi/fullchain.pem;
ssl_certificate_key /etc/ssl/meezi/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:3103;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 443 ssl;
http2 on;
server_name api.meezi.ir;
client_max_body_size 50m;
ssl_certificate /etc/ssl/meezi/fullchain.pem;
ssl_certificate_key /etc/ssl/meezi/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:5080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
server {
listen 443 ssl;
http2 on;
server_name admin-api.meezi.ir;
client_max_body_size 50m;
ssl_certificate /etc/ssl/meezi/fullchain.pem;
ssl_certificate_key /etc/ssl/meezi/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:5081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
# =========================================================
# soroushasadi.com
# =========================================================
server {
listen 80;
server_name soroushasadi.com www.soroushasadi.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name soroushasadi.com www.soroushasadi.com;
client_max_body_size 25m;
ssl_certificate /etc/ssl/soroushasadi/fullchain.pem;
ssl_certificate_key /etc/ssl/soroushasadi/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:3020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# =========================================================
# Hamkadr — hamkadr.ir
# =========================================================
server {
listen 80;
server_name hamkadr.ir www.hamkadr.ir;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name hamkadr.ir www.hamkadr.ir;
client_max_body_size 25m;
ssl_certificate /etc/ssl/soroushasadi/hamkadr/fullchain.pem;
ssl_certificate_key /etc/ssl/soroushasadi/hamkadr/privateKey.pem;
location / {
proxy_pass http://171.22.25.73:2569;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
# Production HTTPS overlay for bargevasat.ir.
# Caddy terminates TLS (auto Let's Encrypt) and reverse-proxies:
# https://bargevasat.ir → web (nginx static)
# https://api.bargevasat.ir → server (.NET SignalR)
# Run: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
# (web/server are reached over the compose network by name; their host port
# publishes from docker-compose.yml are harmless but optional in prod.)
services:
caddy:
image: mirror.soroushasadi.com/caddy:2-alpine
container_name: hokm-caddy
restart: unless-stopped
depends_on:
- web
- server
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- hokm_caddy_data:/data
- hokm_caddy_config:/config
volumes:
hokm_caddy_data:
hokm_caddy_config:
+57 -2
View File
@@ -44,15 +44,46 @@ services:
ASPNETCORE_URLS: http://0.0.0.0:5005
Database__Provider: postgres
ConnectionStrings__Default: "Host=db;Port=5432;Database=hokm;Username=hokm;Password=${POSTGRES_PASSWORD:-hokm_dev_pass}"
Jwt__Key: ${JWT_KEY:?set JWT_KEY in .env}
# Default empty so `docker compose build` (which interpolates the whole file)
# never blocks on a runtime-only secret. The server REFUSES to boot in
# Production with a missing/dev key (see Program.cs guard).
Jwt__Key: ${JWT_KEY:-}
Jwt__Issuer: ${JWT_ISSUER:-hokm}
Jwt__Audience: ${JWT_AUDIENCE:-hokm-clients}
# Comma-separated origins the browser uses to reach the web app.
Cors__Origins: ${CORS_ORIGINS:-http://localhost:1500}
Zarinpal__MerchantId: ${ZARINPAL_MERCHANT_ID:-299685fb-cadf-4dfc-98e2-d4af5d81528d}
Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-true}
Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-false}
Zarinpal__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback}
Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500}
# FlatRender Pay broker (pay.flatrender.ir): shared ZarinPal via the single
# verified domain. Set FLATPAY_API_KEY + FLATPAY_SECRET to route through it
# (issued in FlatRender admin → پرداخت). Empty ⇒ legacy direct ZarinPal above.
FlatPay__BaseUrl: ${FLATPAY_BASE_URL:-https://pay.flatrender.ir}
FlatPay__ApiKey: ${FLATPAY_API_KEY:-}
FlatPay__Secret: ${FLATPAY_SECRET:-}
FlatPay__ReturnUrl: ${FLATPAY_RETURN_URL:-https://bargevasat.ir/?pay=done}
# Store in-app billing verification (Cafe Bazaar / Myket) — fill from panels.
Iab__PackageName: ${IAB_PACKAGE_NAME:-com.bargevasat.app}
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}
# SMS OTP (Kavenegar). Empty key ⇒ dev mode (no SMS, accepts the dev code).
Sms__Provider: ${SMS_PROVIDER:-kavenegar}
Sms__ApiKey: ${SMS_API_KEY:-}
Sms__Template: ${SMS_TEMPLATE:-hokmotp}
# Store-review test login (Google Play / Bazaar / Myket): this phone skips
# SMS and always accepts the static code. Give these to the review team.
Sms__TestPhone: ${SMS_TEST_PHONE:-09120000000}
Sms__TestCode: ${SMS_TEST_CODE:-453115}
# Admin panel (marketing-site links editor) — shared-token auth.
Admin__Token: ${ADMIN_TOKEN:-}
# Where the admin-editable site-links JSON is persisted (mounted volume).
Site__DataDir: /data
volumes:
- hokm_data:/data
ports:
- "${API_PORT:-1505}:5005"
healthcheck:
@@ -88,5 +119,29 @@ services:
retries: 6
start_period: 10s
# Marketing website (bargevasat.ir) — separate static Next.js project in ./site.
site:
build:
context: ./site
dockerfile: Dockerfile
args:
# Browser-facing API (for reading admin-editable store links) + game URL.
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_SERVER_URL:-http://localhost:1505}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:1500}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:1520}
NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/}
image: hokm-site:latest
container_name: hokm-site
restart: unless-stopped
ports:
- "${SITE_PORT:-1520}:80"
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1/"]
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
volumes:
hokm_db_data:
hokm_data:
+8 -1
View File
@@ -10,7 +10,14 @@ server {
try_files $uri $uri.html $uri/ /index.html;
}
# Long-cache immutable build assets.
# Never cache the HTML shell — so a new deploy (with new chunk hashes) is
# always picked up immediately and tabs don't load a stale bundle.
location ~* \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri /index.html;
}
# Long-cache immutable, content-hashed build assets.
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, immutable";
+74
View File
@@ -29,8 +29,10 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^10.1.0",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"playwright": "^1.60.0",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -395,6 +397,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -3143,6 +3152,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4223,6 +4250,21 @@
"node": ">= 10.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -6254,6 +6296,38 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/plist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
+15 -1
View File
@@ -7,9 +7,21 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"build:web": "cross-env NEXT_PUBLIC_STORE=web next build",
"build:bazaar": "cross-env NEXT_PUBLIC_STORE=bazaar next build",
"build:myket": "cross-env NEXT_PUBLIC_STORE=myket next build",
"build:googleplay": "cross-env NEXT_PUBLIC_STORE=googleplay next build",
"cap:sync": "next build && npx cap sync android",
"cap:bazaar": "npm run build:bazaar && npx cap sync android",
"cap:myket": "npm run build:myket && npx cap sync android",
"cap:googleplay": "npm run build:googleplay && npx cap sync android",
"android:open": "npx cap open android",
"android:apk": "npm run cap:sync && cd android && gradlew.bat assembleDebug"
"android:apk": "npm run cap:sync && cd android && gradlew.bat assembleDebug",
"aab:bazaar": "npm run cap:bazaar && cd android && gradlew.bat bundleRelease",
"aab:myket": "npm run cap:myket && cd android && gradlew.bat bundleRelease",
"apk:bazaar": "npm run cap:bazaar && cd android && gradlew.bat assembleRelease",
"apk:myket": "npm run cap:myket && cd android && gradlew.bat assembleRelease",
"apk:googleplay": "npm run cap:googleplay && cd android && gradlew.bat assembleRelease"
},
"dependencies": {
"@capacitor/app": "^8.1.0",
@@ -33,8 +45,10 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"cross-env": "^10.1.0",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"playwright": "^1.60.0",
"tailwindcss": "^4",
"typescript": "^5"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+37 -14
View File
@@ -1,22 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg width="1024" height="1024" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0e1c3f"/>
<radialGradient id="bg" cx="50%" cy="36%" r="78%">
<stop offset="0" stop-color="#16284f"/>
<stop offset="0.62" stop-color="#0a142e"/>
<stop offset="1" stop-color="#060c1f"/>
</linearGradient>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f1da8a"/>
<stop offset="0.55" stop-color="#d4af37"/>
<stop offset="0" stop-color="#f6e4a0"/>
<stop offset="0.5" stop-color="#d4af37"/>
<stop offset="1" stop-color="#b8860b"/>
</linearGradient>
<linearGradient id="face" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fffdf7"/>
<stop offset="1" stop-color="#f1e6cd"/>
</linearGradient>
<linearGradient id="navy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1d356a"/>
<stop offset="1" stop-color="#0a142e"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<g fill="none" stroke="#d4af37" stroke-opacity="0.18" stroke-width="3">
<path d="M256 48 L464 256 L256 464 L48 256 Z"/>
<path d="M256 120 L392 256 L256 392 L120 256 Z"/>
<rect width="512" height="512" fill="url(#bg)"/>
<circle cx="256" cy="196" r="185" fill="#2dd4bf" opacity="0.07"/>
<rect x="30" y="30" width="452" height="452" rx="104" fill="none" stroke="url(#gold)" stroke-width="6" opacity="0.6"/>
<g transform="rotate(-25 256 396)">
<rect x="182" y="180" width="148" height="210" rx="16" fill="url(#navy)" stroke="url(#gold)" stroke-width="4"/>
<rect x="198" y="196" width="116" height="178" rx="10" fill="none" stroke="#d4af37" stroke-width="2" opacity="0.45"/>
<path d="M256 250 l16 35 -16 35 -16 -35 z" fill="#d4af37" opacity="0.75"/>
</g>
<g transform="rotate(25 256 396)">
<rect x="182" y="180" width="148" height="210" rx="16" fill="url(#navy)" stroke="url(#gold)" stroke-width="4"/>
<rect x="198" y="196" width="116" height="178" rx="10" fill="none" stroke="#d4af37" stroke-width="2" opacity="0.45"/>
<path d="M256 250 l16 35 -16 35 -16 -35 z" fill="#d4af37" opacity="0.75"/>
</g>
<g transform="translate(0 -24)">
<rect x="181" y="178" width="150" height="212" rx="16" fill="url(#face)" stroke="url(#gold)" stroke-width="5"/>
<rect x="193" y="190" width="126" height="188" rx="10" fill="none" stroke="#d4af37" stroke-width="2"/>
<g transform="translate(256 268) scale(1.45)">
<path d="M0,-44 C0,-44 -42,-6 -42,16 C-42,30 -31,40 -18,40 C-11,40 -5,37 0,32 C-2,44 -10,52 -20,55 L20,55 C10,52 2,44 0,32 C5,37 11,40 18,40 C31,40 42,30 42,16 C42,-6 0,-44 0,-44 Z" fill="url(#gold)" stroke="#7a5a00" stroke-width="1.5"/>
</g>
</g>
<path d="M256 150 C300 150 330 182 330 224 C330 286 256 330 256 360 C256 330 182 286 182 224 C182 182 212 150 256 150 Z"
fill="url(#gold)"/>
<text x="256" y="438" text-anchor="middle" font-family="Vazirmatn, Tahoma, sans-serif"
font-size="62" font-weight="800" fill="url(#gold)">برگ وسط</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+7 -8
View File
@@ -5,16 +5,15 @@
"lang": "fa",
"dir": "rtl",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone", "minimal-ui"],
"orientation": "portrait",
"background_color": "#060c1f",
"theme_color": "#060c1f",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+41
View File
@@ -0,0 +1,41 @@
// Capture several frames of an actual vs-computer game so we can pick the best
// "gameplay" shot (hand fanned at the bottom, trump chosen, a trick in play).
const { chromium } = require("playwright");
const path = require("path");
const OUT = path.join(__dirname, "shots");
const URL = process.env.SHOT_URL || "http://localhost:3025/";
(async () => {
const browser = await chromium.launch({ channel: "chrome" });
const ctx = await browser.newContext({
viewport: { width: 430, height: 932 },
deviceScaleFactor: 2,
locale: "fa-IR",
isMobile: true,
hasTouch: true,
});
const page = await ctx.newPage();
await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {});
await page.waitForTimeout(2500);
await page.getByText("بازی با کامپیوتر", { exact: false }).first().click();
// Capture a frame every few seconds through deal → hakem → trump → play.
const stamps = [6, 10, 14, 18, 24, 30];
let prev = 0;
for (const s of stamps) {
await page.waitForTimeout((s - prev) * 1000);
prev = s;
await page.screenshot({ path: path.join(OUT, `game-${String(s).padStart(2, "0")}s.png`) });
// If it's our turn to choose trump, pick the first suit so play proceeds.
try {
const trump = page.getByText("حکم را انتخاب", { exact: false });
if (await trump.count()) {
const suit = page.locator("button").filter({ hasText: /♠|♥|♦|♣/ }).first();
if (await suit.count()) await suit.click({ timeout: 1500 }).catch(() => {});
}
} catch {}
console.log("frame", s + "s");
}
await browser.close();
console.log("DONE");
})().catch((e) => { console.error("FATAL", e); process.exit(1); });
+24
View File
@@ -0,0 +1,24 @@
// Render public/icon.svg to a 512x512 store PNG (full square — stores apply
// their own corner mask). Uses Chrome for correct Persian text shaping.
const { chromium } = require("playwright");
const fs = require("fs");
const path = require("path");
const OUT = path.join(__dirname, "shots");
fs.mkdirSync(OUT, { recursive: true });
let svg = fs.readFileSync(path.join(__dirname, "..", "public", "icon.svg"), "utf8");
// Full-bleed square (remove the rounded corners so there's no transparency).
const square = svg.replace('rx="112"', 'rx="0"').replace("<svg ", '<svg width="512" height="512" ');
const html = `<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0;padding:0">${square}</body></html>`;
(async () => {
const browser = await chromium.launch({ channel: "chrome" });
const ctx = await browser.newContext({ viewport: { width: 512, height: 512 }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
await page.screenshot({ path: path.join(OUT, "icon-512.png"), clip: { x: 0, y: 0, width: 512, height: 512 } });
await browser.close();
console.log("icon-512 done");
})().catch((e) => { console.error("FATAL", e); process.exit(1); });
+79
View File
@@ -0,0 +1,79 @@
// Generates every app/website/Android icon asset from the two master SVGs:
// scripts/icon/icon.svg — full design (navy bg + gold frame + fanned cards)
// scripts/icon/icon-foreground.svg — cards only, transparent (Android adaptive foreground)
// Run: node scripts/icon/gen-icons.mjs
import sharp from "sharp";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, "..", "..");
const fullSvg = fs.readFileSync(path.join(here, "icon.svg"));
const fgSvg = fs.readFileSync(path.join(here, "icon-foreground.svg"));
const R = (p) => path.join(root, p);
const ensure = (p) => fs.mkdirSync(path.dirname(p), { recursive: true });
async function png(svg, size) {
return sharp(svg, { density: 384 }).resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
}
async function write(svg, size, dest) {
ensure(R(dest));
fs.writeFileSync(R(dest), await png(svg, size));
console.log(" ", dest, `(${size})`);
}
// PNG-in-ICO container (16/32/48), accepted by all modern browsers.
function buildIco(entries) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(entries.length, 4);
const dir = Buffer.alloc(16 * entries.length);
let offset = 6 + 16 * entries.length;
entries.forEach((e, i) => {
const b = i * 16;
dir.writeUInt8(e.size >= 256 ? 0 : e.size, b);
dir.writeUInt8(e.size >= 256 ? 0 : e.size, b + 1);
dir.writeUInt16LE(1, b + 4); dir.writeUInt16LE(32, b + 6);
dir.writeUInt32LE(e.buf.length, b + 8); dir.writeUInt32LE(offset, b + 12);
offset += e.buf.length;
});
return Buffer.concat([header, dir, ...entries.map((e) => e.buf)]);
}
const ANDROID = [
["mipmap-mdpi", 48, 108],
["mipmap-hdpi", 72, 162],
["mipmap-xhdpi", 96, 216],
["mipmap-xxhdpi", 144, 324],
["mipmap-xxxhdpi", 192, 432],
];
async function run() {
console.log("Web / PWA:");
// public/icon.svg = the vector master (manifest references it)
fs.copyFileSync(path.join(here, "icon.svg"), R("public/icon.svg"));
console.log(" public/icon.svg (vector)");
await write(fullSvg, 192, "public/icon-192.png");
await write(fullSvg, 512, "public/icon-512.png");
await write(fullSvg, 512, "public/icon-maskable.png");
await write(fullSvg, 180, "public/apple-touch-icon.png");
await write(fullSvg, 180, "src/app/apple-icon.png");
// favicon.ico (16/32/48)
const ico = buildIco(await Promise.all([16, 32, 48].map(async (s) => ({ size: s, buf: await png(fullSvg, s) }))));
ensure(R("src/app/favicon.ico"));
fs.writeFileSync(R("src/app/favicon.ico"), ico);
console.log(" src/app/favicon.ico (16/32/48)");
console.log("Android (Capacitor):");
for (const [dir, launcher, fg] of ANDROID) {
await write(fullSvg, launcher, `android/app/src/main/res/${dir}/ic_launcher.png`);
await write(fullSvg, launcher, `android/app/src/main/res/${dir}/ic_launcher_round.png`);
await write(fgSvg, fg, `android/app/src/main/res/${dir}/ic_launcher_foreground.png`);
}
await write(fullSvg, 512, "android/app/src/main/ic_launcher-playstore.png");
console.log("Done.");
}
run().catch((e) => { console.error(e); process.exit(1); });
+38
View File
@@ -0,0 +1,38 @@
<svg width="1024" height="1024" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f6e4a0"/>
<stop offset="0.5" stop-color="#d4af37"/>
<stop offset="1" stop-color="#b8860b"/>
</linearGradient>
<linearGradient id="face" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fffdf7"/>
<stop offset="1" stop-color="#f1e6cd"/>
</linearGradient>
<linearGradient id="navy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1d356a"/>
<stop offset="1" stop-color="#0a142e"/>
</linearGradient>
</defs>
<!-- the fanned cards, centered + scaled into the adaptive safe zone (~66%) -->
<g transform="translate(256 256) scale(0.82) translate(-256 -290)">
<g transform="rotate(-25 256 396)">
<rect x="182" y="180" width="148" height="210" rx="16" fill="url(#navy)" stroke="url(#gold)" stroke-width="4"/>
<rect x="198" y="196" width="116" height="178" rx="10" fill="none" stroke="#d4af37" stroke-width="2" opacity="0.45"/>
<path d="M256 250 l16 35 -16 35 -16 -35 z" fill="#d4af37" opacity="0.75"/>
</g>
<g transform="rotate(25 256 396)">
<rect x="182" y="180" width="148" height="210" rx="16" fill="url(#navy)" stroke="url(#gold)" stroke-width="4"/>
<rect x="198" y="196" width="116" height="178" rx="10" fill="none" stroke="#d4af37" stroke-width="2" opacity="0.45"/>
<path d="M256 250 l16 35 -16 35 -16 -35 z" fill="#d4af37" opacity="0.75"/>
</g>
<g transform="translate(0 -24)">
<rect x="181" y="178" width="150" height="212" rx="16" fill="url(#face)" stroke="url(#gold)" stroke-width="5"/>
<rect x="193" y="190" width="126" height="188" rx="10" fill="none" stroke="#d4af37" stroke-width="2"/>
<g transform="translate(256 268) scale(1.45)">
<path d="M0,-44 C0,-44 -42,-6 -42,16 C-42,30 -31,40 -18,40 C-11,40 -5,37 0,32 C-2,44 -10,52 -20,55 L20,55 C10,52 2,44 0,32 C5,37 11,40 18,40 C31,40 42,30 42,16 C42,-6 0,-44 0,-44 Z" fill="url(#gold)" stroke="#7a5a00" stroke-width="1.5"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+45
View File
@@ -0,0 +1,45 @@
<svg width="1024" height="1024" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="bg" cx="50%" cy="36%" r="78%">
<stop offset="0" stop-color="#16284f"/>
<stop offset="0.62" stop-color="#0a142e"/>
<stop offset="1" stop-color="#060c1f"/>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f6e4a0"/>
<stop offset="0.5" stop-color="#d4af37"/>
<stop offset="1" stop-color="#b8860b"/>
</linearGradient>
<linearGradient id="face" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fffdf7"/>
<stop offset="1" stop-color="#f1e6cd"/>
</linearGradient>
<linearGradient id="navy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1d356a"/>
<stop offset="1" stop-color="#0a142e"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<circle cx="256" cy="196" r="185" fill="#2dd4bf" opacity="0.07"/>
<rect x="30" y="30" width="452" height="452" rx="104" fill="none" stroke="url(#gold)" stroke-width="6" opacity="0.6"/>
<g transform="rotate(-25 256 396)">
<rect x="182" y="180" width="148" height="210" rx="16" fill="url(#navy)" stroke="url(#gold)" stroke-width="4"/>
<rect x="198" y="196" width="116" height="178" rx="10" fill="none" stroke="#d4af37" stroke-width="2" opacity="0.45"/>
<path d="M256 250 l16 35 -16 35 -16 -35 z" fill="#d4af37" opacity="0.75"/>
</g>
<g transform="rotate(25 256 396)">
<rect x="182" y="180" width="148" height="210" rx="16" fill="url(#navy)" stroke="url(#gold)" stroke-width="4"/>
<rect x="198" y="196" width="116" height="178" rx="10" fill="none" stroke="#d4af37" stroke-width="2" opacity="0.45"/>
<path d="M256 250 l16 35 -16 35 -16 -35 z" fill="#d4af37" opacity="0.75"/>
</g>
<g transform="translate(0 -24)">
<rect x="181" y="178" width="150" height="212" rx="16" fill="url(#face)" stroke="url(#gold)" stroke-width="5"/>
<rect x="193" y="190" width="126" height="188" rx="10" fill="none" stroke="#d4af37" stroke-width="2"/>
<g transform="translate(256 268) scale(1.45)">
<path d="M0,-44 C0,-44 -42,-6 -42,16 C-42,30 -31,40 -18,40 C-11,40 -5,37 0,32 C-2,44 -10,52 -20,55 L20,55 C10,52 2,44 0,32 C5,37 11,40 18,40 C31,40 42,30 42,16 C42,-6 0,-44 0,-44 Z" fill="url(#gold)" stroke="#7a5a00" stroke-width="1.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+100
View File
@@ -0,0 +1,100 @@
// Build an animated portrait promo from the captured screenshots and record it
// to webm with Playwright. (ffmpeg then encodes it to mp4 — see the run step.)
const { chromium } = require("playwright");
const fs = require("fs");
const path = require("path");
const ASSETS = path.join(__dirname, "..", "store-assets");
const OUT = path.join(__dirname, "promo");
fs.mkdirSync(OUT, { recursive: true });
const b64 = (f) => "data:image/png;base64," + fs.readFileSync(path.join(ASSETS, f)).toString("base64");
const icon = b64("icon-512.png");
// type: intro | slide | outro
const SLIDES = [
{ type: "intro", title: "برگ وسط", sub: "بازی حکم آنلاین ایرانی" },
{ type: "slide", img: b64("01-home.png"), cap: "سه حالت بازی در یک اپ" },
{ type: "slide", img: b64("06-game.png"), cap: "حکمِ واقعی، کارت‌های زیبا" },
{ type: "slide", img: b64("02-leaderboard.png"), cap: "در لیگ‌ها بالا برو و رکورد بزن" },
{ type: "slide", img: b64("04-shop.png"), cap: "آواتار و آیتم‌های ویژه" },
{ type: "slide", img: b64("03-achievements.png"), cap: "دستاوردها و جایزه‌ی روزانه" },
{ type: "slide", img: b64("05-profile.png"), cap: "پروفایل کامل خودت را بساز" },
{ type: "outro", title: "همین حالا رایگان نصب کن!", sub: "برگ وسط", icon: true },
];
const DUR = 2600; // ms per slide
const FADE = 700;
const slideHtml = (s, i) => {
if (s.type === "intro" || s.type === "outro")
return `<div class="slide center" data-i="${i}">
<img class="icon" src="${icon}"/>
<div class="title">${s.title}</div>
<div class="sub">${s.sub}</div>
</div>`;
return `<div class="slide" data-i="${i}">
<div class="brand">برگ وسط</div>
<div class="phonewrap"><img class="phone" src="${s.img}"/></div>
<div class="cap">${s.cap}</div>
</div>`;
};
const html = `<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; font-family: Tahoma, "Segoe UI", sans-serif; }
html,body { width:1080px; height:1920px; overflow:hidden; }
.stage { position:relative; width:1080px; height:1920px;
background: radial-gradient(120% 80% at 50% 0%, #123 0%, #0e1c3f 38%, #060c1f 100%); }
.stage::after { content:""; position:absolute; inset:0;
background: radial-gradient(60% 35% at 50% 42%, rgba(212,175,55,.16), transparent 70%); }
.slide { position:absolute; inset:0; opacity:0; transition: opacity ${FADE}ms ease;
display:flex; flex-direction:column; align-items:center; padding:70px 60px; }
.slide.active { opacity:1; }
.brand { color:#d4af37; font-size:46px; font-weight:800; letter-spacing:1px;
opacity:.85; margin-bottom:18px; }
.phonewrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0; }
.phone { max-height:1300px; max-width:660px; border-radius:42px;
box-shadow: 0 30px 80px rgba(0,0,0,.55), 0 0 0 2px rgba(212,175,55,.25);
transform: scale(1.0); transition: transform ${DUR + FADE}ms ease-out; }
.slide.active .phone { transform: scale(1.07); }
.cap { color:#fdf6e3; font-size:58px; font-weight:800; text-align:center; line-height:1.5;
text-shadow:0 3px 18px rgba(0,0,0,.6); margin-top:34px;
transform: translateY(24px); opacity:0; transition: all ${FADE}ms ease ${FADE / 2}ms; }
.slide.active .cap { transform: translateY(0); opacity:1; }
.center { justify-content:center; gap:40px; }
.center .icon { width:360px; height:360px; border-radius:80px;
box-shadow:0 24px 70px rgba(0,0,0,.5); transform:scale(.8); transition:transform 900ms ease; }
.center.active .icon { transform:scale(1); }
.center .title { color:#f1da8a; font-size:96px; font-weight:800; text-align:center;
text-shadow:0 4px 24px rgba(0,0,0,.5); }
.center .sub { color:#cbd5e1; font-size:50px; font-weight:600; text-align:center; }
/* intro/outro icon hidden when not requested */
</style></head><body>
<div class="stage">${SLIDES.map(slideHtml).join("")}</div>
<script>
const slides=[...document.querySelectorAll('.slide')];
const DUR=${DUR};
let i=0;
function show(n){ slides.forEach((s,k)=>s.classList.toggle('active',k===n)); }
show(0);
const timer=setInterval(()=>{ i++; if(i>=slides.length){ clearInterval(timer); window.__done=true; return;} show(i); }, DUR);
</script>
</body></html>`;
(async () => {
const total = SLIDES.length * DUR + 1200;
const browser = await chromium.launch({ channel: "chrome" });
const ctx = await browser.newContext({
viewport: { width: 1080, height: 1920 },
deviceScaleFactor: 1,
recordVideo: { dir: OUT, size: { width: 1080, height: 1920 } },
});
const page = await ctx.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
await page.waitForTimeout(total);
const video = page.video();
await ctx.close(); // finalizes the recording
const p = await video.path();
await browser.close();
console.log("VIDEO:" + p);
})().catch((e) => { console.error("FATAL", e); process.exit(1); });
+56
View File
@@ -0,0 +1,56 @@
// Capture 9:16 store screenshots (1080x1920) for Myket from the dev server.
// Output -> store-assets/myket/*.png
const { chromium } = require("playwright");
const fs = require("fs");
const path = require("path");
const OUT = path.join(__dirname, "..", "store-assets", "myket");
fs.mkdirSync(OUT, { recursive: true });
const URL = process.env.SHOT_URL || "http://localhost:3025/";
const shot = (page, name) => page.screenshot({ path: path.join(OUT, name + ".png") });
const tap = (page, text) => page.getByText(text, { exact: false }).first().click({ timeout: 6000 });
(async () => {
const browser = await chromium.launch({ channel: "chrome" });
// 432x768 CSS = 9:16, at DSF 2.5 -> 1080x1920 output (exactly 9:16, < 3000px).
const ctx = await browser.newContext({
viewport: { width: 432, height: 768 },
deviceScaleFactor: 2.5,
locale: "fa-IR",
isMobile: true,
hasTouch: true,
});
const page = await ctx.newPage();
await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {});
await page.waitForTimeout(3000);
await shot(page, "01-home");
for (const [label, name] of [
["جدول امتیازات", "02-leaderboard"],
["فروشگاه", "03-shop"],
["دستاوردها", "04-achievements"],
["پروفایل", "05-profile"],
]) {
try {
await tap(page, label);
await page.waitForTimeout(2200);
await shot(page, name);
} catch (e) {
console.log("skip", name, String(e).split("\n")[0]);
}
await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {});
await page.waitForTimeout(1500);
}
try {
await tap(page, "بازی با کامپیوتر");
await page.waitForTimeout(5000);
await shot(page, "06-game");
} catch (e) {
console.log("skip game", String(e).split("\n")[0]);
}
await browser.close();
console.log("DONE");
})().catch((e) => { console.error("FATAL", e); process.exit(1); });
+69
View File
@@ -0,0 +1,69 @@
// Capture portrait store screenshots from the running dev server (localhost:3025)
// using the system Chrome. Output -> scripts/shots/*.png
const { chromium } = require("playwright");
const fs = require("fs");
const path = require("path");
const OUT = path.join(__dirname, "shots");
fs.mkdirSync(OUT, { recursive: true });
const URL = process.env.SHOT_URL || "http://localhost:3025/";
const shot = async (page, name) => {
await page.screenshot({ path: path.join(OUT, name + ".png") });
console.log("saved", name);
};
const tap = async (page, text) => {
const el = page.getByText(text, { exact: false }).first();
await el.click({ timeout: 6000 });
};
(async () => {
const browser = await chromium.launch({ channel: "chrome" });
const ctx = await browser.newContext({
viewport: { width: 430, height: 932 },
deviceScaleFactor: 2,
locale: "fa-IR",
isMobile: true,
hasTouch: true,
});
const page = await ctx.newPage();
await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {});
await page.waitForTimeout(3000);
await shot(page, "01-home");
// nav-rail screens reachable from home
for (const [label, name] of [
["جدول امتیازات", "02-leaderboard"],
["دستاوردها", "03-achievements"],
["فروشگاه", "04-shop"],
["پروفایل", "05-profile"],
]) {
try {
await tap(page, label);
await page.waitForTimeout(2200);
await shot(page, name);
// back to home for the next nav tap
await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {});
await page.waitForTimeout(1500);
} catch (e) {
console.log("skip", name, String(e).split("\n")[0]);
await page.goto(URL, { waitUntil: "networkidle" }).catch(() => {});
await page.waitForTimeout(1500);
}
}
// vs-computer game (cards on the table)
try {
await tap(page, "بازی با کامپیوتر");
await page.waitForTimeout(5000);
await shot(page, "06-game");
} catch (e) {
console.log("skip game", String(e).split("\n")[0]);
}
await browser.close();
console.log("DONE");
})().catch((e) => {
console.error("FATAL", e);
process.exit(1);
});
+236
View File
@@ -0,0 +1,236 @@
using System.Collections.Concurrent;
using System.Text.Json;
namespace Hokm.Server.Auth;
/// <summary>
/// SMS OTP config. Bound from the "Sms" config section / <c>Sms__*</c> env vars.
/// Kavenegar verify/lookup: the panel template (e.g. "hokmotp") contains a
/// <c>%token</c> placeholder that we fill with the generated code.
/// </summary>
public sealed class SmsOptions
{
public string Provider { get; set; } = "kavenegar";
public string ApiKey { get; set; } = "";
public string Template { get; set; } = "hokmotp";
/// <summary>When true (or no ApiKey), no SMS is sent and a fixed dev code is accepted.</summary>
public bool DevMode { get; set; } = false;
public string DevCode { get; set; } = "1234";
public int TtlSeconds { get; set; } = 120;
/// <summary>
/// A reviewer/test login (Google Play, Bazaar, Myket): this exact phone never
/// triggers a real SMS and always accepts <see cref="TestCode"/>. Give these
/// to the store's review team. Set TestPhone empty to disable.
/// </summary>
public string TestPhone { get; set; } = "09120000000";
public string TestCode { get; set; } = "453115";
/* --- Rate limiting (applies to real SMS sends only; dev mode is unlimited) --- */
/// <summary>Minimum seconds between two OTP sends to the same phone (resend cooldown).</summary>
public int ResendCooldownSeconds { get; set; } = 60;
/// <summary>Max OTP sends to one phone per rolling hour. 0 disables.</summary>
public int MaxPerHour { get; set; } = 5;
/// <summary>Server-wide OTP-send backstop per rolling hour (SMS-bomb / cost cap). 0 disables.</summary>
public int MaxGlobalPerHour { get; set; } = 300;
}
/// <summary>Result of an OTP request, including a rate-limit retry hint.</summary>
public readonly record struct OtpResult(bool Ok, string? DevCode, string? Error, int RetryAfterSeconds);
/// <summary>Generates, sends (Kavenegar) and verifies phone OTP codes.</summary>
public sealed class OtpService : IDisposable
{
private static readonly HttpClient Http = new();
private readonly SmsOptions _opts;
private readonly ILogger<OtpService> _log;
private readonly ConcurrentDictionary<string, Entry> _codes = new();
// Rate-limit logs (singleton service → fields persist across requests).
private readonly ConcurrentDictionary<string, List<DateTime>> _sendLog = new();
private readonly object _globalLock = new();
private readonly List<DateTime> _globalLog = new();
// Periodic prune so expired codes / stale rate-limit logs don't accumulate
// unboundedly over a long-running process.
private readonly Timer _cleanup;
private readonly record struct Entry(string Code, DateTime Expires, int Tries);
public OtpService(SmsOptions opts, ILogger<OtpService> log)
{
_opts = opts;
_log = log;
_cleanup = new Timer(_ => Prune(), null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
}
/// <summary>Drop expired OTP codes and stale (>1h) rate-limit entries.</summary>
private void Prune()
{
try
{
var now = DateTime.UtcNow;
foreach (var kv in _codes)
if (now > kv.Value.Expires) _codes.TryRemove(kv.Key, out _);
var hour = TimeSpan.FromHours(1);
foreach (var kv in _sendLog)
{
lock (kv.Value)
{
kv.Value.RemoveAll(t => now - t >= hour);
if (kv.Value.Count == 0) _sendLog.TryRemove(kv.Key, out _);
}
}
lock (_globalLock) _globalLog.RemoveAll(t => now - t >= hour);
}
catch (Exception ex) { _log.LogWarning(ex, "OTP prune failed"); }
}
public void Dispose() => _cleanup.Dispose();
/// <summary>Dev mode = explicitly on, or no API key configured.</summary>
public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey);
private bool IsTestPhone(string normalizedPhone) =>
!string.IsNullOrWhiteSpace(_opts.TestPhone) && normalizedPhone == Normalize(_opts.TestPhone);
/// <summary>Generate a code, store it, and send the SMS. Returns devCode only in dev mode.</summary>
public async Task<OtpResult> Request(string phone)
{
phone = Normalize(phone);
if (string.IsNullOrWhiteSpace(phone)) return new OtpResult(false, null, "INVALID_PHONE", 0);
// Store review/test login: never send an SMS for the designated test number.
if (IsTestPhone(phone)) return new OtpResult(true, null, null, 0);
// Dev mode never sends an SMS (fixed code) → no cost, no rate limit.
if (IsDev)
{
_codes[phone] = new Entry(_opts.DevCode, DateTime.UtcNow.AddSeconds(_opts.TtlSeconds), 0);
return new OtpResult(true, _opts.DevCode, null, 0);
}
// Real SMS: enforce per-phone cooldown + hourly cap + a global backstop.
var limited = CheckAndRecordRate(phone);
if (limited is { } lim) return lim;
var code = Random.Shared.Next(10000, 100000).ToString();
_codes[phone] = new Entry(code, DateTime.UtcNow.AddSeconds(_opts.TtlSeconds), 0);
try
{
await SendKavenegar(phone, code);
return new OtpResult(true, null, null, 0);
}
catch (Exception e)
{
_log.LogWarning(e, "OTP send failed for {Phone}", phone);
return new OtpResult(false, null, "SMS_FAILED", 0);
}
}
/// <summary>
/// Records an OTP-send attempt against the rate limits. Returns a RATE_LIMITED
/// result (with retry-after seconds) when over a limit, or null when allowed.
/// </summary>
private OtpResult? CheckAndRecordRate(string phone)
{
var now = DateTime.UtcNow;
var hour = TimeSpan.FromHours(1);
var log = _sendLog.GetOrAdd(phone, _ => new List<DateTime>());
lock (log)
{
log.RemoveAll(t => now - t >= hour);
if (log.Count > 0)
{
var since = now - log[^1];
var cooldown = TimeSpan.FromSeconds(_opts.ResendCooldownSeconds);
if (since < cooldown)
return new OtpResult(false, null, "RATE_LIMITED", (int)Math.Ceiling((cooldown - since).TotalSeconds));
}
if (_opts.MaxPerHour > 0 && log.Count >= _opts.MaxPerHour)
{
var retry = (int)Math.Ceiling((hour - (now - log[0])).TotalSeconds);
return new OtpResult(false, null, "RATE_LIMITED", Math.Max(1, retry));
}
log.Add(now); // reserve the slot
}
if (_opts.MaxGlobalPerHour > 0)
{
lock (_globalLock)
{
_globalLog.RemoveAll(t => now - t >= hour);
if (_globalLog.Count >= _opts.MaxGlobalPerHour)
return new OtpResult(false, null, "RATE_LIMITED", 60);
_globalLog.Add(now);
}
}
return null;
}
/// <summary>Verify a submitted code (single-use, time-boxed, max 5 tries).</summary>
public bool Verify(string phone, string code)
{
phone = Normalize(phone);
if (IsTestPhone(phone)) return code == _opts.TestCode; // store-review test login
if (IsDev && code == _opts.DevCode) return true;
if (!_codes.TryGetValue(phone, out var e)) return false;
if (DateTime.UtcNow > e.Expires) { _codes.TryRemove(phone, out _); return false; }
if (e.Tries >= 5) { _codes.TryRemove(phone, out _); return false; }
if (e.Code != code) { _codes[phone] = e with { Tries = e.Tries + 1 }; return false; }
_codes.TryRemove(phone, out _);
return true;
}
private async Task SendKavenegar(string phone, string code)
{
// GET https://api.kavenegar.com/v1/{APIKEY}/verify/lookup.json?receptor=&token=&template=
var url =
$"https://api.kavenegar.com/v1/{_opts.ApiKey}/verify/lookup.json" +
$"?receptor={Uri.EscapeDataString(phone)}" +
$"&token={Uri.EscapeDataString(code)}" +
$"&template={Uri.EscapeDataString(_opts.Template)}";
// Bound the call so a hung/slow Kavenegar can't freeze the login request.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(12));
var resp = await Http.GetAsync(url, cts.Token);
var body = await resp.Content.ReadAsStringAsync(cts.Token);
// Kavenegar replies HTTP 200 with {"return":{"status":200,"message":...}}.
// status 200 = queued/sent; anything else (411 receptor, 418 credit,
// 422 template, 424 template-params…) means it did NOT send.
int? apiStatus = null;
string? apiMessage = null;
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("return", out var ret))
{
if (ret.TryGetProperty("status", out var st) && st.ValueKind == JsonValueKind.Number)
apiStatus = st.GetInt32();
if (ret.TryGetProperty("message", out var msg)) apiMessage = msg.GetString();
}
}
catch { /* non-JSON body */ }
if (!resp.IsSuccessStatusCode || (apiStatus.HasValue && apiStatus != 200))
{
_log.LogWarning("Kavenegar send FAILED http={Http} apiStatus={Api} message={Msg} body={Body}",
(int)resp.StatusCode, apiStatus, apiMessage, body);
throw new InvalidOperationException($"Kavenegar http={(int)resp.StatusCode} status={apiStatus} msg={apiMessage}");
}
_log.LogInformation("Kavenegar OTP sent to {Phone} (status {Status})", phone, apiStatus ?? 200);
}
/// <summary>Normalize to Kavenegar's 09xxxxxxxxx form (handles +98 / 98 prefixes).</summary>
private static string Normalize(string phone)
{
phone = (phone ?? "").Trim().Replace(" ", "");
if (phone.StartsWith("+98")) phone = "0" + phone[3..];
else if (phone.StartsWith("0098")) phone = "0" + phone[4..];
else if (phone.Length == 12 && phone.StartsWith("98")) phone = "0" + phone[2..];
return phone;
}
}
+3 -2
View File
@@ -8,7 +8,7 @@ namespace Hokm.Server.Game;
public record CardDto(string Suit, int Rank, string Id);
public record PlayedCardDto(int Seat, CardDto Card);
public record PlayerDto(int Seat, string Name, int Team, bool IsHuman, int HandCount, List<CardDto>? Hand);
public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot, string? UserId);
public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot, string? UserId, string? AvatarImage = null);
public record RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points);
public record GameStateDto(
@@ -34,7 +34,8 @@ public record GameStateDto(
bool Ranked,
int Stake);
public record MatchmakingStateDto(string Phase, int Players, int? QueuePosition);
public record QueuePlayerDto(string Id, string Name, string Avatar, string? AvatarImage, int Level);
public record MatchmakingStateDto(string Phase, int Players, int? QueuePosition, QueuePlayerDto[]? Queue = null);
public record ReactionDto(int Seat, string Reaction);
public static class Map
@@ -0,0 +1,242 @@
using System.Collections.Concurrent;
using Hokm.Server.Profiles;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
namespace Hokm.Server.Game;
// Wire DTOs for private rooms (camelCase JSON → TS client types).
public record RoomPlayerDto(string Id, string DisplayName, string Avatar, int Level, string? AvatarImage = null);
public record RoomSeatDto(int Seat, string Kind, RoomPlayerDto? Player); // kind: empty|invited|bot|human
public record RoomDto(string Id, string Code, string HostId, string Status, List<RoomSeatDto> Seats, int TargetScore, int Stake, bool Ranked);
public record RoomInviteDto(string RoomId, string Code, string HostName, int Stake);
/// <summary>
/// Server-authoritative private rooms with REAL friend invites. A seat stays
/// "invited" (a pending guest, NOT a bot) until that user accepts; the host can
/// only start once no invite is pending. On start the room becomes a live
/// GameRoom (empty seats — never pending ones — fill with bots).
/// </summary>
public sealed partial class GameManager
{
private sealed class PSeat
{
public int Seat;
public string Kind = "empty"; // empty | invited | bot | human
public string? UserId;
public string Name = "";
public string Avatar = "a-fox";
public string? AvatarImage;
public int Level;
}
private sealed class PRoom
{
public string Id = Guid.NewGuid().ToString("N")[..8];
public string Code = Guid.NewGuid().ToString("N")[..5].ToUpperInvariant();
public string HostId = "";
public int Stake;
public int TargetScore = 7;
public PSeat[] Seats = new PSeat[4];
}
private readonly ConcurrentDictionary<string, PRoom> _privateRooms = new();
private readonly ConcurrentDictionary<string, string> _userPrivate = new(); // host + accepted → roomId
private readonly ConcurrentDictionary<string, string> _pendingInvite = new(); // invited userId → roomId
private readonly object _proomLock = new();
public void CreatePrivateRoom(Player host, int stake, int target)
{
LeavePrivate(host.UserId); // one room per host
lock (_proomLock)
{
var room = new PRoom { HostId = host.UserId, Stake = stake, TargetScore = target <= 0 ? 7 : target };
for (int i = 0; i < 4; i++) room.Seats[i] = new PSeat { Seat = i };
room.Seats[0] = new PSeat { Seat = 0, Kind = "human", UserId = host.UserId, Name = host.Name, Avatar = host.Avatar, AvatarImage = host.AvatarImage, Level = host.Level };
_privateRooms[room.Id] = room;
_userPrivate[host.UserId] = room.Id;
PushRoom(room);
}
}
public void InvitePrivate(string hostId, int seat, string friendId)
{
if (seat is < 1 or > 3 || string.IsNullOrEmpty(friendId) || friendId == hostId) return;
// Authoritative friend identity (so the pending seat shows their real name/avatar).
var (name, avatar, level, avatarImage) = ResolveProfile(friendId, "", "a-fox", 1);
lock (_proomLock)
{
if (!HostRoom(hostId, out var room)) return;
if (room!.Seats[seat].Kind is not ("empty" or "invited")) return;
if (room.Seats.Any(s => s.Seat != seat && s.UserId == friendId)) return; // already in this room
FreeSeatInvite(room.Seats[seat]); // if re-inviting over a prior invite
room.Seats[seat] = new PSeat { Seat = seat, Kind = "invited", UserId = friendId, Name = name, Avatar = avatar, AvatarImage = avatarImage, Level = level };
_pendingInvite[friendId] = room.Id;
_ = _hub.Clients.User(friendId).SendAsync("roomInvite", new RoomInviteDto(room.Id, room.Code, room.Seats[0].Name, room.Stake));
PushRoom(room);
}
}
public void AcceptPrivate(string userId)
{
lock (_proomLock)
{
if (!_pendingInvite.TryRemove(userId, out var roomId) || !_privateRooms.TryGetValue(roomId, out var room)) return;
var seat = room.Seats.FirstOrDefault(x => x.Kind == "invited" && x.UserId == userId);
if (seat == null) return;
var (name, avatar, level, avatarImage) = ResolveProfile(userId, seat.Name, seat.Avatar, seat.Level);
room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat, Kind = "human", UserId = userId, Name = name, Avatar = avatar, AvatarImage = avatarImage, Level = level };
_userPrivate[userId] = room.Id;
PushRoom(room);
}
}
public void DeclinePrivate(string userId)
{
lock (_proomLock)
{
if (!_pendingInvite.TryRemove(userId, out var roomId) || !_privateRooms.TryGetValue(roomId, out var room)) return;
var seat = room.Seats.FirstOrDefault(x => x.Kind == "invited" && x.UserId == userId);
if (seat != null) room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat };
PushRoom(room);
}
}
public void AddPrivateBot(string hostId, int seat)
{
if (seat is < 1 or > 3) return;
lock (_proomLock)
{
if (!HostRoom(hostId, out var room)) return;
FreeSeatInvite(room!.Seats[seat]);
room.Seats[seat] = new PSeat { Seat = seat, Kind = "bot", Name = BotNames[_rng.Next(BotNames.Length)], Avatar = Avatars[_rng.Next(Avatars.Length)], Level = _rng.Next(1, 50) };
PushRoom(room);
}
}
public void ClearPrivateSeat(string hostId, int seat)
{
if (seat is < 1 or > 3) return;
lock (_proomLock)
{
if (!HostRoom(hostId, out var room)) return;
FreeSeatInvite(room!.Seats[seat]);
room.Seats[seat] = new PSeat { Seat = seat };
PushRoom(room);
}
}
public void StartPrivate(string hostId)
{
SeatSlot[]? slots = null;
int stake = 0, target = 7;
lock (_proomLock)
{
if (!HostRoom(hostId, out var room)) return;
if (room!.Seats.Any(s => s.Kind == "invited")) return; // never start with a pending invite
stake = room.Stake; target = room.TargetScore;
slots = new SeatSlot[4];
for (int i = 0; i < 4; i++)
{
var s = room.Seats[i];
slots[i] = s.Kind == "human"
? new SeatSlot { Seat = i, UserId = s.UserId, Name = s.Name, Avatar = s.Avatar, AvatarImage = s.AvatarImage, Level = s.Level }
: new SeatSlot { Seat = i, IsBot = true,
Name = s.Kind == "bot" && s.Name.Length > 0 ? s.Name : BotNames[_rng.Next(BotNames.Length)],
Avatar = s.Kind == "bot" ? s.Avatar : Avatars[_rng.Next(Avatars.Length)],
Level = s.Level > 0 ? s.Level : _rng.Next(1, 50) };
}
foreach (var s in room.Seats.Where(s => s.UserId != null)) _userPrivate.TryRemove(s.UserId!, out _);
_privateRooms.TryRemove(room.Id, out _);
}
if (slots != null) StartMatchSeats(slots, stake, target);
}
public void LeavePrivate(string userId)
{
lock (_proomLock)
{
_pendingInvite.TryRemove(userId, out _);
if (!_userPrivate.TryRemove(userId, out var roomId) || !_privateRooms.TryGetValue(roomId, out var room)) return;
if (room.HostId == userId)
{
_privateRooms.TryRemove(room.Id, out _);
foreach (var s in room.Seats.Where(s => s.UserId != null))
{
_userPrivate.TryRemove(s.UserId!, out _);
_pendingInvite.TryRemove(s.UserId!, out _);
if (s.UserId != userId) _ = _hub.Clients.User(s.UserId!).SendAsync("roomClosed");
}
}
else
{
var seat = room.Seats.FirstOrDefault(s => s.UserId == userId);
if (seat != null) room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat };
PushRoom(room);
}
}
}
// ----------------------------- helpers -----------------------------
private bool HostRoom(string hostId, out PRoom? room)
{
room = null;
if (_userPrivate.TryGetValue(hostId, out var id) && _privateRooms.TryGetValue(id, out var r) && r.HostId == hostId)
{
room = r;
return true;
}
return false;
}
private void FreeSeatInvite(PSeat s)
{
if (s.Kind == "invited" && s.UserId != null)
{
_pendingInvite.TryRemove(s.UserId, out _);
_ = _hub.Clients.User(s.UserId).SendAsync("roomInviteCancelled");
}
}
private (string name, string avatar, int level, string? avatarImage) ResolveProfile(string userId, string fbName, string fbAvatar, int fbLevel)
{
try
{
using var scope = _scopes.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
var p = svc.GetOrCreate(userId, null).GetAwaiter().GetResult();
return (p.DisplayName, p.Avatar, p.Level, p.AvatarImage);
}
catch { return (fbName, fbAvatar, fbLevel, null); }
}
private void PushRoom(PRoom room)
{
var dto = ToDto(room);
// Only accepted members (host + humans) get room state; invited users get the invite event.
foreach (var s in room.Seats.Where(s => s.Kind == "human" && s.UserId != null))
_ = _hub.Clients.User(s.UserId!).SendAsync("room", dto);
}
private static RoomDto ToDto(PRoom room) => new(
room.Id, room.Code, room.HostId, "lobby",
room.Seats.Select(SeatDto).ToList(), room.TargetScore, room.Stake, false);
private static RoomSeatDto SeatDto(PSeat s) =>
s.Kind == "empty"
? new RoomSeatDto(s.Seat, "empty", null)
: new RoomSeatDto(s.Seat, s.Kind, new RoomPlayerDto(s.UserId ?? $"bot-{s.Seat}", s.Name, s.Avatar, s.Level, s.Kind == "bot" ? null : s.AvatarImage));
/// <summary>Turn a fixed seat arrangement into a live match (used by private-room start).</summary>
private void StartMatchSeats(SeatSlot[] seats, int stake, int targetScore)
{
var room = new GameRoom(_hub, _scopes, seats, ranked: false, stake: stake, targetScore: targetScore);
room.OnFinished = FinishRoom;
_rooms[room.Id] = room;
foreach (var s in seats.Where(s => !s.IsBot && s.UserId != null)) _userRoom[s.UserId!] = room.Id;
foreach (var s in seats.Where(s => !s.IsBot && s.UserId != null))
_ = _hub.Clients.User(s.UserId!).SendAsync("matchFound", new { roomId = room.Id, seat = room.SeatOf(s.UserId!) });
room.Start();
}
}
+79 -14
View File
@@ -10,15 +10,23 @@ public sealed class Player
public required string UserId { get; init; }
public string Name { get; init; } = "";
public string Avatar { get; init; } = "a-fox";
public string? AvatarImage { get; init; }
public int Level { get; init; }
public string Plan { get; init; } = "free";
}
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
public sealed class GameManager
public sealed partial class GameManager
{
// Real players get priority: wait this long for humans before bots fill in.
private const int QueueWaitMs = 9000;
// Real players get priority:
// • a full table of 4 humans forms instantly, at any time;
// • at the 15s checkpoint, if ≥2 humans are waiting they start together
// (bots fill any empty seats);
// • a player left ALONE keeps waiting until the 25s hard deadline, then we
// fill the seats with AI and start.
// (QueueWaitMs mirrors MATCH_QUEUE_WAIT_MS on the client — keep both in sync.)
private const int QueueWaitMs = 15000;
private const int MaxAloneWaitMs = 25000;
private static readonly string[] BotNames =
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
@@ -30,7 +38,7 @@ public sealed class GameManager
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
private readonly ConcurrentDictionary<string, string> _userRoom = new(); // userId -> roomId
private readonly object _mmLock = new();
private readonly List<(Player player, Timer timer)> _waiting = new();
private readonly List<(Player player, Timer timer, DateTime since)> _waiting = new();
private readonly Random _rng = new();
public GameManager(IHubContext<GameHub> hub, IServiceScopeFactory scopes)
@@ -43,21 +51,26 @@ public sealed class GameManager
public void StartMatchmaking(Player p)
{
// Pro players skip the queue entirely.
if (p.Plan == "pro")
// One running game per player: if already in a live match, re-sync them to
// it (re-broadcasts current state) instead of starting a second game.
if (RoomOf(p.UserId) is { } existing)
{
StartMatch(new List<Player> { p });
existing.SetConnected(p.UserId, true);
return;
}
// Everyone — including pro — waits in the queue so we always try to seat
// real players together first. The 15s timer (NextQueueWaitMs) then fills
// any empty seats with bots, and a full group of 4 forms instantly.
lock (_mmLock)
{
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
_waiting.Add((p, timer));
_ = _hub.Clients.User(p.UserId).SendAsync("matchmaking",
new MatchmakingStateDto("searching", _waiting.Count, null));
_waiting.Add((p, timer, DateTime.UtcNow));
if (_waiting.Count >= 4) FormGroupLocked(4);
// Tell EVERYONE still waiting the new count (so friends queuing together
// see each other join), not just the player who just joined.
BroadcastQueueLocked();
}
}
@@ -66,7 +79,42 @@ public sealed class GameManager
lock (_mmLock)
{
var idx = _waiting.FindIndex(w => w.player.UserId == userId);
if (idx >= 0) { _waiting[idx].timer.Dispose(); _waiting.RemoveAt(idx); }
if (idx >= 0)
{
_waiting[idx].timer.Dispose();
_waiting.RemoveAt(idx);
BroadcastQueueLocked();
}
}
}
/// <summary>Push the current queue size + player list to every waiting player (call inside _mmLock).</summary>
private void BroadcastQueueLocked()
{
var queue = _waiting
.Select(w => new QueuePlayerDto(w.player.UserId, w.player.Name, w.player.Avatar, w.player.AvatarImage, w.player.Level))
.ToArray();
int n = queue.Length;
foreach (var w in _waiting)
_ = _hub.Clients.User(w.player.UserId).SendAsync("matchmaking",
new MatchmakingStateDto("searching", n, null, queue));
}
/// <summary>Client safety net: re-send the current game state to a player who
/// may have missed the initial broadcast (green-felt freeze guard).</summary>
public void Resync(string userId)
{
if (RoomOf(userId) is { } room) room.ResendStateTo(userId);
}
/// <summary>Player asked to start right now — match any humans waiting and
/// fill the rest with bots, instead of waiting out the queue.</summary>
public void PlayNow(string userId)
{
lock (_mmLock)
{
if (!_waiting.Any(w => w.player.UserId == userId)) return;
FormGroupLocked(_waiting.Count);
}
}
@@ -74,8 +122,24 @@ public sealed class GameManager
{
lock (_mmLock)
{
if (!_waiting.Any(w => w.player.UserId == userId)) return;
FormGroupLocked(_waiting.Count); // start with whoever is waiting; bots fill the rest
var idx = _waiting.FindIndex(w => w.player.UserId == userId);
if (idx < 0) return;
// A second human is already waiting → seat them together now and let
// bots fill any empty chairs (real players matched immediately).
if (_waiting.Count >= 2) { FormGroupLocked(_waiting.Count); return; }
// Alone: keep the table open for an online opponent until the 25s
// deadline, then fill the seats with AI. Re-arm the timer to land
// exactly on the deadline rather than overshooting by a full window.
var waited = (DateTime.UtcNow - _waiting[idx].since).TotalMilliseconds;
var remaining = MaxAloneWaitMs - waited;
if (remaining > 250)
{
_waiting[idx].timer.Change((int)remaining, Timeout.Infinite);
return;
}
FormGroupLocked(1);
}
}
@@ -97,7 +161,7 @@ public sealed class GameManager
if (i < humans.Count)
{
var h = humans[i];
seats[i] = new SeatSlot { Seat = i, UserId = h.UserId, Name = h.Name, Avatar = h.Avatar, Level = h.Level };
seats[i] = new SeatSlot { Seat = i, UserId = h.UserId, Name = h.Name, Avatar = h.Avatar, AvatarImage = h.AvatarImage, Level = h.Level };
}
else
{
@@ -160,6 +224,7 @@ public sealed class GameManager
if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0)
_onlineUsers.TryRemove(userId, out _);
CancelMatchmaking(userId);
LeavePrivate(userId); // free their private-room seat / close their room
RoomOf(userId)?.SetConnected(userId, false);
}
}
+27 -3
View File
@@ -12,6 +12,7 @@ public sealed class SeatSlot
public string? UserId { get; set; }
public string Name { get; set; } = "";
public string Avatar { get; set; } = "a-fox";
public string? AvatarImage { get; set; }
public int Level { get; set; }
public bool IsBot { get; set; }
public bool Connected { get; set; } = true;
@@ -28,7 +29,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;
@@ -50,6 +54,9 @@ public sealed class GameRoom : IDisposable
private int? _forfeitPendingTeam;
private string? _forfeitRequester;
private Timer? _forfeitTimer;
// Per-user cooldown so a player can't spam surrender requests at their teammate.
private const int ForfeitCooldownSeconds = 45;
private readonly Dictionary<string, DateTime> _forfeitNextAllowed = new();
public string Id { get; } = Guid.NewGuid().ToString("N")[..8];
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
@@ -195,12 +202,16 @@ public sealed class GameRoom : IDisposable
if (seat is null) return;
int team = seat.Value % 2;
var mate = Seats.FirstOrDefault(s => s.Seat % 2 == team && s.Seat != seat.Value);
// No human teammate to ask → forfeit immediately.
// No human teammate to ask → forfeit immediately (no cooldown needed).
if (mate is null || mate.IsBot || mate.UserId is null || !mate.Connected)
{
FinalizeForfeit(team);
return;
}
// Rate-limit repeated asks at a human teammate (anti-nag).
if (_forfeitNextAllowed.TryGetValue(userId, out var until) && DateTime.UtcNow < until)
return;
_forfeitNextAllowed[userId] = DateTime.UtcNow.AddSeconds(ForfeitCooldownSeconds);
_forfeitPendingTeam = team;
_forfeitRequester = userId;
var requester = Seats.First(s => s.UserId == userId);
@@ -377,6 +388,19 @@ public sealed class GameRoom : IDisposable
_ = _hub.Clients.User(slot.UserId!).SendAsync("state", ToDto(slot.Seat));
}
/// <summary>Re-send the current state to one player on demand — the client's
/// safety net when the initial broadcast was dropped/raced and the table
/// would otherwise freeze waiting for a state that never arrived.</summary>
public void ResendStateTo(string userId)
{
lock (_lock)
{
var slot = Seats.FirstOrDefault(s => !s.IsBot && s.UserId == userId);
if (slot != null)
_ = _hub.Clients.User(userId).SendAsync("state", ToDto(slot.Seat));
}
}
private void Broadcast(string method, object payload)
{
foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null && s.Connected))
@@ -390,7 +414,7 @@ public sealed class GameRoom : IDisposable
p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList();
var seatPlayers = Seats.OrderBy(s => s.Seat)
.Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot, s.IsBot ? null : s.UserId)).ToList();
.Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot, s.IsBot ? null : s.UserId, s.IsBot ? null : s.AvatarImage)).ToList();
RoundResultDto? rr = State.LastRoundResult is null ? null
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
+34 -1
View File
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.SignalR;
namespace Hokm.Server.Hubs;
public record MatchmakeRequest(string Name, string Avatar, int Level, string Plan);
public record MatchmakeRequest(string Name, string Avatar, int Level, string Plan, string? AvatarImage = null);
[Authorize]
public sealed class GameHub : Hub
@@ -32,15 +32,48 @@ public sealed class GameHub : Hub
UserId = Uid,
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
Avatar = req.Avatar,
AvatarImage = req.AvatarImage,
Level = req.Level,
Plan = req.Plan,
});
public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid);
public void PlayNow() => _manager.PlayNow(Uid);
public void Resync() => _manager.Resync(Uid);
public void PlayCard(string cardId) => _manager.PlayCard(Uid, cardId);
public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit);
public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction);
public void RequestForfeit() => _manager.RequestForfeit(Uid);
public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid);
public void DeclineForfeit() => _manager.DeclineForfeit(Uid);
/* ----------------------- private rooms (friend invites) ----------------------- */
public void CreatePrivateRoom(MatchmakeRequest req, int stake, int target) =>
_manager.CreatePrivateRoom(PlayerFrom(req), stake, target);
public void InvitePrivate(int seat, string friendId) => _manager.InvitePrivate(Uid, seat, friendId);
public void AcceptPrivate() => _manager.AcceptPrivate(Uid);
public void DeclinePrivate() => _manager.DeclinePrivate(Uid);
public void AddPrivateBot(int seat) => _manager.AddPrivateBot(Uid, seat);
public void ClearPrivateSeat(int seat) => _manager.ClearPrivateSeat(Uid, seat);
public void StartPrivate() => _manager.StartPrivate(Uid);
public void LeavePrivate() => _manager.LeavePrivate(Uid);
private Player PlayerFrom(MatchmakeRequest req) => new()
{
UserId = Uid,
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
Avatar = req.Avatar,
AvatarImage = req.AvatarImage,
Level = req.Level,
Plan = req.Plan,
};
/// <summary>Notify a chat peer that this user is typing (ephemeral, not stored).</summary>
public Task Typing(string peerId) =>
string.IsNullOrWhiteSpace(peerId)
? Task.CompletedTask
: Clients.User(peerId).SendAsync("typing", new { from = Uid });
}
@@ -0,0 +1,110 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace Hokm.Server.Payments;
public sealed class FlatPayOptions
{
/// <summary>Broker base URL, e.g. https://pay.flatrender.ir</summary>
public string BaseUrl { get; set; } = "https://pay.flatrender.ir";
/// <summary>Client app api key (pk_...) issued by the FlatRender pay admin.</summary>
public string ApiKey { get; set; } = "";
/// <summary>Shared HMAC secret (sk_...). Signs requests + verifies webhooks.</summary>
public string Secret { get; set; } = "";
/// <summary>Where the broker sends the user's browser back after payment.</summary>
public string ReturnUrl { get; set; } = "https://bargevasat.ir/?pay=done";
}
/// <summary>
/// Routes coin purchases through the shared FlatRender ZarinPal broker
/// (pay.flatrender.ir) — ZarinPal only accepts callbacks on that one verified
/// domain, so bargevasat.ir pays through the broker and is credited via a signed
/// webhook. When ApiKey/Secret are unset this is disabled and the legacy direct
/// ZarinpalService path is used instead.
/// </summary>
public sealed class FlatPayService
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
private readonly FlatPayOptions _opts;
private readonly ILogger<FlatPayService> _log;
// Idempotency: broker webhooks may be delivered more than once.
private readonly ConcurrentDictionary<string, byte> _processed = new();
public FlatPayService(FlatPayOptions opts, ILogger<FlatPayService> log)
{
_opts = opts;
_log = log;
}
public bool Enabled =>
!string.IsNullOrWhiteSpace(_opts.ApiKey) && !string.IsNullOrWhiteSpace(_opts.Secret);
private string Sign(byte[] message)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_opts.Secret));
return Convert.ToHexString(hmac.ComputeHash(message)).ToLowerInvariant();
}
/// <summary>Create a payment at the broker; returns the StartPay URL to redirect to.</summary>
public async Task<string?> Request(string userId, string packId, int priceToman, string description)
{
var payload = new
{
amount = priceToman,
currency = "IRT",
description,
client_ref = Guid.NewGuid().ToString("N"),
return_url = _opts.ReturnUrl,
metadata = new { user_id = userId, pack_id = packId },
};
var json = JsonSerializer.Serialize(payload);
var bytes = Encoding.UTF8.GetBytes(json);
using var req = new HttpRequestMessage(HttpMethod.Post, $"{_opts.BaseUrl.TrimEnd('/')}/v1/pay/request");
req.Headers.TryAddWithoutValidation("X-Api-Key", _opts.ApiKey);
req.Headers.TryAddWithoutValidation("X-Signature", Sign(bytes));
req.Content = new ByteArrayContent(bytes);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
try
{
var resp = await Http.SendAsync(req);
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
if (resp.IsSuccessStatusCode &&
doc.RootElement.TryGetProperty("payment_url", out var url))
return url.GetString();
}
catch (Exception ex) { _log.LogWarning(ex, "FlatPay broker payment request failed"); }
return null;
}
public bool VerifyWebhook(byte[] rawBody, string? signature) =>
!string.IsNullOrEmpty(signature) &&
CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(Sign(rawBody)),
SafeHex(signature));
private static byte[] SafeHex(string s)
{
try { return Convert.FromHexString(s); }
catch { return Array.Empty<byte>(); }
}
/// <summary>Returns true the first time a transaction id is seen (idempotency guard).</summary>
public bool MarkProcessed(string transactionId) =>
_processed.TryAdd(transactionId, 1);
}
/// <summary>Shape of the broker webhook body (snake_case JSON).</summary>
public sealed class FlatPayWebhook
{
public string? Event { get; set; }
public string? Id { get; set; }
public string? Status { get; set; }
public string? Ref_Id { get; set; }
public JsonElement Metadata { get; set; }
}
@@ -0,0 +1,138 @@
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.app";
// ── Cafe Bazaar (pardakht dev API, OAuth refresh-token flow) ──
public string BazaarClientId { get; set; } = "";
public string BazaarClientSecret { get; set; } = "";
public string BazaarRefreshToken { get; set; } = "";
/// <summary>
/// Cafe Bazaar in-app billing RSA public key (panel: «دریافت کلید RSA برای
/// قراردادن در برنامه»). Used to verify a purchase payload's signature locally
/// (Poolakey in-app library flow). NOT used by the current deep-link flow,
/// which verifies server-to-server via the pardakht API above — kept here so
/// the key has a home if/when the native Poolakey plugin is added.
/// </summary>
public string BazaarRsaPublicKey { 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
{
// Bounded timeout so a hung store API can't tie up request threads.
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
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. POST the purchase token in the body
/// (`{ "tokenId": ... }`) to the partners/verify endpoint with an X-Access-Token
/// header. The access token comes from the Myket developer panel → in-app
/// products. See https://myket.ir/kb/pages/server-to-server-payment-validation-api/
/// </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/partners/applications/{_opts.PackageName}/purchases/products/{Uri.EscapeDataString(productId)}/verify";
using var req = new HttpRequestMessage(HttpMethod.Post, url);
req.Headers.Add("X-Access-Token", _opts.MyketAccessToken);
req.Content = new StringContent(
JsonSerializer.Serialize(new { tokenId = token }),
System.Text.Encoding.UTF8,
"application/json");
var resp = await Http.SendAsync(req);
if (!resp.IsSuccessStatusCode) return false;
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
// purchaseState: 0 = successful purchase, 1 = failed.
if (doc.RootElement.TryGetProperty("purchaseState", out var ps) && ps.ValueKind == JsonValueKind.Number)
return ps.GetInt32() == 0;
return true;
}
}
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace Hokm.Server.Payments;
@@ -22,11 +23,17 @@ public sealed record PendingPayment(string UserId, string PackId, int AmountRial
/// </summary>
public sealed class ZarinpalService
{
private static readonly HttpClient Http = new();
// Bounded timeout so a hung gateway can't tie up request threads.
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly ZarinpalOptions _opts;
private readonly ILogger<ZarinpalService> _log;
private readonly ConcurrentDictionary<string, PendingPayment> _pending = new();
public ZarinpalService(ZarinpalOptions opts) => _opts = opts;
public ZarinpalService(ZarinpalOptions opts, ILogger<ZarinpalService> log)
{
_opts = opts;
_log = log;
}
public string ClientReturnUrl => _opts.ClientReturnUrl;
@@ -59,7 +66,7 @@ public sealed class ZarinpalService
return $"{Base}/pg/StartPay/{authority}";
}
}
catch { /* gateway unreachable */ }
catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment request failed for user {User}", userId); }
return null;
}
@@ -82,7 +89,7 @@ public sealed class ZarinpalService
return pending;
}
}
catch { /* gateway unreachable */ }
catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment verify failed for authority {Authority}", authority); }
return null;
}
}
@@ -56,12 +56,13 @@ public static class Gamification
// metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach.
private record AchDef(string Id, string? Metric, int RatingFloor, int Goal, int Coin, string NameFa, string NameEn, string Icon);
// Mirrors src/lib/online/gamification.ts (same ids/goals/coins/metrics).
private static int Coin(int g) => Math.Max(100, (int)Math.Floor((80.0 + g * 12) / 50.0 + 0.5) * 50);
// Reward escalates strictly per milestone tier (50, 150, 250 … capped 1500).
private static int CoinAt(int i) => Math.Min(1500, 50 + i * 100);
private static string Fa(int n) =>
new string(n.ToString().Select(c => c is >= '0' and <= '9' ? "۰۱۲۳۴۵۶۷۸۹"[c - '0'] : c).ToArray());
private static AchDef[] Tier(string metric, string prefix, string icon, int[] goals, Func<int, string> faName, Func<int, string> enName)
=> goals.Select(g => new AchDef($"{prefix}_{g}", metric, 0, g, Coin(g), faName(g), enName(g), icon)).ToArray();
=> goals.Select((g, i) => new AchDef($"{prefix}_{g}", metric, 0, g, CoinAt(i), faName(g), enName(g), icon)).ToArray();
private static readonly AchDef[] Achs = BuildAchs();
private static AchDef[] BuildAchs()
@@ -77,11 +78,11 @@ public static class Gamification
l.AddRange(Tier("roundsWon", "rounds", "🎴", new[] { 25, 100, 250, 500, 1000, 2000, 5000 }, g => $"{Fa(g)} دست برده", g => $"{g} Rounds Won"));
l.AddRange(Tier("tricks", "tricks", "🗂️", new[] { 50, 100, 250, 500, 1000, 2500, 5000, 10000 }, g => $"{Fa(g)} دست‌برد", g => $"{g} Tricks"));
l.AddRange(Tier("losses", "grit", "🛡️", new[] { 10, 50, 100 }, g => $"{Fa(g)} باخت", g => $"{g} Losses"));
l.Add(new AchDef("reach_silver", null, 1100, 1, 200, "لیگ نقره", "Reach Silver", "🥈"));
l.Add(new AchDef("reach_gold", null, 1300, 1, 500, "لیگ طلا", "Reach Gold", "🥇"));
l.Add(new AchDef("reach_platinum", null, 1500, 1, 1000, "لیگ پلاتین", "Reach Platinum", "🛡️"));
l.Add(new AchDef("reach_diamond", null, 1700, 1, 2000, "لیگ الماس", "Reach Diamond", "💠"));
l.Add(new AchDef("reach_master", null, 1900, 1, 4000, "لیگ استاد", "Reach Master", "👑"));
l.Add(new AchDef("reach_silver", null, 1100, 1, 150, "لیگ نقره", "Reach Silver", "🥈"));
l.Add(new AchDef("reach_gold", null, 1300, 1, 300, "لیگ طلا", "Reach Gold", "🥇"));
l.Add(new AchDef("reach_platinum", null, 1500, 1, 500, "لیگ پلاتین", "Reach Platinum", "🛡️"));
l.Add(new AchDef("reach_diamond", null, 1700, 1, 900, "لیگ الماس", "Reach Diamond", "💠"));
l.Add(new AchDef("reach_master", null, 1900, 1, 1500, "لیگ استاد", "Reach Master", "👑"));
return l.ToArray();
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using Hokm.Server.Data;
using Microsoft.EntityFrameworkCore;
namespace Hokm.Server.Profiles;
public record LeaderboardEntryDto(
int Rank, string Id, string DisplayName, string Avatar, string? AvatarImage,
int Level, int Rating, double LevelProgress, bool IsYou);
/// <summary>
/// Real, DB-backed leaderboard. Profiles are stored as JSON blobs (no rating
/// column to ORDER BY), so we load and rank in memory behind a short cache to
/// keep it cheap under load. Bounded scan so a large table can't exhaust memory.
/// </summary>
public sealed class LeaderboardService
{
private sealed record Row(string Id, string Name, string Avatar, string? Img, int Level, int Xp, int Rating);
private readonly IServiceScopeFactory _scopes;
private readonly object _lock = new();
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30);
private List<Row> _cache = new();
private DateTime _cachedAt = DateTime.MinValue;
public LeaderboardService(IServiceScopeFactory scopes) => _scopes = scopes;
private List<Row> Snapshot()
{
lock (_lock)
{
if (_cache.Count > 0 && DateTime.UtcNow - _cachedAt < Ttl) return _cache;
}
var list = new List<Row>();
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Cap the scan; ranking is by rating which lives inside the JSON blob.
var rows = db.Profiles.AsNoTracking().Take(5000).ToList();
foreach (var r in rows)
{
try
{
var p = JsonSerializer.Deserialize<ProfileDto>(r.Json, JsonOpts.Default);
if (p == null) continue;
list.Add(new Row(
string.IsNullOrEmpty(p.Id) ? r.Id : p.Id,
p.DisplayName, p.Avatar, p.AvatarImage, p.Level, p.Xp, p.Rating));
}
catch { /* skip malformed rows */ }
}
var top = list.OrderByDescending(x => x.Rating).ThenByDescending(x => x.Level).Take(100).ToList();
lock (_lock) { _cache = top; _cachedAt = DateTime.UtcNow; }
return top;
}
public List<LeaderboardEntryDto> Top(string? meId)
{
var snap = Snapshot();
var result = new List<LeaderboardEntryDto>(snap.Count);
for (int i = 0; i < snap.Count; i++)
{
var r = snap[i];
var need = Gamification.XpForLevel(r.Level);
var progress = need > 0 ? Math.Clamp((double)r.Xp / need, 0, 1) : 0;
result.Add(new LeaderboardEntryDto(
i + 1, r.Id, r.Name, r.Avatar, r.Img, r.Level, r.Rating, progress,
meId != null && r.Id == meId));
}
return result;
}
}
@@ -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
{
@@ -32,7 +41,7 @@ public class ProfileDto
public long? PlanUntil { get; set; }
public int Level { get; set; } = 1;
public int Xp { get; set; }
public int Coins { get; set; } = 1000;
public int Coins { get; set; } = 2000;
public int Rating { get; set; } = 1000;
public StatsDto Stats { get; set; } = new();
public List<string> OwnedAvatars { get; set; } = new() { "a-fox", "a-lion" };
@@ -48,11 +57,44 @@ 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 string? City { get; set; } // selected city id (see client IRAN_CITIES)
public bool CityRewardClaimed { get; set; } // one-time "set your city" reward granted
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; }
+152 -11
View File
@@ -11,10 +11,12 @@ public class ProfileService
public static readonly CoinPackDto[] Packs =
{
new() { Id = "p1", Coins = 50000, Bonus = 0, PriceToman = 95000, Tag = "starter" },
new() { Id = "p2", Coins = 120000, Bonus = 15000, PriceToman = 189000, Tag = "popular" },
new() { Id = "p3", Coins = 300000, Bonus = 50000, PriceToman = 389000, Tag = "best" },
new() { Id = "p4", Coins = 700000, Bonus = 150000, PriceToman = 790000 },
// Id == Cafe Bazaar / Myket SKU (in-app product id). The store is the
// source of truth for price; PriceToman here is only for display.
new() { Id = "Coin5K", Coins = 5000, Bonus = 0, PriceToman = 99000, Tag = "starter" },
new() { Id = "Coin12K", Coins = 11000, Bonus = 1000, PriceToman = 199000, Tag = "popular" },
new() { Id = "Coin28K", Coins = 24000, Bonus = 4000, PriceToman = 399000, Tag = "best" },
new() { Id = "Coin65K", Coins = 50000, Bonus = 15000, PriceToman = 799000 },
};
private static ProfileDto Default(string userId, string? name) => new()
@@ -54,18 +56,89 @@ public class ProfileService
await _db.SaveChangesAsync();
}
/// <summary>Distinct players who must flag an avatar as nudity before it auto-hides.</summary>
public const int NudityHideThreshold = 3;
/// <summary>
/// Record a moderation report (inappropriate avatar / insulting chat). Stored
/// in the write-only ledger as kind="report" so no schema change is needed;
/// Ref encodes "{targetId}|{reason}|{details}". Once enough *distinct* players
/// flag a target's avatar as nudity, the custom photo is auto-removed.
/// </summary>
public async Task ReportUser(string reporterUid, string targetId, string? reason, string? details)
{
if (string.IsNullOrWhiteSpace(targetId) || targetId == reporterUid) return;
var safeReason = reason is "nudity" or "insult" or "other" ? reason : "other";
var safeDetails = (details ?? "").Replace("\n", " ").Trim();
var @ref = $"{targetId}|{safeReason}|{safeDetails}";
if (@ref.Length > 480) @ref = @ref[..480];
var nudityPrefix = targetId + "|nudity";
// De-dupe nudity reports so a single player can't nuke an avatar alone.
if (safeReason == "nudity")
{
var already = await _db.Ledger.AnyAsync(
l => l.Kind == "report" && l.UserId == reporterUid && l.Ref!.StartsWith(nudityPrefix));
if (already) return;
}
await Ledger(reporterUid, "report", 0, @ref);
// Auto-hide a custom avatar once enough distinct players flag it.
if (safeReason == "nudity")
{
var reporters = await _db.Ledger
.Where(l => l.Kind == "report" && l.Ref!.StartsWith(nudityPrefix))
.Select(l => l.UserId)
.Distinct()
.CountAsync();
if (reporters >= NudityHideThreshold)
{
var target = await GetOrCreate(targetId, null);
if (!string.IsNullOrEmpty(target.AvatarImage))
{
target.AvatarImage = null; // revert to their default avatar
await SaveInternal(target);
}
}
}
}
public async Task<ProfileDto> Update(string uid, JsonElement patch)
{
var p = await GetOrCreate(uid, null);
if (patch.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) p.DisplayName = dn.GetString()!;
if (patch.TryGetProperty("avatar", out var av) && av.ValueKind == JsonValueKind.String) p.Avatar = av.GetString()!;
// Custom photo upload is gated behind level 25.
if (p.Level >= 25 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString();
// Custom photo upload is gated behind level 3.
if (p.Level >= 3 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString();
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()!;
return await Save(p);
// 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;
// One-time "set your city" reward: first non-empty city → +500 coins.
var cityRewarded = false;
if (patch.TryGetProperty("city", out var ci) && ci.ValueKind == JsonValueKind.String)
{
var city = ci.GetString();
p.City = city;
if (!string.IsNullOrWhiteSpace(city) && !p.CityRewardClaimed)
{
p.CityRewardClaimed = true;
p.Coins += CityReward;
cityRewarded = true;
}
}
await Save(p);
if (cityRewarded) await Ledger(uid, "city", CityReward, "profile-city");
return p;
}
/// <summary>One-time coin reward for setting your city (mirrors client CITY_REWARD).</summary>
public const int CityReward = 500;
public async Task<ProfileDto> UpgradePlan(string uid)
{
@@ -115,15 +188,81 @@ public class ProfileService
// Coin-priced XP packs (XP is intentionally expensive). Server-authoritative.
public static readonly Dictionary<string, (int Price, int Xp)> XpPacks = new()
{
["xp1"] = (5000, 200),
["xp2"] = (12000, 600),
["xp3"] = (25000, 1500),
["xp1"] = (1500, 200),
["xp2"] = (4000, 600),
["xp3"] = (8000, 1500),
};
// Gated gifts encode their tier in the id (`-t<n>-`); the gate is derived from
// the tier so the server enforces it without a 100-entry catalog mirror.
// Mirrors GIFT_TIERS in src/lib/online/types.ts.
private static readonly (int Level, int Rating)[] GiftGate =
{ (0, 0), (0, 0), (10, 0), (20, 0), (35, 0), (0, 1700) }; // index = tier (1..5)
private static (int Level, int Rating) GiftGateFor(string id)
{
var m = System.Text.RegularExpressions.Regex.Match(id, @"-t(\d)-");
if (m.Success && int.TryParse(m.Groups[1].Value, out var tier) && tier >= 1 && tier <= 5)
return GiftGate[tier];
return (0, 0);
}
// Per-item purchase gates for the named (non-tier-encoded) cosmetics, keyed by
// "kind:id" since ids repeat across kinds (e.g. "taunt" is both a reaction &
// sticker pack). Every one is still coin-priced — this only gates the purchase.
// ⚠️ Mirror of req* in src/lib/online/types.ts (AVATARS) + gamification.ts
// (CARD_BACKS/FRONTS, REACTION_PACKS, STICKER_PACKS). Keep both in sync.
private static readonly Dictionary<string, (int Level, int Rating, string? Ach)> ItemGate = new()
{
// avatars
["avatar:a-robot"] = (0, 0, "wins_50"),
["avatar:a-wizard"] = (0, 1300, null),
["avatar:a-ninja"] = (0, 0, "wins_100"),
["avatar:a-king"] = (0, 1500, null),
["avatar:a-genie"] = (0, 1700, null),
["avatar:a-crown"] = (0, 1900, "hakem_7"),
["avatar:a-gem"] = (0, 2100, "shutout_10"),
// card backs
["cardback:crimson"] = (0, 0, "wins_25"),
["cardback:ruby"] = (0, 1300, null),
["cardback:royal"] = (0, 0, "wins_50"),
["cardback:aurora"] = (0, 1500, null),
["cardback:obsidian"] = (0, 1700, null),
["cardback:imperial"] = (0, 1900, "hakem_7"),
// card fronts
["cardfront:parchment"] = (0, 1300, null),
["cardfront:mint"] = (0, 0, "wins_50"),
["cardfront:goldleaf"] = (0, 1500, null),
["cardfront:crystal"] = (0, 1700, null),
["cardfront:imperial-face"] = (0, 0, "wins_100"),
// reaction packs
["reactionpack:champion"] = (0, 1300, null),
["reactionpack:legend"] = (0, 0, "wins_100"),
// sticker packs
["stickerpack:hokm"] = (0, 0, "shutout_1"),
["stickerpack:persian"] = (0, 0, "wins_100"),
["stickerpack:taunt"] = (0, 0, "kot_25"),
["stickerpack:rulership"] = (0, 0, "hakem_7"),
["stickerpack:firestorm"] = (0, 0, "streak_10"),
["stickerpack:victory"] = (0, 1500, null),
["stickerpack:raghib"] = (0, 0, "kot_10"),
};
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
{
var p = await GetOrCreate(uid, null);
// Gated gift: locked until the player meets the tier's level/rating gate.
var gate = GiftGateFor(id);
if (p.Level < gate.Level || p.Rating < gate.Rating) return (false, p, "locked");
// Named-item gate (avatars/backs/fronts/reactions/stickers): coin-priced but
// locked until the level / rating / achievement requirement is met.
if (ItemGate.TryGetValue($"{kind}:{id}", out var ig) &&
(p.Level < ig.Level || p.Rating < ig.Rating ||
(ig.Ach != null && !p.Unlocked.Contains(ig.Ach))))
return (false, p, "locked");
// XP packs are consumable (grant XP, may level up) — not added to an owned list.
if (kind == "xp")
{
@@ -144,6 +283,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 +298,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 = { 100, 150, 200, 300, 400, 600, 1500 };
private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd");
public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid)
+136 -17
View File
@@ -7,6 +7,7 @@ using Hokm.Server.Game;
using Hokm.Server.Hubs;
using Hokm.Server.Payments;
using Hokm.Server.Profiles;
using Hokm.Server.Site;
using Hokm.Server.Social;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
@@ -15,9 +16,14 @@ using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// --- options ---
const string DevJwtKey = "dev-only-insecure-key-change-me-please-32+bytes!!";
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
if (string.IsNullOrWhiteSpace(jwt.Key))
jwt.Key = "dev-only-insecure-key-change-me-please-32+bytes!!";
jwt.Key = DevJwtKey;
// In Production a real secret is mandatory — refuse to boot with the dev key.
if (builder.Environment.IsProduction() && (jwt.Key == DevJwtKey || jwt.Key.Length < 32))
throw new InvalidOperationException(
"Jwt:Key (env JWT_KEY) must be a 32+ char secret in Production. Set it in ENV_FILE: openssl rand -hex 32");
builder.Services.AddSingleton(jwt);
builder.Services.AddSingleton<TokenService>();
builder.Services.AddSingleton<GameManager>();
@@ -28,12 +34,15 @@ var dbConn = builder.Configuration.GetConnectionString("Default");
builder.Services.AddDbContext<AppDbContext>(o =>
{
if (dbProvider.Equals("postgres", StringComparison.OrdinalIgnoreCase))
o.UseNpgsql(dbConn ?? "");
// Retry transient Postgres failures (network blips, DB restarts) so a
// brief outage doesn't surface as request errors in production.
o.UseNpgsql(dbConn ?? "", npg => npg.EnableRetryOnFailure(maxRetryCount: 5));
else
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
});
builder.Services.AddScoped<ProfileService>();
builder.Services.AddScoped<SocialService>();
builder.Services.AddSingleton<LeaderboardService>();
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
@@ -41,6 +50,38 @@ if (string.IsNullOrWhiteSpace(zp.MerchantId)) zp.MerchantId = "299685fb-cadf-4df
builder.Services.AddSingleton(zp);
builder.Services.AddSingleton<ZarinpalService>();
// --- FlatRender Pay broker (pay.flatrender.ir): shared ZarinPal merchant via the
// single verified callback domain. Preferred when configured; otherwise the
// direct ZarinpalService above is used. ---
var flatpay = builder.Configuration.GetSection("FlatPay").Get<FlatPayOptions>() ?? new FlatPayOptions();
builder.Services.AddSingleton(flatpay);
builder.Services.AddSingleton<FlatPayService>();
// --- Store in-app billing (Cafe Bazaar / Myket) verification ---
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOptions();
// Production guard: AllowUnverified credits coins WITHOUT verifying the purchase
// with the store — a forged token could mint coins. Never allow it in prod.
if (builder.Environment.IsProduction() && iab.AllowUnverified)
throw new InvalidOperationException(
"Iab:AllowUnverified (env IAB_ALLOW_UNVERIFIED) must be false in Production.");
builder.Services.AddSingleton(iab);
builder.Services.AddSingleton<IabService>();
// --- SMS OTP (Kavenegar). No ApiKey ⇒ dev mode (fixed code, no SMS sent). ---
var sms = builder.Configuration.GetSection("Sms").Get<SmsOptions>() ?? new SmsOptions();
// Production guard: with no API key the OTP service runs in DEV mode (accepts a
// fixed code for ANY phone), which would let anyone log in. Require a real key.
if (builder.Environment.IsProduction() && string.IsNullOrWhiteSpace(sms.ApiKey))
throw new InvalidOperationException(
"Sms:ApiKey (env SMS_API_KEY) is mandatory in Production — without it OTP runs in dev mode.");
builder.Services.AddSingleton(sms);
builder.Services.AddSingleton<OtpService>();
// --- Marketing site links (admin-editable) + shared-token admin auth ---
var admin = builder.Configuration.GetSection("Admin").Get<AdminOptions>() ?? new AdminOptions();
builder.Services.AddSingleton(admin);
builder.Services.AddSingleton<SiteLinksService>();
// --- SignalR (camelCase to match the TS client) ---
builder.Services
.AddSignalR()
@@ -116,13 +157,31 @@ app.UseAuthorization();
app.MapGet("/", () => Results.Json(new { service = "Barg-e Vasat SignalR server", status = "ok" }));
app.MapGet("/api/stats/online", (GameManager m) => Results.Json(new { online = m.OnlineCount }));
// --- dev auth (mock OTP + email). Replace with the V2 Identity Service later. ---
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
Results.Json(new { devCode = "1234", phone = req.Phone }));
app.MapPost("/api/auth/otp/verify", async (OtpVerify req, TokenService tokens, ProfileService profiles) =>
// --- Marketing site links: public read, admin-token write ---
app.MapGet("/api/site/links", (SiteLinksService s) => Results.Json(s.Get(), JsonOpts.Default));
app.MapPost("/api/admin/site/links", (HttpRequest req, AdminOptions admin, SiteLinksService s, SiteLinks body) =>
{
if (req.Code != "1234")
var token = req.Headers["X-Admin-Token"].ToString();
if (string.IsNullOrWhiteSpace(admin.Token) || token != admin.Token)
return Results.Json(new { error = "UNAUTHORIZED" }, statusCode: 401);
return Results.Json(s.Update(body), JsonOpts.Default);
});
// --- phone OTP (Kavenegar SMS) + email login ---
app.MapPost("/api/auth/otp/request", async (OtpRequest req, OtpService otp) =>
{
var r = await otp.Request(req.Phone);
if (r.Ok)
// devCode is only populated in dev mode (no API key); null in production.
return Results.Json(new { sent = true, phone = req.Phone, devCode = r.DevCode });
if (r.Error == "RATE_LIMITED")
return Results.Json(new { error = "RATE_LIMITED", retryAfter = r.RetryAfterSeconds }, statusCode: 429);
return Results.BadRequest(new { error = r.Error ?? "SMS_FAILED" });
});
app.MapPost("/api/auth/otp/verify", async (OtpVerify req, OtpService otp, TokenService tokens, ProfileService profiles) =>
{
if (!otp.Verify(req.Phone, req.Code))
return Results.BadRequest(new { error = "INVALID_CODE" });
var userId = "phone:" + req.Phone;
var p = await profiles.GetOrCreate(userId, req.Name);
@@ -148,6 +207,30 @@ 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();
// Report a player (inappropriate avatar / insulting chat).
app.MapPost("/api/report", async (ClaimsPrincipal u, ProfileService svc, ReportReq req) =>
{
await svc.ReportUser(Uid(u), req.TargetId, req.Reason, req.Details);
return Results.Json(new { ok = true }, JsonOpts.Default);
}).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();
// Real, DB-backed leaderboard (top players by rating).
app.MapGet("/api/leaderboard", (ClaimsPrincipal u, LeaderboardService lb) =>
Results.Json(lb.Top(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();
@@ -168,16 +251,21 @@ app.MapPost("/api/match/result", async (ClaimsPrincipal u, ProfileService svc, M
return Results.Json(new { reward, profile = p }, JsonOpts.Default);
}).RequireAuthorization();
// ZarinPal: create a payment → returns the StartPay URL to redirect to.
app.MapPost("/api/coins/pay/request", async (ClaimsPrincipal u, ZarinpalService zp, BuyReq req) =>
// Create a payment → returns the StartPay URL to redirect to. Prefers the shared
// FlatRender Pay broker (single verified ZarinPal domain) when configured.
app.MapPost("/api/coins/pay/request", async (ClaimsPrincipal u, ZarinpalService zp, FlatPayService fp, BuyReq req) =>
{
var pack = ProfileService.Packs.FirstOrDefault(p => p.Id == req.PackId);
if (pack == null) return Results.BadRequest(new { ok = false });
var url = await zp.Request(Uid(u), pack.Id, pack.PriceToman, $"خرید {pack.Coins + pack.Bonus} سکه برگ وسط");
var desc = $"خرید {pack.Coins + pack.Bonus} سکه برگ وسط";
var url = fp.Enabled
? await fp.Request(Uid(u), pack.Id, pack.PriceToman, desc)
: await zp.Request(Uid(u), pack.Id, pack.PriceToman, desc);
return url != null ? Results.Json(new { ok = true, url }) : Results.Json(new { ok = false });
}).RequireAuthorization();
// ZarinPal redirects the browser here after payment (no JWT — authority is the secret).
// Legacy direct path (used when the broker is not configured).
app.MapGet("/api/coins/pay/callback", async (string? authority, string? status, ZarinpalService zp, ProfileService svc) =>
{
var pending = authority != null ? await zp.Verify(authority, status ?? "") : null;
@@ -189,11 +277,39 @@ 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) =>
// FlatRender Pay broker webhook (server-to-server, HMAC-signed) → credit coins.
// Idempotent: the broker may deliver more than once.
app.MapPost("/api/coins/pay/webhook", async (HttpRequest http, FlatPayService fp, ProfileService svc) =>
{
// TODO: verify req.Token with Cafe Bazaar (Pardakht/Poolakey) or Myket dev API.
using var ms = new MemoryStream();
await http.Body.CopyToAsync(ms);
var raw = ms.ToArray();
if (!fp.VerifyWebhook(raw, http.Headers["X-FlatPay-Signature"]))
return Results.Unauthorized();
var ev = JsonSerializer.Deserialize<FlatPayWebhook>(raw,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (ev?.Id == null || !string.Equals(ev.Status, "Paid", StringComparison.OrdinalIgnoreCase))
return Results.Ok(new { ok = true }); // ack non-paid events (no retry)
if (!fp.MarkProcessed(ev.Id)) return Results.Ok(new { ok = true, duplicate = true });
string? userId = ev.Metadata.ValueKind == JsonValueKind.Object &&
ev.Metadata.TryGetProperty("user_id", out var uid) ? uid.GetString() : null;
string? packId = ev.Metadata.ValueKind == JsonValueKind.Object &&
ev.Metadata.TryGetProperty("pack_id", out var pid) ? pid.GetString() : null;
if (userId != null && packId != null)
await svc.BuyCoins(userId, packId);
return Results.Ok(new { ok = true });
});
// 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) =>
{
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 +343,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 +381,7 @@ 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);
record ReportReq(string TargetId, string? Reason, string? Details);
@@ -0,0 +1,120 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Hokm.Server.Site;
/// <summary>Admin-editable links + flags shown on the marketing site (bargevasat.ir).</summary>
public class SiteLinks
{
// Android stores
public string BazaarUrl { get; set; } = "";
public bool BazaarEnabled { get; set; } = false;
public string MyketUrl { get; set; } = "";
public bool MyketEnabled { get; set; } = false;
// Direct APK (optional, for sideloading)
public string DirectApkUrl { get; set; } = "";
public bool DirectApkEnabled { get; set; } = false;
// Play on web / PWA
public string WebPlayUrl { get; set; } = "https://app.bargevasat.ir";
public bool IosPwaEnabled { get; set; } = true; // iOS = Add to Home Screen
// Socials / support
public string Instagram { get; set; } = "";
public string Telegram { get; set; } = "";
public string SupportEmail { get; set; } = "";
public string SupportPhone { get; set; } = "";
public string AppVersion { get; set; } = "";
}
/// <summary>Shared-token admin auth (set ADMIN_TOKEN in ENV_FILE).</summary>
public class AdminOptions
{
public string Token { get; set; } = "";
}
/// <summary>
/// Loads/persists <see cref="SiteLinks"/> as a JSON file under a writable data dir
/// (mount a volume at it in prod). No DB migration required.
/// </summary>
public class SiteLinksService
{
private readonly string _path;
private readonly object _gate = new();
private SiteLinks _current;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
WriteIndented = true,
};
public SiteLinksService(IConfiguration config)
{
var dataDir = config["Site:DataDir"];
if (string.IsNullOrWhiteSpace(dataDir)) dataDir = "/data";
try { Directory.CreateDirectory(dataDir); } catch { /* fall back below */ }
if (!CanWrite(dataDir)) dataDir = AppContext.BaseDirectory; // dev fallback
_path = Path.Combine(dataDir, "site-links.json");
_current = Load() ?? Seed(config);
// Persist the seed so the file exists for the admin to edit.
if (!File.Exists(_path)) TrySave(_current);
}
public SiteLinks Get()
{
lock (_gate) return Clone(_current);
}
public SiteLinks Update(SiteLinks next)
{
lock (_gate)
{
_current = next;
TrySave(_current);
return Clone(_current);
}
}
private SiteLinks? Load()
{
try
{
if (!File.Exists(_path)) return null;
return JsonSerializer.Deserialize<SiteLinks>(File.ReadAllText(_path), JsonOpts);
}
catch { return null; }
}
private void TrySave(SiteLinks v)
{
try { File.WriteAllText(_path, JsonSerializer.Serialize(v, JsonOpts)); }
catch { /* read-only fs in dev — keep in-memory only */ }
}
// Seed defaults from config (Site section) when no file exists yet.
private static SiteLinks Seed(IConfiguration config)
{
var seeded = config.GetSection("Site:Links").Get<SiteLinks>();
return seeded ?? new SiteLinks();
}
private static SiteLinks Clone(SiteLinks v) =>
JsonSerializer.Deserialize<SiteLinks>(JsonSerializer.Serialize(v, JsonOpts), JsonOpts)!;
private static bool CanWrite(string dir)
{
try
{
var probe = Path.Combine(dir, ".write-test");
File.WriteAllText(probe, "ok");
File.Delete(probe);
return true;
}
catch { return false; }
}
}
@@ -6,6 +6,7 @@ public class FriendDto
public string Username { 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"; // online | offline
@@ -24,6 +25,7 @@ public class ChatMessageDto
public bool FromMe { get; set; }
public string Text { get; set; } = "";
public long Ts { get; set; }
public bool SenderPro { get; set; }
}
public class ConversationDto
@@ -32,3 +34,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; }
}
+156 -6
View File
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Hokm.Server.Data;
using Hokm.Server.Game;
@@ -14,6 +15,12 @@ public class SocialService
private readonly GameManager _mgr;
private readonly IHubContext<GameHub> _hub;
/// <summary>Max outgoing friend requests allowed per user within a rolling hour.</summary>
public const int FriendReqLimit = 10;
private static readonly TimeSpan FriendReqWindow = TimeSpan.FromHours(1);
// Process-wide log of each user's recent outgoing-request timestamps (resets on restart).
private static readonly ConcurrentDictionary<string, List<DateTime>> _reqLog = new();
public SocialService(AppDbContext db, GameManager mgr, IHubContext<GameHub> hub)
{
_db = db;
@@ -21,6 +28,28 @@ public class SocialService
_hub = hub;
}
/// <summary>
/// Records an outgoing friend-request attempt against the rolling-hour cap.
/// Returns false (with the minutes until a slot frees) when over the limit.
/// </summary>
private static bool TryRecordRequest(string uid, out int retryMins)
{
retryMins = 0;
var now = DateTime.UtcNow;
var list = _reqLog.GetOrAdd(uid, _ => new List<DateTime>());
lock (list)
{
list.RemoveAll(t => now - t >= FriendReqWindow);
if (list.Count >= FriendReqLimit)
{
retryMins = Math.Max(1, (int)Math.Ceiling((FriendReqWindow - (now - list[0])).TotalMinutes));
return false;
}
list.Add(now);
return true;
}
}
private async Task<FriendDto> FriendDtoFor(string userId)
{
var row = await _db.Profiles.FindAsync(userId);
@@ -31,6 +60,7 @@ public class SocialService
Username = p?.Username ?? userId,
DisplayName = p?.DisplayName ?? userId,
Avatar = p?.Avatar ?? "a-fox",
AvatarImage = p?.AvatarImage,
Level = p?.Level ?? 1,
Rating = p?.Rating ?? 1000,
Status = _mgr.IsOnline(userId) ? "online" : "offline",
@@ -60,21 +90,129 @@ public class SocialService
{
var digits = new string(query.Where(char.IsDigit).ToArray());
var targetId = query.Contains(':') ? query.Trim() : (digits.Length >= 4 ? "phone:" + digits : query.Trim());
return await AddFriendById(uid, targetId);
}
/// <summary>Send a friend request to a concrete user id (rate-limited to 10/hour).</summary>
public async Task<(bool ok, string messageFa, string messageEn)> AddFriendById(string uid, string targetId)
{
targetId = targetId.Trim();
var target = await _db.Profiles.FindAsync(targetId);
if (target == null || targetId == uid)
return (false, "کاربر پیدا نشد", "User not found");
if (await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId))
return (false, "از قبل دوست هستید", "Already friends");
if (!await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId))
{
// Already pending → idempotent success, doesn't consume the hourly quota.
if (await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId))
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
if (!TryRecordRequest(uid, out var mins))
return (false,
$"در هر ساعت حداکثر {FriendReqLimit} درخواست دوستی می‌توانید بفرستید. {mins} دقیقه دیگر تلاش کنید.",
$"You can send at most {FriendReqLimit} friend requests per hour. Try again in {mins} min.");
_db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow });
await _db.SaveChangesAsync();
await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid));
}
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
}
/* --------------------------- discovery ----------------------------- */
private PlayerSummaryDto ToSummary(ProfileDto p, HashSet<string> friendIds, HashSet<string> sentIds) => new()
{
Id = p.Id,
DisplayName = p.DisplayName,
Avatar = p.Avatar,
AvatarImage = p.AvatarImage,
Level = p.Level,
Rating = p.Rating,
Status = _mgr.IsOnline(p.Id) ? "online" : "offline",
Gender = p.Gender ?? "",
Title = p.Title,
IsFriend = friendIds.Contains(p.Id),
RequestSent = sentIds.Contains(p.Id),
};
/// <summary>Search players by display name (case-insensitive contains).</summary>
public async Task<List<PlayerSummaryDto>> SearchPlayers(string uid, string query)
{
query = (query ?? "").Trim();
if (query.Length == 0) return new();
var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet();
var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet();
var rows = await _db.Profiles.Where(p => p.Id != uid).ToListAsync();
var list = new List<PlayerSummaryDto>();
foreach (var row in rows)
{
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p?.DisplayName == null) continue;
if (!p.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
list.Add(ToSummary(p, friendIds, sentIds));
if (list.Count >= 20) break;
}
return list;
}
/// <summary>Suggested players to befriend (online-first, excludes existing friends).</summary>
public async Task<List<PlayerSummaryDto>> Suggested(string uid)
{
var friendIds = (await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync()).ToHashSet();
var sentIds = (await _db.FriendRequests.Where(r => r.FromUserId == uid).Select(r => r.ToUserId).ToListAsync()).ToHashSet();
var rows = await _db.Profiles.Where(p => p.Id != uid).Take(80).ToListAsync();
var list = new List<PlayerSummaryDto>();
foreach (var row in rows)
{
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p == null || friendIds.Contains(p.Id)) continue;
list.Add(ToSummary(p, friendIds, sentIds));
}
// Online players first, then by rating.
return list
.OrderByDescending(x => x.Status == "online")
.ThenByDescending(x => x.Rating)
.Take(12)
.ToList();
}
/// <summary>Another player's public profile + achievement board (no private fields).</summary>
public async Task<PublicProfileDto?> GetPublicProfile(string uid, string targetId)
{
targetId = targetId.Trim();
var row = await _db.Profiles.FindAsync(targetId);
if (row == null) return null;
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
if (p == null) return null;
var isFriend = await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId);
var requestSent = await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId);
var isYou = targetId == uid;
// Social links honor the owner's privacy: public → everyone, friends → only
// friends (and the owner), hidden → nobody.
var vis = string.IsNullOrEmpty(p.SocialsVisibility) ? "public" : p.SocialsVisibility;
var canSeeSocials = isYou || vis == "public" || (vis == "friends" && isFriend);
return new PublicProfileDto
{
Id = p.Id,
DisplayName = p.DisplayName,
Avatar = p.Avatar,
AvatarImage = p.AvatarImage,
Plan = p.Plan,
Title = p.Title,
Level = p.Level,
Rating = p.Rating,
Stats = p.Stats,
Achievements = p.Achievements,
Unlocked = p.Unlocked,
CreatedAt = p.CreatedAt,
Gender = p.Gender ?? "",
Socials = canSeeSocials ? p.Socials : null,
IsFriend = isFriend,
IsYou = isYou,
RequestSent = requestSent,
};
}
public async Task Accept(string uid, long requestId)
{
var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
@@ -128,7 +266,9 @@ public class SocialService
.OrderBy(m => m.CreatedAt).ToListAsync();
var unread = msgs.Where(m => m.UserId == peerId && m.PeerId == uid && !m.ReadByPeer).ToList();
if (unread.Count > 0) { unread.ForEach(m => m.ReadByPeer = true); await _db.SaveChangesAsync(); }
return msgs.Select(m => ToDto(m, uid)).ToList();
// Resolve each participant's plan once so premium (pro) senders show gold.
bool uidPro = await IsPro(uid), peerPro = await IsPro(peerId);
return msgs.Select(m => ToDto(m, uid, m.UserId == uid ? uidPro : peerPro)).ToList();
}
public async Task<ChatMessageDto> Send(string uid, string peerId, string text)
@@ -137,14 +277,24 @@ public class SocialService
_db.Messages.Add(m);
await _db.SaveChangesAsync();
await _hub.Clients.User(peerId).SendAsync("chat", new { peerId = uid });
return ToDto(m, uid);
return ToDto(m, uid, await IsPro(uid));
}
private static ChatMessageDto ToDto(MessageRow m, string uid) => new()
/// <summary>True when the user has an active premium (pro) plan.</summary>
private async Task<bool> IsPro(string userId)
{
var row = await _db.Profiles.FindAsync(userId);
if (row == null) return false;
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
return p?.Plan == "pro";
}
private static ChatMessageDto ToDto(MessageRow m, string uid, bool senderPro = false) => new()
{
Id = m.Id.ToString(),
FromMe = m.UserId == uid,
Text = m.Text,
Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(),
SenderPro = senderPro,
};
}
@@ -1,5 +1,5 @@
{
"// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production.",
"// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production. (The Docker deploy uses env vars from ENV_FILE instead — this file is for a bare-metal run.)",
"Jwt": {
"Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
"Issuer": "hokm",
@@ -12,10 +12,22 @@
"// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)",
"Default": "Host=db.<project>.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=<password>;SSL Mode=Require;Trust Server Certificate=true"
},
"Cors": {
"Origins": "https://bargevasat.ir,https://www.bargevasat.ir"
},
"Zarinpal": {
"MerchantId": "<your-live-merchant-id>",
"Sandbox": false,
"CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback",
"ClientReturnUrl": "https://yourdomain.com"
"CallbackUrl": "https://api.bargevasat.ir/api/coins/pay/callback",
"ClientReturnUrl": "https://bargevasat.ir"
},
"Iab": {
"// note": "Cafe Bazaar / Myket in-app purchase. Fill after publishing & getting store creds. Keys are FLAT (must match IabOptions).",
"PackageName": "com.bargevasat.app",
"BazaarClientId": "<bazaar-client-id>",
"BazaarClientSecret": "<bazaar-client-secret>",
"BazaarRefreshToken": "<bazaar-refresh-token>",
"MyketAccessToken": "<myket-access-token>",
"AllowUnverified": false
}
}
+24
View File
@@ -23,5 +23,29 @@
"Sandbox": true,
"CallbackUrl": "http://localhost:5005/api/coins/pay/callback",
"ClientReturnUrl": "http://localhost:3000"
},
"FlatPay": {
"BaseUrl": "https://pay.flatrender.ir",
"ApiKey": "",
"Secret": "",
"ReturnUrl": "https://bargevasat.ir/?pay=done"
},
"Iab": {
"PackageName": "com.bargevasat.app",
"BazaarClientId": "",
"BazaarClientSecret": "",
"BazaarRefreshToken": "",
"MyketAccessToken": "",
"AllowUnverified": false
},
"Sms": {
"Provider": "kavenegar",
"ApiKey": "",
"Template": "hokmotp",
"DevMode": false,
"DevCode": "1234",
"ResendCooldownSeconds": 60,
"MaxPerHour": 5,
"MaxGlobalPerHour": 300
}
}
+9
View File
@@ -0,0 +1,9 @@
node_modules
.next
out
.git
*.md
Dockerfile
.dockerignore
npm-debug.log*
tsconfig.tsbuildinfo
+7
View File
@@ -0,0 +1,7 @@
/node_modules
/.next/
/out/
/.turbo
*.tsbuildinfo
next-env.d.ts
.DS_Store
+23
View File
@@ -0,0 +1,23 @@
# Barg-e Vasat marketing site (Next.js static export → nginx).
FROM mirror.soroushasadi.com/node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
ARG NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/
RUN npm ci --legacy-peer-deps --strict-ssl=false --no-audit --no-fund \
--registry "${NPM_REGISTRY}"
COPY . .
# Public URLs baked at build time (browser-facing).
ARG NEXT_PUBLIC_API_URL=https://api.bargevasat.ir
ARG NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir
ARG NEXT_PUBLIC_SITE_URL=https://bargevasat.ir
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN npm run build
FROM mirror.soroushasadi.com/nginx:alpine
COPY --from=build /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=5s --retries=6 --start-period=10s \
CMD wget -q -O- http://127.0.0.1/ || exit 1
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import { fetchLinks, FALLBACK_LINKS, type SiteLinks } from "@/lib/links";
import { API_URL } from "@/lib/site";
type Field = { key: keyof SiteLinks; label: string; type: "text" | "bool" };
const FIELDS: Field[] = [
{ key: "bazaarUrl", label: "لینک کافه‌بازار", type: "text" },
{ key: "bazaarEnabled", label: "نمایش دکمهٔ کافه‌بازار", type: "bool" },
{ key: "myketUrl", label: "لینک مایکت", type: "text" },
{ key: "myketEnabled", label: "نمایش دکمهٔ مایکت", type: "bool" },
{ key: "directApkUrl", label: "لینک دانلود مستقیم APK", type: "text" },
{ key: "directApkEnabled", label: "نمایش دانلود مستقیم", type: "bool" },
{ key: "webPlayUrl", label: "آدرس بازی (وب)", type: "text" },
{ key: "iosPwaEnabled", label: "نمایش نصب iOS/PWA", type: "bool" },
{ key: "instagram", label: "اینستاگرام", type: "text" },
{ key: "telegram", label: "تلگرام", type: "text" },
{ key: "supportEmail", label: "ایمیل پشتیبانی", type: "text" },
{ key: "supportPhone", label: "تلفن پشتیبانی", type: "text" },
{ key: "appVersion", label: "نسخهٔ اپ", type: "text" },
];
export default function AdminPage() {
const [token, setToken] = useState("");
const [authed, setAuthed] = useState(false);
const [links, setLinks] = useState<SiteLinks>(FALLBACK_LINKS);
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function login() {
setBusy(true);
setMsg(null);
const l = await fetchLinks();
setLinks(l);
setAuthed(true);
setBusy(false);
}
async function save() {
setBusy(true);
setMsg(null);
try {
const res = await fetch(`${API_URL}/api/admin/site/links`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify(links),
});
if (res.status === 401) {
setMsg("توکن نامعتبر است.");
} else if (!res.ok) {
setMsg("خطا در ذخیره.");
} else {
setLinks(await res.json());
setMsg("ذخیره شد ✓");
}
} catch {
setMsg("سرور در دسترس نیست.");
}
setBusy(false);
}
function set<K extends keyof SiteLinks>(k: K, v: SiteLinks[K]) {
setLinks((p) => ({ ...p, [k]: v }));
}
if (!authed) {
return (
<section className="mx-auto max-w-md px-4 py-20">
<h1 className="text-2xl font-black gold-text">ورود مدیریت</h1>
<p className="mt-2 text-sm text-cream/60">توکن مدیریت را وارد کن.</p>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="ADMIN_TOKEN"
className="mt-5 w-full rounded-xl bg-navy-800 px-4 py-3 text-cream outline-none ring-1 ring-gold/20 focus:ring-gold/50"
/>
<button
onClick={login}
disabled={!token || busy}
className="mt-4 w-full rounded-xl btn-gold px-4 py-3 disabled:opacity-50"
>
ورود
</button>
<p className="mt-3 text-xs text-cream/45">
توکن همان مقدار ADMIN_TOKEN در فایل محیطی سرور است. ذخیره هنگام «ثبت» اعتبارسنجی میشود.
</p>
</section>
);
}
return (
<section className="mx-auto max-w-2xl px-4 py-14">
<h1 className="text-2xl font-black gold-text">مدیریت لینکها</h1>
<p className="mt-2 text-sm text-cream/60">لینکهای کافهبازار، مایکت، شبکههای اجتماعی و پشتیبانی را اینجا تنظیم کن.</p>
<div className="mt-8 space-y-4">
{FIELDS.map((f) =>
f.type === "bool" ? (
<label key={f.key} className="glass flex items-center justify-between rounded-xl px-4 py-3">
<span>{f.label}</span>
<input
type="checkbox"
checked={Boolean(links[f.key])}
onChange={(e) => set(f.key, e.target.checked as never)}
className="h-5 w-5 accent-[#d4af37]"
/>
</label>
) : (
<div key={f.key}>
<label className="mb-1 block text-sm text-cream/70">{f.label}</label>
<input
dir="ltr"
value={String(links[f.key] ?? "")}
onChange={(e) => set(f.key, e.target.value as never)}
className="w-full rounded-xl bg-navy-800 px-4 py-2.5 text-cream outline-none ring-1 ring-gold/15 focus:ring-gold/50"
/>
</div>
)
)}
</div>
<div className="mt-8 flex items-center gap-3">
<button onClick={save} disabled={busy} className="rounded-xl btn-gold px-6 py-3 disabled:opacity-50">
ثبت تغییرات
</button>
{msg && <span className="text-sm text-cream/80">{msg}</span>}
</div>
</section>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { Metadata } from "next";
import { PageShell } from "@/components/PageShell";
import { DownloadButtons } from "@/components/DownloadButtons";
export const metadata: Metadata = {
title: "دانلود و نصب",
description:
"برگ وسط را روی اندروید (کافه‌بازار، مایکت)، آیفون (نصب وب/PWA) یا مستقیماً در مرورگر اجرا کن. راهنمای گام‌به‌گام نصب.",
alternates: { canonical: "/download" },
};
function Steps({ items }: { items: string[] }) {
return (
<ol className="space-y-3">
{items.map((s, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full btn-gold text-sm font-black">
{i + 1}
</span>
<span className="pt-0.5 text-cream/80">{s}</span>
</li>
))}
</ol>
);
}
export default function DownloadPage() {
return (
<PageShell title="دانلود و نصب" subtitle="هر طور که دوست داری بازی کن — روی گوشی نصب کن یا مستقیم در مرورگر اجرا کن.">
<div className="mb-8">
<DownloadButtons variant="full" />
</div>
{/* Web */}
<div className="glass rounded-2xl p-6">
<h2 className="text-xl font-bold text-cream">🌐 بازی در مرورگر (بدون نصب)</h2>
<p className="mt-2 text-cream/70">
سریعترین راه: کافی است آدرس بازی را در مرورگر باز کنی و وارد شوی. هیچ نصبی لازم نیست و روی هر دستگاهی کار میکند.
</p>
</div>
{/* Android */}
<div id="android" className="glass rounded-2xl p-6">
<h2 className="text-xl font-bold text-cream">🤖 اندروید</h2>
<p className="mt-2 mb-4 text-cream/70">از کافهبازار یا مایکت نصب کن، یا اپ وب را به صفحهٔ اصلی اضافه کن:</p>
<Steps
items={[
"آدرس بازی را در مرورگر کروم باز کن.",
"روی منوی سه‌نقطهٔ بالا-راست بزن.",
"گزینهٔ «افزودن به صفحهٔ اصلی / Install app» را انتخاب کن.",
"آیکن برگ وسط مثل یک اپ روی گوشی‌ات می‌نشیند.",
]}
/>
</div>
{/* iOS */}
<div id="ios" className="glass rounded-2xl p-6">
<h2 className="text-xl font-bold text-cream">🍏 آیفون و آیپد (iOS)</h2>
<p className="mt-2 mb-4 text-cream/70">
روی iOS بازی را بهصورت وباپ (PWA) نصب کن درست مثل یک اپ واقعی، با آیکن روی صفحهٔ اصلی:
</p>
<Steps
items={[
"آدرس بازی را در مرورگر Safari باز کن.",
"روی دکمهٔ «اشتراک‌گذاری» (مربع با فلش رو به بالا) بزن.",
"کمی پایین برو و «Add to Home Screen / افزودن به صفحهٔ اصلی» را انتخاب کن.",
"روی «Add» بزن — آیکن برگ وسط روی صفحهٔ اصلی اضافه می‌شود و تمام‌صفحه اجرا می‌شود.",
]}
/>
<p className="mt-4 text-sm text-cream/55">
نکته: روی آیفون حتماً از مرورگر Safari استفاده کن؛ افزودن به صفحهٔ اصلی فقط در Safari کار میکند.
</p>
</div>
</PageShell>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { PageShell } from "@/components/PageShell";
export const metadata: Metadata = {
title: "سوال‌های متداول",
description: "پاسخ پرسش‌های رایج دربارهٔ بازی حکم آنلاین برگ وسط — رایگان بودن، نصب، بازی با دوستان و سکه‌ها.",
alternates: { canonical: "/faq" },
};
const FAQ = [
{ q: "بازی رایگان است؟", a: "بله، برگ وسط کاملاً رایگان است. می‌توانی همهٔ بخش‌ها را بدون پرداخت بازی کنی. خرید سکه فقط اختیاری است." },
{ q: "چطور با دوستانم بازی کنم؟", a: "یک اتاق خصوصی بساز، کد اتاق را برای دوستانت بفرست و هم‌تیمی و حریف‌هایت را انتخاب کن." },
{ q: "اینترنت لازم دارم؟", a: "برای بازی آنلاین بله، اما بخش «بازی با کامپیوتر» کاملاً آفلاین کار می‌کند." },
{ q: "روی آیفون نصب می‌شود؟", a: "بله، روی iOS از طریق Safari بازی را به صفحهٔ اصلی اضافه کن (PWA). راهنمای کامل در صفحهٔ دانلود هست." },
{ q: "سکه‌ها به چه درد می‌خورند؟", a: "با سکه در لیگ‌های بالاتر بازی می‌کنی و آیتم‌های ظاهری مثل آواتار، طرح کارت و عنوان می‌خری." },
{ q: "اگر وسط بازی قطع شوم چه می‌شود؟", a: "بازی‌ات زنده می‌ماند و می‌توانی برگردی و ادامه دهی." },
{ q: "کُت (کوت) یعنی چه؟", a: "اگر تیم حاکم همهٔ ۷ دست را ببرد، حریف «کُت» می‌شود و امتیاز و جایزهٔ بیشتری می‌گیری." },
];
export default function FaqPage() {
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: FAQ.map((f) => ({
"@type": "Question",
name: f.q,
acceptedAnswer: { "@type": "Answer", text: f.a },
})),
};
return (
<PageShell title="سوال‌های متداول">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<div className="space-y-3">
{FAQ.map((f) => (
<details key={f.q} className="glass group rounded-2xl p-5">
<summary className="cursor-pointer list-none text-lg font-bold text-cream marker:hidden">
{f.q}
</summary>
<p className="mt-3 text-cream/70">{f.a}</p>
</details>
))}
</div>
</PageShell>
);
}
+114
View File
@@ -0,0 +1,114 @@
@import "tailwindcss";
@import "@fontsource-variable/vazirmatn";
:root {
--navy-950: #070b18;
--navy-900: #0b1226;
--navy-800: #111a33;
--gold: #d4af37;
--gold-soft: #e7c873;
--teal: #2dd4bf;
--cream: #f5efe0;
}
@theme inline {
--color-navy-950: var(--navy-950);
--color-navy-900: var(--navy-900);
--color-navy-800: var(--navy-800);
--color-gold: var(--gold);
--color-gold-soft: var(--gold-soft);
--color-teal: var(--teal);
--color-cream: var(--cream);
--font-sans: "Vazirmatn Variable", ui-sans-serif, system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: radial-gradient(120% 120% at 50% 0%, #0e1730 0%, var(--navy-950) 60%);
color: var(--cream);
font-family: "Vazirmatn Variable", ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
[dir="rtl"] {
font-family: "Vazirmatn Variable", ui-sans-serif, system-ui, sans-serif;
}
/* Utility helpers */
.gold-text {
background: linear-gradient(180deg, var(--gold-soft), var(--gold));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.glass {
background: rgba(17, 26, 51, 0.55);
border: 1px solid rgba(212, 175, 55, 0.18);
backdrop-filter: blur(10px);
}
.btn-gold {
background: linear-gradient(180deg, var(--gold-soft), var(--gold));
color: #1a1206;
font-weight: 800;
}
.btn-gold:hover {
filter: brightness(1.06);
}
.felt {
background:
radial-gradient(120% 120% at 50% 0%, rgba(45, 212, 191, 0.08), transparent 60%),
radial-gradient(80% 80% at 80% 90%, rgba(212, 175, 55, 0.06), transparent 60%);
}
.card-pattern {
background-image:
linear-gradient(45deg, rgba(212, 175, 55, 0.04) 25%, transparent 25%),
linear-gradient(-45deg, rgba(212, 175, 55, 0.04) 25%, transparent 25%);
background-size: 22px 22px;
}
/* Gentle float for the hero logo. */
@keyframes float-y {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.float-y {
animation: float-y 5.5s ease-in-out infinite;
}
/* Soft gold halo behind the hero logo. */
.gold-halo {
background: radial-gradient(circle, rgba(212, 175, 55, 0.28), transparent 62%);
filter: blur(8px);
}
/* Card-suit accent for hero/section glyphs. */
.suit {
color: var(--gold);
opacity: 0.16;
user-select: none;
line-height: 1;
}
/* Thin gold hairline divider. */
.rule-gold {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.5), transparent);
}
@media (prefers-reduced-motion: reduce) {
.float-y { animation: none; }
html { scroll-behavior: auto; }
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { BRAND, SITE_URL } from "@/lib/site";
import { Nav } from "@/components/Nav";
import { Footer } from "@/components/Footer";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: `${BRAND.nameFa} | بازی حکم آنلاین رایگان`,
template: `%s | ${BRAND.nameFa}`,
},
description: BRAND.descFa,
keywords: [
"حکم",
"بازی حکم",
"حکم آنلاین",
"بازی ورق ایرانی",
"برگ وسط",
"بازی کارتی آنلاین",
"حکم با دوستان",
"Hokm",
"Barg-e Vasat",
],
applicationName: BRAND.nameFa,
authors: [{ name: BRAND.nameFa }],
alternates: { canonical: "/" },
openGraph: {
type: "website",
locale: "fa_IR",
url: SITE_URL,
siteName: BRAND.nameFa,
title: `${BRAND.nameFa} | بازی حکم آنلاین رایگان`,
description: BRAND.descFa,
images: [{ url: "/og.png", width: 1200, height: 630, alt: BRAND.nameFa }],
},
twitter: {
card: "summary_large_image",
title: `${BRAND.nameFa} | بازی حکم آنلاین`,
description: BRAND.descFa,
images: ["/og.png"],
},
icons: { icon: "/icon.svg", apple: "/icon.svg" },
// No web-app manifest on the marketing site — it's a plain SEO site, not an
// installable PWA. Only the game app (app.bargevasat.ir) is a PWA.
};
export const viewport: Viewport = {
themeColor: "#070b18",
width: "device-width",
initialScale: 1,
};
const jsonLd = {
"@context": "https://schema.org",
"@type": "VideoGame",
name: "برگ وسط",
alternateName: "Barg-e Vasat",
description: BRAND.descFa,
url: SITE_URL,
applicationCategory: "GameApplication",
genre: "بازی کارتی",
operatingSystem: "Android, iOS, Web",
inLanguage: "fa-IR",
offers: { "@type": "Offer", price: "0", priceCurrency: "IRR" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fa" dir="rtl">
<body>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Nav />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
+131
View File
@@ -0,0 +1,131 @@
import {
Users, Bot, Trophy, Gift, MessageCircle, Globe, ShieldCheck, Zap, Crown, Star,
} from "lucide-react";
import { DownloadButtons } from "@/components/DownloadButtons";
import { Logo } from "@/components/Logo";
import { BRAND } from "@/lib/site";
const FEATURES = [
{ icon: Users, title: "حکم ۴ نفره آنلاین", desc: "با بازیکن‌های واقعی از سراسر ایران، دونفره و تیمی بازی کن." },
{ icon: Bot, title: "بازی با هوش مصنوعی", desc: "آفلاین و بدون اینترنت، با ربات‌های هوشمند تمرین کن." },
{ icon: Trophy, title: "لیگ و رتبه‌بندی", desc: "از لیگ مبتدی تا استاد بالا برو و در جدول قهرمانان بدرخش." },
{ icon: Gift, title: "جایزه‌های روزانه", desc: "هر روز سکه بگیر، دستاورد باز کن و جوایز ویژه ببر." },
{ icon: MessageCircle, title: "چت و شکلک", desc: "سر میز با هم‌تیمی و حریف کل‌کل کن؛ استیکرهای فارسی." },
{ icon: Globe, title: "همه‌جا در دسترس", desc: "اندروید، آیفون و مرورگر — پیشرفتت همه‌جا همگام می‌شود." },
];
const STEPS = [
{ n: "۱", title: "وارد شو", desc: "با شماره موبایل ثبت‌نام کن — سریع و رایگان." },
{ n: "۲", title: "میز انتخاب کن", desc: "بازی سریع آنلاین، اتاق خصوصی با دوستان، یا بازی با کامپیوتر." },
{ n: "۳", title: "حکم بزن و ببر", desc: "حاکم شو، خال حکم را انتخاب کن و حریف را کُت کن!" },
];
const STATS = [
{ icon: Zap, label: "بازی سریع", value: "زیر ۱۵ ثانیه شروع" },
{ icon: ShieldCheck, label: "بدون تقلب", value: "سرور منصف و امن" },
{ icon: Crown, label: "کاملاً رایگان", value: "بدون اجبار خرید" },
];
export default function Home() {
return (
<>
{/* Hero */}
<section className="felt card-pattern relative overflow-hidden">
{/* decorative card suits floating in the backdrop */}
<span className="suit pointer-events-none absolute right-[6%] top-16 text-8xl"></span>
<span className="suit pointer-events-none absolute left-[8%] top-40 text-7xl"></span>
<span className="suit pointer-events-none absolute left-[14%] bottom-12 text-6xl"></span>
<span className="suit pointer-events-none absolute right-[12%] bottom-20 text-7xl"></span>
<div className="mx-auto max-w-6xl px-4 py-16 text-center sm:py-24">
{/* card-fan brand mark */}
<div className="relative mx-auto mb-8 grid h-40 w-40 place-items-center sm:h-48 sm:w-48">
<div className="gold-halo absolute inset-0 rounded-full" />
<div className="float-y relative">
<Logo size={160} glow />
</div>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full glass px-3 py-1 text-xs text-gold-soft">
<Star size={13} /> بازی حکمِ ایرانی، حرفهایتر از همیشه
</span>
<h1 className="mx-auto mt-6 max-w-3xl text-4xl font-black leading-tight sm:text-6xl">
<span className="gold-text">{BRAND.nameFa}</span>
<br />
بازی حکم آنلاین با دوستان
</h1>
<p className="mx-auto mt-5 max-w-2xl text-base leading-8 text-cream/70 sm:text-lg">
{BRAND.descFa}
</p>
<div className="mt-9 flex justify-center">
<DownloadButtons variant="hero" />
</div>
<div className="mx-auto mt-12 grid max-w-3xl gap-3 sm:grid-cols-3">
{STATS.map((s) => (
<div key={s.label} className="glass rounded-2xl px-4 py-4 transition hover:border-gold/40">
<s.icon className="mx-auto text-teal" size={22} />
<div className="mt-2 text-sm font-bold text-cream">{s.label}</div>
<div className="text-xs text-cream/55">{s.value}</div>
</div>
))}
</div>
</div>
</section>
{/* Features */}
<section id="features" className="mx-auto max-w-6xl px-4 py-16">
<h2 className="text-center text-3xl font-black sm:text-4xl">
چرا <span className="gold-text">برگ وسط</span>؟
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-cream/60">
همهٔ چیزی که یک بازی حکم بینقص لازم دارد، در یک اپ.
</p>
<div className="rule-gold mx-auto mt-6 max-w-xs" />
<div className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((f) => (
<div key={f.title} className="glass rounded-2xl p-6 transition hover:border-gold/40">
<f.icon className="text-gold" size={28} />
<h3 className="mt-4 text-lg font-bold text-cream">{f.title}</h3>
<p className="mt-2 text-sm leading-7 text-cream/65">{f.desc}</p>
</div>
))}
</div>
</section>
{/* How to play */}
<section className="mx-auto max-w-6xl px-4 py-16">
<div className="glass rounded-3xl p-8 sm:p-12">
<h2 className="text-center text-3xl font-black sm:text-4xl">در ۳ قدم شروع کن</h2>
<div className="mt-10 grid gap-6 sm:grid-cols-3">
{STEPS.map((s) => (
<div key={s.n} className="text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full btn-gold text-2xl font-black">
{s.n}
</div>
<h3 className="mt-4 text-lg font-bold text-cream">{s.title}</h3>
<p className="mt-2 text-sm leading-7 text-cream/65">{s.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Final CTA */}
<section className="mx-auto max-w-4xl px-4 py-16 text-center">
<div className="mx-auto mb-6 w-fit">
<Logo size={64} glow />
</div>
<h2 className="text-3xl font-black sm:text-4xl">
همین حالا <span className="gold-text">حکم</span> را شروع کن
</h2>
<p className="mx-auto mt-3 max-w-lg text-cream/65">
رایگان روی مرورگر بازی کن یا اپ را روی گوشیات نصب کن.
</p>
<div className="mt-8 flex justify-center">
<DownloadButtons variant="full" />
</div>
</section>
</>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { PageShell } from "@/components/PageShell";
import { BRAND } from "@/lib/site";
export const metadata: Metadata = {
title: "حریم خصوصی",
description: "سیاست حریم خصوصی برگ وسط: چه داده‌هایی جمع‌آوری می‌شود و چگونه از آن محافظت می‌کنیم.",
alternates: { canonical: "/privacy" },
};
export default function PrivacyPage() {
return (
<PageShell title="سیاست حریم خصوصی" subtitle="آخرین به‌روزرسانی: ۱۴۰۴">
<p>
برگ وسط ({BRAND.nameEn}) به حریم خصوصی شما احترام میگذارد. این سند توضیح میدهد که چه اطلاعاتی جمعآوری
میشود و چگونه استفاده میشود.
</p>
<h2 className="text-xl font-bold text-cream">۱. اطلاعاتی که جمعآوری میکنیم</h2>
<ul className="list-disc space-y-2 pr-5">
<li>شمارهٔ موبایل برای ورود و احراز هویت.</li>
<li>اطلاعات نمایه که خودتان وارد میکنید (نام نمایشی، آواتار، تنظیمات).</li>
<li>دادههای بازی مانند امتیاز، رتبه، سکه و دستاوردها.</li>
<li>اطلاعات فنی پایه برای پایداری سرویس (مانند نوع دستگاه و خطاها).</li>
</ul>
<h2 className="text-xl font-bold text-cream">۲. استفاده از اطلاعات</h2>
<p>
از اطلاعات فقط برای ارائهٔ سرویس بازی، ذخیرهٔ پیشرفت شما، جلوگیری از تقلب و بهبود تجربهٔ کاربری استفاده
میکنیم. اطلاعات شما را به اشخاص ثالث نمیفروشیم.
</p>
<h2 className="text-xl font-bold text-cream">۳. پرداختها</h2>
<p>
خریدهای درونبرنامهای از طریق درگاههای معتبر (زرینپال) و فروشگاهها (کافهبازار، مایکت) انجام میشود و
اطلاعات کارت بانکی شما نزد ما ذخیره نمیشود.
</p>
<h2 className="text-xl font-bold text-cream">۴. امنیت</h2>
<p>برای محافظت از دادهها از رمزنگاری و سرورهای امن استفاده میکنیم.</p>
<h2 className="text-xl font-bold text-cream">۵. حذف حساب</h2>
<p>
برای حذف حساب و دادههای مرتبط، از طریق ایمیل {BRAND.email} با ما تماس بگیرید.
</p>
<h2 className="text-xl font-bold text-cream">۶. تماس</h2>
<p>برای هر پرسشی دربارهٔ حریم خصوصی به {BRAND.email} ایمیل بزنید.</p>
</PageShell>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: "/admin" },
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
const routes = ["", "/download", "/faq", "/support", "/privacy", "/terms"];
return routes.map((r) => ({
url: `${SITE_URL}${r}`,
changeFrequency: r === "" ? "weekly" : "monthly",
priority: r === "" ? 1 : 0.7,
}));
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { PageShell } from "@/components/PageShell";
import { SupportContact } from "@/components/SupportContact";
export const metadata: Metadata = {
title: "پشتیبانی",
description: "از تیم پشتیبانی برگ وسط کمک بگیرید — ایمیل، تلگرام و اینستاگرام.",
alternates: { canonical: "/support" },
};
export default function SupportPage() {
return (
<PageShell title="پشتیبانی" subtitle="سوالی داری یا مشکلی پیش آمده؟ ما اینجاییم.">
<SupportContact />
<p className="text-sm text-cream/55">
پیش از تماس، نگاهی به{" "}
<a href="/faq" className="text-gold-soft underline">
سوالهای متداول
</a>{" "}
بینداز شاید جوابت همانجا باشد.
</p>
</PageShell>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { PageShell } from "@/components/PageShell";
import { BRAND } from "@/lib/site";
export const metadata: Metadata = {
title: "قوانین و مقررات",
description: "قوانین و شرایط استفاده از بازی حکم آنلاین برگ وسط.",
alternates: { canonical: "/terms" },
};
export default function TermsPage() {
return (
<PageShell title="قوانین و مقررات" subtitle="آخرین به‌روزرسانی: ۱۴۰۴">
<p>با استفاده از برگ وسط، شرایط زیر را میپذیرید.</p>
<h2 className="text-xl font-bold text-cream">۱. استفادهٔ مجاز</h2>
<p>
استفاده از تقلب، رباتهای غیرمجاز، سوءاستفاده از باگها یا هرگونه رفتار مخل بازی ممنوع است و میتواند به
مسدودسازی حساب منجر شود.
</p>
<h2 className="text-xl font-bold text-cream">۲. حساب کاربری</h2>
<p>مسئولیت حفظ امنیت حساب و فعالیتهای انجامشده با آن بر عهدهٔ شماست.</p>
<h2 className="text-xl font-bold text-cream">۳. سکه و خریدها</h2>
<p>
سکهها و آیتمهای مجازی ارزش واقعی پولی ندارند و قابل بازگشت به وجه نقد نیستند. خریدهای درونبرنامهای پس از
انجام، طبق قوانین فروشگاه مربوطه قابل بازگشتاند.
</p>
<h2 className="text-xl font-bold text-cream">۴. رفتار سر میز</h2>
<p>توهین، آزار و محتوای نامناسب در چت ممنوع است.</p>
<h2 className="text-xl font-bold text-cream">۵. تغییرات</h2>
<p>ممکن است این قوانین بهمرور بهروزرسانی شوند. ادامهٔ استفاده بهمنزلهٔ پذیرش نسخهٔ جدید است.</p>
<h2 className="text-xl font-bold text-cream">۶. تماس</h2>
<p>برای سوالها به {BRAND.email} ایمیل بزنید.</p>
</PageShell>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
import { Play, Smartphone, Download } from "lucide-react";
import { fetchLinks, FALLBACK_LINKS, type SiteLinks } from "@/lib/links";
import { APP_URL } from "@/lib/site";
function BazaarIcon() {
return <span className="text-lg">🛒</span>;
}
function MyketIcon() {
return <span className="text-lg">🟢</span>;
}
export function DownloadButtons({ variant = "hero" }: { variant?: "hero" | "full" }) {
const [links, setLinks] = useState<SiteLinks>(FALLBACK_LINKS);
useEffect(() => {
let on = true;
fetchLinks().then((l) => on && setLinks(l));
return () => {
on = false;
};
}, []);
const webUrl = links.webPlayUrl || APP_URL;
return (
<div className={variant === "hero" ? "flex flex-wrap gap-3" : "grid gap-3 sm:grid-cols-2"}>
{/* Always available: play in browser */}
<a href={webUrl} className="flex items-center justify-center gap-2 rounded-2xl btn-gold px-6 py-3.5 text-base">
<Play size={18} /> بازی در مرورگر (رایگان)
</a>
{links.bazaarEnabled && links.bazaarUrl && (
<a
href={links.bazaarUrl}
target="_blank"
rel="noopener"
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
>
<BazaarIcon /> کافهبازار
</a>
)}
{links.myketEnabled && links.myketUrl && (
<a
href={links.myketUrl}
target="_blank"
rel="noopener"
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
>
<MyketIcon /> مایکت
</a>
)}
{links.iosPwaEnabled && (
<a
href="/download#ios"
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
>
<span className="text-lg">🍏</span> نصب روی آیفون (iOS)
</a>
)}
{variant === "full" && links.iosPwaEnabled && (
<a
href="/download#android"
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
>
<Smartphone size={18} /> نصب روی اندروید (PWA)
</a>
)}
{links.directApkEnabled && links.directApkUrl && (
<a
href={links.directApkUrl}
className="flex items-center justify-center gap-2 rounded-2xl glass px-6 py-3.5 text-base text-cream hover:border-gold/40"
>
<Download size={18} /> دانلود مستقیم APK
</a>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import Link from "next/link";
import { Logo } from "./Logo";
import { BRAND } from "@/lib/site";
export function Footer() {
return (
<footer className="mt-24 border-t border-gold/10 bg-navy-950/60">
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-12 sm:grid-cols-2 md:grid-cols-4">
<div className="sm:col-span-2 md:col-span-1">
<div className="flex items-center gap-2">
<Logo size={30} />
<span className="font-extrabold gold-text">{BRAND.nameFa}</span>
</div>
<p className="mt-3 max-w-xs text-sm leading-7 text-cream/60">{BRAND.descFa}</p>
</div>
<div>
<h4 className="mb-3 font-bold text-cream">بازی</h4>
<ul className="space-y-2 text-sm text-cream/65">
<li><Link href="/#features" className="hover:text-cream">ویژگیها</Link></li>
<li><Link href="/download" className="hover:text-cream">دانلود و نصب</Link></li>
<li><Link href="/faq" className="hover:text-cream">سوالهای متداول</Link></li>
</ul>
</div>
<div>
<h4 className="mb-3 font-bold text-cream">قوانین</h4>
<ul className="space-y-2 text-sm text-cream/65">
<li><Link href="/privacy" className="hover:text-cream">حریم خصوصی</Link></li>
<li><Link href="/terms" className="hover:text-cream">قوانین و مقررات</Link></li>
<li><Link href="/support" className="hover:text-cream">پشتیبانی</Link></li>
</ul>
</div>
<div>
<h4 className="mb-3 font-bold text-cream">ارتباط</h4>
<ul className="space-y-2 text-sm text-cream/65">
<li><a href={`mailto:${BRAND.email}`} className="hover:text-cream">{BRAND.email}</a></li>
</ul>
</div>
</div>
<div className="border-t border-gold/10 py-5 text-center text-xs text-cream/45">
© {new Date().getFullYear()} {BRAND.nameFa} همهٔ حقوق محفوظ است.
</div>
</footer>
);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Brand mark — the app's card-fan icon (mirrors public/icon.svg): three gold-edged
* playing cards fanned out, a spade on the face card. Scales cleanly from the nav
* (≈34px) to the hero (≈160px). `glow` adds a soft gold halo for hero use.
*/
export function Logo({ size = 36, glow = false }: { size?: number; glow?: boolean }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
style={glow ? { filter: "drop-shadow(0 10px 36px rgba(212,175,55,0.35))" } : undefined}
>
<defs>
<radialGradient id="frbg" cx="50%" cy="36%" r="78%">
<stop offset="0" stopColor="#16284f" />
<stop offset="0.62" stopColor="#0a142e" />
<stop offset="1" stopColor="#060c1f" />
</radialGradient>
<linearGradient id="frgold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#f6e4a0" />
<stop offset="0.5" stopColor="#d4af37" />
<stop offset="1" stopColor="#b8860b" />
</linearGradient>
<linearGradient id="frface" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#fffdf7" />
<stop offset="1" stopColor="#f1e6cd" />
</linearGradient>
<linearGradient id="frnavy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#1d356a" />
<stop offset="1" stopColor="#0a142e" />
</linearGradient>
</defs>
<rect width="512" height="512" rx="116" fill="url(#frbg)" />
<circle cx="256" cy="196" r="185" fill="#2dd4bf" opacity="0.07" />
<rect x="30" y="30" width="452" height="452" rx="100" fill="none" stroke="url(#frgold)" strokeWidth="6" opacity="0.6" />
<g transform="rotate(-25 256 396)">
<rect x="182" y="180" width="148" height="210" rx="16" fill="url(#frnavy)" stroke="url(#frgold)" strokeWidth="4" />
<rect x="198" y="196" width="116" height="178" rx="10" fill="none" stroke="#d4af37" strokeWidth="2" opacity="0.45" />
<path d="M256 250 l16 35 -16 35 -16 -35 z" fill="#d4af37" opacity="0.75" />
</g>
<g transform="rotate(25 256 396)">
<rect x="182" y="180" width="148" height="210" rx="16" fill="url(#frnavy)" stroke="url(#frgold)" strokeWidth="4" />
<rect x="198" y="196" width="116" height="178" rx="10" fill="none" stroke="#d4af37" strokeWidth="2" opacity="0.45" />
<path d="M256 250 l16 35 -16 35 -16 -35 z" fill="#d4af37" opacity="0.75" />
</g>
<g transform="translate(0 -24)">
<rect x="181" y="178" width="150" height="212" rx="16" fill="url(#frface)" stroke="url(#frgold)" strokeWidth="5" />
<rect x="193" y="190" width="126" height="188" rx="10" fill="none" stroke="#d4af37" strokeWidth="2" />
<g transform="translate(256 268) scale(1.45)">
<path
d="M0,-44 C0,-44 -42,-6 -42,16 C-42,30 -31,40 -18,40 C-11,40 -5,37 0,32 C-2,44 -10,52 -20,55 L20,55 C10,52 2,44 0,32 C5,37 11,40 18,40 C31,40 42,30 42,16 C42,-6 0,-44 0,-44 Z"
fill="url(#frgold)"
stroke="#7a5a00"
strokeWidth="1.5"
/>
</g>
</g>
</svg>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Menu, X, Play } from "lucide-react";
import { Logo } from "./Logo";
import { APP_URL, BRAND } from "@/lib/site";
const NAV = [
{ href: "/#features", label: "ویژگی‌ها" },
{ href: "/download", label: "دانلود و نصب" },
{ href: "/faq", label: "سوال‌ها" },
{ href: "/support", label: "پشتیبانی" },
];
export function Nav() {
const [open, setOpen] = useState(false);
return (
<header className="sticky top-0 z-50 glass">
<nav className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<Link href="/" className="flex items-center gap-2">
<Logo size={34} />
<span className="text-lg font-extrabold gold-text">{BRAND.nameFa}</span>
</Link>
<div className="hidden items-center gap-6 md:flex">
{NAV.map((n) => (
<Link key={n.href} href={n.href} className="text-sm text-cream/80 hover:text-cream">
{n.label}
</Link>
))}
</div>
<div className="flex items-center gap-2">
<a
href={APP_URL}
className="hidden items-center gap-1.5 rounded-xl btn-gold px-4 py-2 text-sm sm:flex"
>
<Play size={16} /> بازی در مرورگر
</a>
<button
className="rounded-lg p-2 text-cream md:hidden"
onClick={() => setOpen((v) => !v)}
aria-label="منو"
>
{open ? <X size={22} /> : <Menu size={22} />}
</button>
</div>
</nav>
{open && (
<div className="border-t border-gold/10 px-4 pb-4 md:hidden">
<div className="flex flex-col gap-1 pt-2">
{NAV.map((n) => (
<Link
key={n.href}
href={n.href}
onClick={() => setOpen(false)}
className="rounded-lg px-3 py-2 text-cream/85 hover:bg-navy-800"
>
{n.label}
</Link>
))}
<a href={APP_URL} className="mt-2 flex items-center justify-center gap-1.5 rounded-xl btn-gold px-4 py-2.5">
<Play size={16} /> بازی در مرورگر
</a>
</div>
</div>
)}
</header>
);
}
+21
View File
@@ -0,0 +1,21 @@
export function PageShell({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="mx-auto max-w-3xl px-4 py-14">
<h1 className="text-3xl font-black sm:text-4xl gold-text">{title}</h1>
{subtitle && <p className="mt-3 text-cream/65">{subtitle}</p>}
<div className="mt-8 space-y-5 leading-8 text-cream/80">{children}</div>
</section>
);
}
export function Prose({ children }: { children: React.ReactNode }) {
return <div className="glass rounded-2xl p-6 leading-8 text-cream/80">{children}</div>;
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { Mail, Phone, Send } from "lucide-react";
import { fetchLinks, FALLBACK_LINKS, type SiteLinks } from "@/lib/links";
export function SupportContact() {
const [links, setLinks] = useState<SiteLinks>(FALLBACK_LINKS);
useEffect(() => {
let on = true;
fetchLinks().then((l) => on && setLinks(l));
return () => {
on = false;
};
}, []);
const email = links.supportEmail || FALLBACK_LINKS.supportEmail;
return (
<div className="grid gap-3 sm:grid-cols-2">
<a href={`mailto:${email}`} className="glass flex items-center gap-3 rounded-2xl p-5 hover:border-gold/40">
<Mail className="text-gold" /> <span>{email}</span>
</a>
{links.supportPhone && (
<a href={`tel:${links.supportPhone}`} className="glass flex items-center gap-3 rounded-2xl p-5 hover:border-gold/40">
<Phone className="text-gold" /> <span dir="ltr">{links.supportPhone}</span>
</a>
)}
{links.telegram && (
<a href={links.telegram} target="_blank" rel="noopener" className="glass flex items-center gap-3 rounded-2xl p-5 hover:border-gold/40">
<Send className="text-teal" /> <span>تلگرام پشتیبانی</span>
</a>
)}
{links.instagram && (
<a href={links.instagram} target="_blank" rel="noopener" className="glass flex items-center gap-3 rounded-2xl p-5 hover:border-gold/40">
<span className="text-lg">📷</span> <span>اینستاگرام</span>
</a>
)}
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
// Lint is skipped during `next build` (see next.config.ts). This minimal flat
// config keeps `eslint` runnable without extra deps.
export default [];
+46
View File
@@ -0,0 +1,46 @@
import { API_URL, APP_URL } from "./site";
export interface SiteLinks {
bazaarUrl: string;
bazaarEnabled: boolean;
myketUrl: string;
myketEnabled: boolean;
directApkUrl: string;
directApkEnabled: boolean;
webPlayUrl: string;
iosPwaEnabled: boolean;
instagram: string;
telegram: string;
supportEmail: string;
supportPhone: string;
appVersion: string;
}
// Safe defaults used until the API responds (or if it's unreachable).
export const FALLBACK_LINKS: SiteLinks = {
bazaarUrl: "",
bazaarEnabled: false,
myketUrl: "",
myketEnabled: false,
directApkUrl: "",
directApkEnabled: false,
webPlayUrl: APP_URL,
iosPwaEnabled: true,
instagram: "",
telegram: "",
supportEmail: "support@bargevasat.ir",
supportPhone: "",
appVersion: "",
};
/** Fetch admin-editable links at runtime (client-side). Falls back gracefully. */
export async function fetchLinks(): Promise<SiteLinks> {
try {
const res = await fetch(`${API_URL}/api/site/links`, { cache: "no-store" });
if (!res.ok) return FALLBACK_LINKS;
const data = (await res.json()) as Partial<SiteLinks>;
return { ...FALLBACK_LINKS, ...data };
} catch {
return FALLBACK_LINKS;
}
}
+13
View File
@@ -0,0 +1,13 @@
// Build-time public config (baked into the static bundle).
export const API_URL = (process.env.NEXT_PUBLIC_API_URL || "https://api.bargevasat.ir").replace(/\/$/, "");
export const APP_URL = (process.env.NEXT_PUBLIC_APP_URL || "https://app.bargevasat.ir").replace(/\/$/, "");
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || "https://bargevasat.ir").replace(/\/$/, "");
export const BRAND = {
nameFa: "برگ وسط",
nameEn: "Barg-e Vasat",
taglineFa: "بازی حکمِ آنلاین، رایگان و حرفه‌ای",
descFa:
"برگ وسط، بازی حکم ایرانی به‌صورت آنلاین: با دوستان یا هوش مصنوعی بازی کن، در لیگ‌ها بالا برو، سکه و دستاورد جمع کن. روی اندروید، iOS و مرورگر.",
email: "support@bargevasat.ir",
};
+12
View File
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
// Static export — the marketing site is fully static (SEO-friendly pre-rendered
// HTML) and served by nginx. Store links are fetched client-side at runtime from
// the API (so the admin can change them without a rebuild).
const nextConfig: NextConfig = {
output: "export",
images: { unoptimized: true },
trailingSlash: true,
};
export default nextConfig;
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Static export with trailingSlash: serve dir/index.html, or the .html twin.
location / {
try_files $uri $uri/ $uri.html /index.html;
}
# Never cache HTML — new deploys (new chunk hashes) are picked up immediately.
location ~* \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Long-cache immutable, content-hashed build assets.
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

Some files were not shown because too many files have changed in this diff Show More