Compare commits
101 Commits
e450a6a2ed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1af8e395ac | |||
| 5f07a0580e | |||
| 5f43392de2 | |||
| b12a7c7813 | |||
| 856fbab701 | |||
| 3875141f46 | |||
| 4fb5a1776f | |||
| 940e2af6d2 | |||
| fe3bedc631 | |||
| 2aac6257d6 | |||
| 0790ad6fe0 | |||
| 4739018488 | |||
| e5b48ecb26 | |||
| 23b3713b44 | |||
| f97354167d | |||
| c0e3fdb046 | |||
| 9901c5e6d4 | |||
| 6aa4f37642 | |||
| 60d44100a2 | |||
| d932dbbb52 | |||
| e1e3a716a4 | |||
| d05cce6550 | |||
| 8262fa79b3 | |||
| fefa9e2e3a | |||
| f059065d4b | |||
| 99b9ee5c91 | |||
| 7c6c9fcd90 | |||
| c287c7d62c | |||
| 868bef0c56 | |||
| 21fd5c123e | |||
| 76c4b68a74 | |||
| a35acea7e4 | |||
| 6530096994 | |||
| 6502b17356 | |||
| 974a6bf0ae | |||
| 97d3a02a3c | |||
| bc695bc8e9 | |||
| 78878efc22 | |||
| 53759be8b7 | |||
| 1954992203 | |||
| fdf4235fbd | |||
| 83d9c1c7d0 | |||
| 9cce016b90 | |||
| d1bd279eba | |||
| 7dbadee406 | |||
| 05945f215d | |||
| 8ffdc6a5b1 | |||
| cd5742d623 | |||
| bf5b07962d | |||
| 66c83991d4 | |||
| 7f08249fa7 | |||
| 6c431fee3e | |||
| a7c0900c3b | |||
| 55c0407d73 | |||
| 857287fa84 | |||
| 6641741669 | |||
| 8033023a1f | |||
| ad5b42db06 | |||
| efefbcec3d | |||
| deb83cf77c | |||
| 24a2c251ad | |||
| 494683b63b | |||
| 3d3241b976 | |||
| 34678c4e0e | |||
| 5e726e88ba | |||
| ac05a7b679 | |||
| 5c00f44fdc | |||
| 5b2fddee4a | |||
| 8efd357289 | |||
| 08d81cba65 | |||
| 78dea770d7 | |||
| cc63312305 | |||
| 3e37085d18 | |||
| e8b3172197 | |||
| c1ecdff729 | |||
| 7e9d83e79a | |||
| 48460c6282 | |||
| 6ed9279ac8 | |||
| af3274ae9f | |||
| 29b410eefc | |||
| c4513f7b0c | |||
| 5d38312ef0 | |||
| 8d0d4dc991 | |||
| 72efc03e2d | |||
| ccfc9b0536 | |||
| fd7bef36d8 | |||
| 3dd22aee1e | |||
| b0668e6e31 | |||
| 12177d2a33 | |||
| 3e0c0ed876 | |||
| 1fba9c2f96 | |||
| dcea0bc87c | |||
| 0847d2c7cf | |||
| ed3e11b64b | |||
| 36600fa494 | |||
| 38ac8b06d1 | |||
| e49df07c0f | |||
| 265d878f22 | |||
| 82b2bc0648 | |||
| 03dfbe1e67 | |||
| cb27a16dc1 |
@@ -3,6 +3,7 @@ node_modules
|
|||||||
out
|
out
|
||||||
android
|
android
|
||||||
server
|
server
|
||||||
|
site
|
||||||
.git
|
.git
|
||||||
.gitea
|
.gitea
|
||||||
*.md
|
*.md
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
if [ -n "$CURRENT" ]; then docker tag "$CURRENT" hokm-server:rollback && echo "rollback tag = $CURRENT"; fi
|
if [ -n "$CURRENT" ]; then docker tag "$CURRENT" hokm-server:rollback && echo "rollback tag = $CURRENT"; fi
|
||||||
|
|
||||||
- name: Build images
|
- name: Build images
|
||||||
run: docker compose build --parallel server web
|
run: docker compose build --parallel server web site
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
@@ -182,6 +182,22 @@ jobs:
|
|||||||
sleep 5
|
sleep 5
|
||||||
done
|
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
|
- name: Prune dangling images
|
||||||
if: success()
|
if: success()
|
||||||
run: docker image prune -f
|
run: docker image prune -f
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# 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
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
@@ -25,6 +29,12 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# built mobile artifacts (APK/AAB) + release signing secrets
|
||||||
|
/dist
|
||||||
|
/android/keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
@@ -54,3 +64,8 @@ next-env.d.ts
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
|
|
||||||
|
# store screenshot artifacts
|
||||||
|
/scripts/shots/
|
||||||
|
/store-assets/
|
||||||
|
/scripts/promo/
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -60,18 +60,33 @@ npm run build # next static export
|
|||||||
## 3. Feature status (DONE)
|
## 3. Feature status (DONE)
|
||||||
|
|
||||||
- Full offline vs-AI game (engine, AI, turn timer + auto-play, disconnect/reconnect sim).
|
- Full offline vs-AI game (engine, AI, turn timer + auto-play, disconnect/reconnect sim).
|
||||||
- Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards.
|
- Online multiplayer over SignalR: matchmaking (pro skips queue, bots fill after wait), live server-run ranked games, server-authoritative entry/rewards. **Matchmaking waits ~15s (randomized 12–18s) for humans, then bots fill** (`GameManager.NextQueueWaitMs`; mock mirrors it in `beginSearch`). MatchmakingScreen shows the elapsed timer + a bot-fill hint.
|
||||||
- **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).
|
- **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 600–6,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).
|
- **XP/levels:** every game grants XP, **winner ×2**; **premium (pro) ×1.5**; max level 100; curve `100*lvl + 15*lvl²`. **Store sells XP packs** (xp1 +200/5k, xp2 +600/12k, xp3 +1500/25k coins; consumable; unlocks level achievements).
|
||||||
- **Achievements:** ~100, metric-driven generator (categories: victory/kot/streak/hakem/level/rank/veteran), incl. "7× hakem", "7–0 sweep". Dedicated **AchievementsScreen** (tabbed) + Profile summary. Some unlock **sticker packs**.
|
- **Achievements:** ~100, metric-driven generator (categories: victory/kot/streak/hakem/level/rank/veteran), incl. "7× hakem", "7–0 sweep". Dedicated **AchievementsScreen** (tabbed) + Profile summary. Some unlock **sticker packs**.
|
||||||
- **Cosmetics:** avatars, titles (incl. expert/professional/captain/leader ladder), card **front**+**back**, reaction packs, sticker packs (custom SVG art incl. crown/seven-zip/streak-fire). Profile **photo upload gated at level ≥ 25**.
|
- **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.
|
||||||
- **Social:** friends + chat **server-persisted** (`Social/SocialService`, REST + hub `friendRequest`/`social`/`chat`); friend remove needs confirm. **Premium chat = animated gold bubbles**.
|
- **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.
|
- **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).
|
- **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({...})`.
|
- **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`).
|
||||||
- **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).
|
- **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`.
|
- **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`.
|
- **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
|
## 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).
|
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.
|
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** token verification (Cafe Bazaar Poolakey / Myket) — `/api/coins/iab/verify` is a stub.
|
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.
|
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.
|
- 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.
|
- 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).
|
- 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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -99,3 +99,8 @@ app/src/main/assets/public
|
|||||||
app/src/main/assets/capacitor.config.json
|
app/src/main/assets/capacitor.config.json
|
||||||
app/src/main/assets/capacitor.plugins.json
|
app/src/main/assets/capacitor.plugins.json
|
||||||
app/src/main/res/xml/config.xml
|
app/src/main/res/xml/config.xml
|
||||||
|
|
||||||
|
# Release signing — NEVER commit (back these up separately!)
|
||||||
|
keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# In‑app 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 / on‑device verification path (the current Bazaar flow uses
|
||||||
|
the `bazaar://in_app` deep‑link + server token verification, so this key is only
|
||||||
|
needed if/when on‑device signature verification is added).
|
||||||
|
|
||||||
|
```
|
||||||
|
MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwCQ6/F2F0yNSXULayEDzFPSse07K7q70pcxZrE+lzKyw8N4vx3yZKqj/rrYbe1JvS9iYDZy3q5G3x5tdi45Ggjer5uP1EP3oq/liONVcLXU206PTe0AfWQtruvA045iPn9aRv3ZaZBz9dniSA8rrX53+YxgGiENC9TShQ3uItQe12utsUcHO5Xj0av+ZufWkL5w/Mr1dQLlvHY8QT+R2uYv8sLBgcgOc9E8BKnOIO0CAwEAAQ==
|
||||||
|
```
|
||||||
|
|
||||||
|
## Myket
|
||||||
|
Required by the (to‑be‑built) native Myket billing plugin to verify the
|
||||||
|
`INAPP_DATA_SIGNATURE` of each purchase before crediting coins.
|
||||||
|
|
||||||
|
```
|
||||||
|
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbUBKRU4g1AQrbOO8GkcBn79ol0hbs5PZVd5vPP6za98BTc9leqvyGE+DwSg7lbsXTZxCzPRBS3m0qB9LShe70WG+RQapG9Q2lodszYkauicPkJSpbXWh/nfrziTWNqEHqUfCsC4+lkKSEkxDNa1Po7uZzbwaJ+Kf1+d8wSWYpxwIDAQAB
|
||||||
|
```
|
||||||
|
|
||||||
|
## In‑app 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 تومان |
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
apply plugin: 'com.android.application'
|
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 {
|
android {
|
||||||
namespace = "com.bargevasat.app"
|
namespace = "com.bargevasat.app"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
// AGP 8 disables AIDL by default; the Myket billing service needs it.
|
||||||
|
buildFeatures {
|
||||||
|
aidl true
|
||||||
|
}
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.bargevasat.app"
|
applicationId "com.bargevasat.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName "1.0"
|
versionName "1.1"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// 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:!*~'
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
if (keystorePropsFile.exists()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
android:label="@string/title_activity_main"
|
android:label="@string/title_activity_main"
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -38,4 +39,14 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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>
|
</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);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 56 KiB |
@@ -1,5 +1,51 @@
|
|||||||
package com.bargevasat.app;
|
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;
|
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"?>
|
<?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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportHeight="108"
|
android:viewportHeight="108"
|
||||||
android:viewportWidth="108">
|
android:viewportWidth="108">
|
||||||
<path
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
android:fillColor="#26A69A"
|
<aapt:attr name="android:fillColor">
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
<gradient
|
||||||
<path
|
android:type="radial"
|
||||||
android:fillColor="#00000000"
|
android:centerX="54"
|
||||||
android:pathData="M9,0L9,108"
|
android:centerY="40"
|
||||||
android:strokeColor="#33FFFFFF"
|
android:gradientRadius="82"
|
||||||
android:strokeWidth="0.8" />
|
android:startColor="#16284F"
|
||||||
<path
|
android:centerColor="#0A142E"
|
||||||
android:fillColor="#00000000"
|
android:endColor="#060C1F" />
|
||||||
android:pathData="M19,0L19,108"
|
</aapt:attr>
|
||||||
android:strokeColor="#33FFFFFF"
|
</path>
|
||||||
android:strokeWidth="0.8" />
|
<path android:pathData="M54,42m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0z">
|
||||||
<path
|
<aapt:attr name="android:fillColor">
|
||||||
android:fillColor="#00000000"
|
<gradient
|
||||||
android:pathData="M29,0L29,108"
|
android:type="radial"
|
||||||
android:strokeColor="#33FFFFFF"
|
android:centerX="54"
|
||||||
android:strokeWidth="0.8" />
|
android:centerY="42"
|
||||||
<path
|
android:gradientRadius="46"
|
||||||
android:fillColor="#00000000"
|
android:startColor="#1A2DD4BF"
|
||||||
android:pathData="M39,0L39,108"
|
android:endColor="#002DD4BF" />
|
||||||
android:strokeColor="#33FFFFFF"
|
</aapt:attr>
|
||||||
android:strokeWidth="0.8" />
|
</path>
|
||||||
<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" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,49 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
# Barg-e Vasat — ENV_FILE
|
# Barg-e Vasat — ENV_FILE TEMPLATE (placeholders only — NO real secrets here)
|
||||||
# Paste the contents of this file (filled in) into the Gitea repo secret:
|
# Copy to deploy/ENV_FILE.local (git-ignored), fill real values, and paste the
|
||||||
# https://git.soroushasadi.com/soroushdes/HokmPlay/settings/secrets → ENV_FILE
|
# WHOLE thing into the Gitea repo secret ENV_FILE. Saving the secret REPLACES
|
||||||
# The deploy job writes it verbatim to `.env`, which docker compose reads.
|
# the entire file — always paste the complete contents.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Host ports (1500–1600 range so the stack coexists with manual dev on 3000/5005)
|
# Ports
|
||||||
WEB_PORT=1500
|
WEB_PORT=1500
|
||||||
API_PORT=1505
|
API_PORT=1505
|
||||||
DB_PORT=1510
|
DB_PORT=1510
|
||||||
|
SITE_PORT=1520
|
||||||
|
|
||||||
# Database (postgres container)
|
# Database — MUST match the existing postgres volume's password
|
||||||
POSTGRES_PASSWORD=change-me-strong-password
|
POSTGRES_PASSWORD=<strong-password>
|
||||||
|
|
||||||
# JWT — generate with: openssl rand -hex 32
|
# 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_ISSUER=hokm
|
||||||
JWT_AUDIENCE=hokm-clients
|
JWT_AUDIENCE=hokm-clients
|
||||||
|
|
||||||
# Browser-facing API origin (host-mapped api port).
|
# URLs / CORS
|
||||||
# If the browser is NOT on the deploy host, use the host LAN IP instead of
|
NEXT_PUBLIC_SERVER_URL=https://api.bargevasat.ir
|
||||||
# localhost, e.g. http://172.28.144.1:1505 (localhost can be VPN-hijacked).
|
NEXT_PUBLIC_APP_URL=https://app.bargevasat.ir
|
||||||
NEXT_PUBLIC_SERVER_URL=http://localhost:1505
|
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.
|
# ZarinPal
|
||||||
CORS_ORIGINS=http://localhost:1500
|
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
|
# Admin panel token (openssl rand -hex 24)
|
||||||
# (no SSL) because the HTTPS mirror serves a partial cert chain that fresh
|
ADMIN_TOKEN=<admin-token>
|
||||||
# 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/
|
|
||||||
|
|
||||||
# ZarinPal (sandbox for now — switch in admin/panel later)
|
# In-app billing (Cafe Bazaar / Myket) — fill from the developer panels.
|
||||||
ZARINPAL_MERCHANT_ID=299685fb-cadf-4dfc-98e2-d4af5d81528d
|
IAB_PACKAGE_NAME=com.bargevasat.app
|
||||||
ZARINPAL_SANDBOX=true
|
IAB_BAZAAR_CLIENT_ID=<bazaar-client-id>
|
||||||
ZARINPAL_CALLBACK_URL=http://localhost:1505/api/coins/pay/callback
|
IAB_BAZAAR_CLIENT_SECRET=<bazaar-client-secret>
|
||||||
ZARINPAL_CLIENT_RETURN_URL=http://localhost:1500
|
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
|
||||||
|
|||||||
@@ -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 A‑records (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 DNS‑01 if behind the CDN — see SSL notes.)
|
||||||
|
|
||||||
|
## 3. nginx (edit /root/mirror-server/nginx/nginx.conf)
|
||||||
|
Replace the single Barg‑e 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 + health‑wait, 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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -44,15 +44,46 @@ services:
|
|||||||
ASPNETCORE_URLS: http://0.0.0.0:5005
|
ASPNETCORE_URLS: http://0.0.0.0:5005
|
||||||
Database__Provider: postgres
|
Database__Provider: postgres
|
||||||
ConnectionStrings__Default: "Host=db;Port=5432;Database=hokm;Username=hokm;Password=${POSTGRES_PASSWORD:-hokm_dev_pass}"
|
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__Issuer: ${JWT_ISSUER:-hokm}
|
||||||
Jwt__Audience: ${JWT_AUDIENCE:-hokm-clients}
|
Jwt__Audience: ${JWT_AUDIENCE:-hokm-clients}
|
||||||
# Comma-separated origins the browser uses to reach the web app.
|
# Comma-separated origins the browser uses to reach the web app.
|
||||||
Cors__Origins: ${CORS_ORIGINS:-http://localhost:1500}
|
Cors__Origins: ${CORS_ORIGINS:-http://localhost:1500}
|
||||||
Zarinpal__MerchantId: ${ZARINPAL_MERCHANT_ID:-299685fb-cadf-4dfc-98e2-d4af5d81528d}
|
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__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback}
|
||||||
Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500}
|
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:
|
ports:
|
||||||
- "${API_PORT:-1505}:5005"
|
- "${API_PORT:-1505}:5005"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -88,5 +119,29 @@ services:
|
|||||||
retries: 6
|
retries: 6
|
||||||
start_period: 10s
|
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:
|
volumes:
|
||||||
hokm_db_data:
|
hokm_db_data:
|
||||||
|
hokm_data:
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ server {
|
|||||||
try_files $uri $uri.html $uri/ /index.html;
|
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/ {
|
location /_next/static/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
|
|||||||
@@ -29,8 +29,10 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.7",
|
"eslint-config-next": "16.2.7",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
@@ -395,6 +397,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
@@ -3143,6 +3152,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4223,6 +4250,21 @@
|
|||||||
"node": ">= 10.0.0"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -6254,6 +6296,38 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -7,9 +7,21 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"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: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: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": {
|
"dependencies": {
|
||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
@@ -33,8 +45,10 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.7",
|
"eslint-config-next": "16.2.7",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -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>
|
<defs>
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
<radialGradient id="bg" cx="50%" cy="36%" r="78%">
|
||||||
<stop offset="0" stop-color="#0e1c3f"/>
|
<stop offset="0" stop-color="#16284f"/>
|
||||||
|
<stop offset="0.62" stop-color="#0a142e"/>
|
||||||
<stop offset="1" stop-color="#060c1f"/>
|
<stop offset="1" stop-color="#060c1f"/>
|
||||||
</linearGradient>
|
</radialGradient>
|
||||||
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0" stop-color="#f1da8a"/>
|
<stop offset="0" stop-color="#f6e4a0"/>
|
||||||
<stop offset="0.55" stop-color="#d4af37"/>
|
<stop offset="0.5" stop-color="#d4af37"/>
|
||||||
<stop offset="1" stop-color="#b8860b"/>
|
<stop offset="1" stop-color="#b8860b"/>
|
||||||
</linearGradient>
|
</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>
|
</defs>
|
||||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
|
||||||
<g fill="none" stroke="#d4af37" stroke-opacity="0.18" stroke-width="3">
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
<path d="M256 48 L464 256 L256 464 L48 256 Z"/>
|
<circle cx="256" cy="196" r="185" fill="#2dd4bf" opacity="0.07"/>
|
||||||
<path d="M256 120 L392 256 L256 392 L120 256 Z"/>
|
<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>
|
</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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -5,16 +5,15 @@
|
|||||||
"lang": "fa",
|
"lang": "fa",
|
||||||
"dir": "rtl",
|
"dir": "rtl",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "fullscreen",
|
||||||
"orientation": "any",
|
"display_override": ["fullscreen", "standalone", "minimal-ui"],
|
||||||
|
"orientation": "portrait",
|
||||||
"background_color": "#060c1f",
|
"background_color": "#060c1f",
|
||||||
"theme_color": "#060c1f",
|
"theme_color": "#060c1f",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||||
"src": "/icon.svg",
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
"sizes": "any",
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
"type": "image/svg+xml",
|
{ "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
"purpose": "any maskable"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -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); });
|
||||||
@@ -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); });
|
||||||
@@ -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 |
@@ -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 |
@@ -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); });
|
||||||
@@ -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); });
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ namespace Hokm.Server.Game;
|
|||||||
public record CardDto(string Suit, int Rank, string Id);
|
public record CardDto(string Suit, int Rank, string Id);
|
||||||
public record PlayedCardDto(int Seat, CardDto Card);
|
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 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 RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points);
|
||||||
|
|
||||||
public record GameStateDto(
|
public record GameStateDto(
|
||||||
@@ -34,7 +34,8 @@ public record GameStateDto(
|
|||||||
bool Ranked,
|
bool Ranked,
|
||||||
int Stake);
|
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 record ReactionDto(int Seat, string Reaction);
|
||||||
|
|
||||||
public static class Map
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,15 +10,23 @@ public sealed class Player
|
|||||||
public required string UserId { get; init; }
|
public required string UserId { get; init; }
|
||||||
public string Name { get; init; } = "";
|
public string Name { get; init; } = "";
|
||||||
public string Avatar { get; init; } = "a-fox";
|
public string Avatar { get; init; } = "a-fox";
|
||||||
|
public string? AvatarImage { get; init; }
|
||||||
public int Level { get; init; }
|
public int Level { get; init; }
|
||||||
public string Plan { get; init; } = "free";
|
public string Plan { get; init; } = "free";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
/// <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.
|
// Real players get priority:
|
||||||
private const int QueueWaitMs = 9000;
|
// • 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 =
|
private static readonly string[] BotNames =
|
||||||
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
|
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
|
||||||
@@ -30,7 +38,7 @@ public sealed class GameManager
|
|||||||
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
|
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
|
||||||
private readonly ConcurrentDictionary<string, string> _userRoom = new(); // userId -> roomId
|
private readonly ConcurrentDictionary<string, string> _userRoom = new(); // userId -> roomId
|
||||||
private readonly object _mmLock = new();
|
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();
|
private readonly Random _rng = new();
|
||||||
|
|
||||||
public GameManager(IHubContext<GameHub> hub, IServiceScopeFactory scopes)
|
public GameManager(IHubContext<GameHub> hub, IServiceScopeFactory scopes)
|
||||||
@@ -43,21 +51,26 @@ public sealed class GameManager
|
|||||||
|
|
||||||
public void StartMatchmaking(Player p)
|
public void StartMatchmaking(Player p)
|
||||||
{
|
{
|
||||||
// Pro players skip the queue entirely.
|
// One running game per player: if already in a live match, re-sync them to
|
||||||
if (p.Plan == "pro")
|
// 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;
|
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)
|
lock (_mmLock)
|
||||||
{
|
{
|
||||||
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
|
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
|
||||||
var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
|
var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
|
||||||
_waiting.Add((p, timer));
|
_waiting.Add((p, timer, DateTime.UtcNow));
|
||||||
_ = _hub.Clients.User(p.UserId).SendAsync("matchmaking",
|
|
||||||
new MatchmakingStateDto("searching", _waiting.Count, null));
|
|
||||||
if (_waiting.Count >= 4) FormGroupLocked(4);
|
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)
|
lock (_mmLock)
|
||||||
{
|
{
|
||||||
var idx = _waiting.FindIndex(w => w.player.UserId == userId);
|
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)
|
lock (_mmLock)
|
||||||
{
|
{
|
||||||
if (!_waiting.Any(w => w.player.UserId == userId)) return;
|
var idx = _waiting.FindIndex(w => w.player.UserId == userId);
|
||||||
FormGroupLocked(_waiting.Count); // start with whoever is waiting; bots fill the rest
|
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)
|
if (i < humans.Count)
|
||||||
{
|
{
|
||||||
var h = humans[i];
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -160,6 +224,7 @@ public sealed class GameManager
|
|||||||
if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0)
|
if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0)
|
||||||
_onlineUsers.TryRemove(userId, out _);
|
_onlineUsers.TryRemove(userId, out _);
|
||||||
CancelMatchmaking(userId);
|
CancelMatchmaking(userId);
|
||||||
|
LeavePrivate(userId); // free their private-room seat / close their room
|
||||||
RoomOf(userId)?.SetConnected(userId, false);
|
RoomOf(userId)?.SetConnected(userId, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class SeatSlot
|
|||||||
public string? UserId { get; set; }
|
public string? UserId { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Avatar { get; set; } = "a-fox";
|
public string Avatar { get; set; } = "a-fox";
|
||||||
|
public string? AvatarImage { get; set; }
|
||||||
public int Level { get; set; }
|
public int Level { get; set; }
|
||||||
public bool IsBot { get; set; }
|
public bool IsBot { get; set; }
|
||||||
public bool Connected { get; set; } = true;
|
public bool Connected { get; set; } = true;
|
||||||
@@ -28,7 +29,10 @@ public sealed class GameRoom : IDisposable
|
|||||||
private const int AiPlayMs = 800;
|
private const int AiPlayMs = 800;
|
||||||
private const int TrickPauseMs = 1100;
|
private const int TrickPauseMs = 1100;
|
||||||
private const int RoundPauseMs = 2500;
|
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 object _lock = new();
|
||||||
private readonly IHubContext<GameHub> _hub;
|
private readonly IHubContext<GameHub> _hub;
|
||||||
@@ -50,6 +54,9 @@ public sealed class GameRoom : IDisposable
|
|||||||
private int? _forfeitPendingTeam;
|
private int? _forfeitPendingTeam;
|
||||||
private string? _forfeitRequester;
|
private string? _forfeitRequester;
|
||||||
private Timer? _forfeitTimer;
|
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 Id { get; } = Guid.NewGuid().ToString("N")[..8];
|
||||||
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
||||||
@@ -195,12 +202,16 @@ public sealed class GameRoom : IDisposable
|
|||||||
if (seat is null) return;
|
if (seat is null) return;
|
||||||
int team = seat.Value % 2;
|
int team = seat.Value % 2;
|
||||||
var mate = Seats.FirstOrDefault(s => s.Seat % 2 == team && s.Seat != seat.Value);
|
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)
|
if (mate is null || mate.IsBot || mate.UserId is null || !mate.Connected)
|
||||||
{
|
{
|
||||||
FinalizeForfeit(team);
|
FinalizeForfeit(team);
|
||||||
return;
|
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;
|
_forfeitPendingTeam = team;
|
||||||
_forfeitRequester = userId;
|
_forfeitRequester = userId;
|
||||||
var requester = Seats.First(s => s.UserId == 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));
|
_ = _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)
|
private void Broadcast(string method, object payload)
|
||||||
{
|
{
|
||||||
foreach (var slot in Seats.Where(s => !s.IsBot && s.UserId != null && s.Connected))
|
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();
|
p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList();
|
||||||
|
|
||||||
var seatPlayers = Seats.OrderBy(s => s.Seat)
|
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
|
RoundResultDto? rr = State.LastRoundResult is null ? null
|
||||||
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
|
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
|
|
||||||
namespace Hokm.Server.Hubs;
|
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]
|
[Authorize]
|
||||||
public sealed class GameHub : Hub
|
public sealed class GameHub : Hub
|
||||||
@@ -32,15 +32,48 @@ public sealed class GameHub : Hub
|
|||||||
UserId = Uid,
|
UserId = Uid,
|
||||||
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
|
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
|
||||||
Avatar = req.Avatar,
|
Avatar = req.Avatar,
|
||||||
|
AvatarImage = req.AvatarImage,
|
||||||
Level = req.Level,
|
Level = req.Level,
|
||||||
Plan = req.Plan,
|
Plan = req.Plan,
|
||||||
});
|
});
|
||||||
|
|
||||||
public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid);
|
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 PlayCard(string cardId) => _manager.PlayCard(Uid, cardId);
|
||||||
public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit);
|
public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit);
|
||||||
public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction);
|
public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction);
|
||||||
public void RequestForfeit() => _manager.RequestForfeit(Uid);
|
public void RequestForfeit() => _manager.RequestForfeit(Uid);
|
||||||
public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid);
|
public void ConfirmForfeit() => _manager.ConfirmForfeit(Uid);
|
||||||
public void DeclineForfeit() => _manager.DeclineForfeit(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.Collections.Concurrent;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Hokm.Server.Payments;
|
namespace Hokm.Server.Payments;
|
||||||
|
|
||||||
@@ -22,11 +23,17 @@ public sealed record PendingPayment(string UserId, string PackId, int AmountRial
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ZarinpalService
|
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 ZarinpalOptions _opts;
|
||||||
|
private readonly ILogger<ZarinpalService> _log;
|
||||||
private readonly ConcurrentDictionary<string, PendingPayment> _pending = new();
|
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;
|
public string ClientReturnUrl => _opts.ClientReturnUrl;
|
||||||
|
|
||||||
@@ -59,7 +66,7 @@ public sealed class ZarinpalService
|
|||||||
return $"{Base}/pg/StartPay/{authority}";
|
return $"{Base}/pg/StartPay/{authority}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* gateway unreachable */ }
|
catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment request failed for user {User}", userId); }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +89,7 @@ public sealed class ZarinpalService
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* gateway unreachable */ }
|
catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment verify failed for authority {Authority}", authority); }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,12 +56,13 @@ public static class Gamification
|
|||||||
// metric: wins|kotsFor|bestWinStreak|shutoutWins|games|tricks|level ; ratingFloor>0 = rank ach.
|
// 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);
|
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).
|
// 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) =>
|
private static string Fa(int n) =>
|
||||||
new string(n.ToString().Select(c => c is >= '0' and <= '9' ? "۰۱۲۳۴۵۶۷۸۹"[c - '0'] : c).ToArray());
|
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)
|
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 readonly AchDef[] Achs = BuildAchs();
|
||||||
private static AchDef[] 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("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("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.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_silver", null, 1100, 1, 150, "لیگ نقره", "Reach Silver", "🥈"));
|
||||||
l.Add(new AchDef("reach_gold", null, 1300, 1, 500, "لیگ طلا", "Reach Gold", "🥇"));
|
l.Add(new AchDef("reach_gold", null, 1300, 1, 300, "لیگ طلا", "Reach Gold", "🥇"));
|
||||||
l.Add(new AchDef("reach_platinum", null, 1500, 1, 1000, "لیگ پلاتین", "Reach Platinum", "🛡️"));
|
l.Add(new AchDef("reach_platinum", null, 1500, 1, 500, "لیگ پلاتین", "Reach Platinum", "🛡️"));
|
||||||
l.Add(new AchDef("reach_diamond", null, 1700, 1, 2000, "لیگ الماس", "Reach Diamond", "💠"));
|
l.Add(new AchDef("reach_diamond", null, 1700, 1, 900, "لیگ الماس", "Reach Diamond", "💠"));
|
||||||
l.Add(new AchDef("reach_master", null, 1900, 1, 4000, "لیگ استاد", "Reach Master", "👑"));
|
l.Add(new AchDef("reach_master", null, 1900, 1, 1500, "لیگ استاد", "Reach Master", "👑"));
|
||||||
return l.ToArray();
|
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; }
|
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>
|
/// <summary>Mirrors the client UserProfile (camelCase JSON).</summary>
|
||||||
public class ProfileDto
|
public class ProfileDto
|
||||||
{
|
{
|
||||||
@@ -32,7 +41,7 @@ public class ProfileDto
|
|||||||
public long? PlanUntil { get; set; }
|
public long? PlanUntil { get; set; }
|
||||||
public int Level { get; set; } = 1;
|
public int Level { get; set; } = 1;
|
||||||
public int Xp { get; set; }
|
public int Xp { get; set; }
|
||||||
public int Coins { get; set; } = 1000;
|
public int Coins { get; set; } = 2000;
|
||||||
public int Rating { get; set; } = 1000;
|
public int Rating { get; set; } = 1000;
|
||||||
public StatsDto Stats { get; set; } = new();
|
public StatsDto Stats { get; set; } = new();
|
||||||
public List<string> OwnedAvatars { get; set; } = new() { "a-fox", "a-lion" };
|
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 List<string> Unlocked { get; set; } = new();
|
||||||
public long CreatedAt { get; set; }
|
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
|
// daily reward streak
|
||||||
public int DailyDay { get; set; } = 1;
|
public int DailyDay { get; set; } = 1;
|
||||||
public string? DailyLastClaimed { get; set; } // yyyy-MM-dd
|
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 class MatchSummaryDto
|
||||||
{
|
{
|
||||||
public bool Ranked { get; set; }
|
public bool Ranked { get; set; }
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ public class ProfileService
|
|||||||
|
|
||||||
public static readonly CoinPackDto[] Packs =
|
public static readonly CoinPackDto[] Packs =
|
||||||
{
|
{
|
||||||
new() { Id = "p1", Coins = 50000, Bonus = 0, PriceToman = 95000, Tag = "starter" },
|
// Id == Cafe Bazaar / Myket SKU (in-app product id). The store is the
|
||||||
new() { Id = "p2", Coins = 120000, Bonus = 15000, PriceToman = 189000, Tag = "popular" },
|
// source of truth for price; PriceToman here is only for display.
|
||||||
new() { Id = "p3", Coins = 300000, Bonus = 50000, PriceToman = 389000, Tag = "best" },
|
new() { Id = "Coin5K", Coins = 5000, Bonus = 0, PriceToman = 99000, Tag = "starter" },
|
||||||
new() { Id = "p4", Coins = 700000, Bonus = 150000, PriceToman = 790000 },
|
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()
|
private static ProfileDto Default(string userId, string? name) => new()
|
||||||
@@ -54,19 +56,90 @@ public class ProfileService
|
|||||||
await _db.SaveChangesAsync();
|
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)
|
public async Task<ProfileDto> Update(string uid, JsonElement patch)
|
||||||
{
|
{
|
||||||
var p = await GetOrCreate(uid, null);
|
var p = await GetOrCreate(uid, null);
|
||||||
if (patch.TryGetProperty("displayName", out var dn) && dn.ValueKind == JsonValueKind.String) p.DisplayName = dn.GetString()!;
|
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()!;
|
if (patch.TryGetProperty("avatar", out var av) && av.ValueKind == JsonValueKind.String) p.Avatar = av.GetString()!;
|
||||||
// Custom photo upload is gated behind level 25.
|
// Custom photo upload is gated behind level 3.
|
||||||
if (p.Level >= 25 && patch.TryGetProperty("avatarImage", out var ai) && ai.ValueKind == JsonValueKind.String) p.AvatarImage = ai.GetString();
|
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("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("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()!;
|
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)
|
public async Task<ProfileDto> UpgradePlan(string uid)
|
||||||
{
|
{
|
||||||
var p = await GetOrCreate(uid, null);
|
var p = await GetOrCreate(uid, null);
|
||||||
@@ -115,15 +188,81 @@ public class ProfileService
|
|||||||
// Coin-priced XP packs (XP is intentionally expensive). Server-authoritative.
|
// Coin-priced XP packs (XP is intentionally expensive). Server-authoritative.
|
||||||
public static readonly Dictionary<string, (int Price, int Xp)> XpPacks = new()
|
public static readonly Dictionary<string, (int Price, int Xp)> XpPacks = new()
|
||||||
{
|
{
|
||||||
["xp1"] = (5000, 200),
|
["xp1"] = (1500, 200),
|
||||||
["xp2"] = (12000, 600),
|
["xp2"] = (4000, 600),
|
||||||
["xp3"] = (25000, 1500),
|
["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)
|
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
|
||||||
{
|
{
|
||||||
var p = await GetOrCreate(uid, null);
|
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.
|
// XP packs are consumable (grant XP, may level up) — not added to an owned list.
|
||||||
if (kind == "xp")
|
if (kind == "xp")
|
||||||
{
|
{
|
||||||
@@ -144,6 +283,7 @@ public class ProfileService
|
|||||||
"cardback" => p.OwnedCardBacks,
|
"cardback" => p.OwnedCardBacks,
|
||||||
"reactionpack" => p.OwnedReactionPacks,
|
"reactionpack" => p.OwnedReactionPacks,
|
||||||
"stickerpack" => p.OwnedStickerPacks,
|
"stickerpack" => p.OwnedStickerPacks,
|
||||||
|
"title" => p.OwnedTitles,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
if (list == null) return (false, null, "bad_kind");
|
if (list == null) return (false, null, "bad_kind");
|
||||||
@@ -158,7 +298,8 @@ public class ProfileService
|
|||||||
|
|
||||||
/* ----------------------------- daily ------------------------------ */
|
/* ----------------------------- 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");
|
private static string Today => DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid)
|
public async Task<(int day, string? lastClaimed, bool available)> GetDaily(string uid)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Hokm.Server.Game;
|
|||||||
using Hokm.Server.Hubs;
|
using Hokm.Server.Hubs;
|
||||||
using Hokm.Server.Payments;
|
using Hokm.Server.Payments;
|
||||||
using Hokm.Server.Profiles;
|
using Hokm.Server.Profiles;
|
||||||
|
using Hokm.Server.Site;
|
||||||
using Hokm.Server.Social;
|
using Hokm.Server.Social;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -15,9 +16,14 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// --- options ---
|
// --- options ---
|
||||||
|
const string DevJwtKey = "dev-only-insecure-key-change-me-please-32+bytes!!";
|
||||||
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
|
var jwt = builder.Configuration.GetSection("Jwt").Get<JwtOptions>() ?? new JwtOptions();
|
||||||
if (string.IsNullOrWhiteSpace(jwt.Key))
|
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(jwt);
|
||||||
builder.Services.AddSingleton<TokenService>();
|
builder.Services.AddSingleton<TokenService>();
|
||||||
builder.Services.AddSingleton<GameManager>();
|
builder.Services.AddSingleton<GameManager>();
|
||||||
@@ -28,12 +34,15 @@ var dbConn = builder.Configuration.GetConnectionString("Default");
|
|||||||
builder.Services.AddDbContext<AppDbContext>(o =>
|
builder.Services.AddDbContext<AppDbContext>(o =>
|
||||||
{
|
{
|
||||||
if (dbProvider.Equals("postgres", StringComparison.OrdinalIgnoreCase))
|
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
|
else
|
||||||
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
|
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<ProfileService>();
|
builder.Services.AddScoped<ProfileService>();
|
||||||
builder.Services.AddScoped<SocialService>();
|
builder.Services.AddScoped<SocialService>();
|
||||||
|
builder.Services.AddSingleton<LeaderboardService>();
|
||||||
|
|
||||||
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
|
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
|
||||||
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
|
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(zp);
|
||||||
builder.Services.AddSingleton<ZarinpalService>();
|
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) ---
|
// --- SignalR (camelCase to match the TS client) ---
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddSignalR()
|
.AddSignalR()
|
||||||
@@ -116,13 +157,31 @@ app.UseAuthorization();
|
|||||||
app.MapGet("/", () => Results.Json(new { service = "Barg-e Vasat SignalR server", status = "ok" }));
|
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 }));
|
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. ---
|
// --- Marketing site links: public read, admin-token write ---
|
||||||
app.MapPost("/api/auth/otp/request", (OtpRequest req) =>
|
app.MapGet("/api/site/links", (SiteLinksService s) => Results.Json(s.Get(), JsonOpts.Default));
|
||||||
Results.Json(new { devCode = "1234", phone = req.Phone }));
|
app.MapPost("/api/admin/site/links", (HttpRequest req, AdminOptions admin, SiteLinksService s, SiteLinks body) =>
|
||||||
|
|
||||||
app.MapPost("/api/auth/otp/verify", async (OtpVerify req, TokenService tokens, ProfileService profiles) =>
|
|
||||||
{
|
{
|
||||||
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" });
|
return Results.BadRequest(new { error = "INVALID_CODE" });
|
||||||
var userId = "phone:" + req.Phone;
|
var userId = "phone:" + req.Phone;
|
||||||
var p = await profiles.GetOrCreate(userId, req.Name);
|
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))
|
Results.Json(await svc.Update(Uid(u), patch), JsonOpts.Default))
|
||||||
.RequireAuthorization();
|
.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) =>
|
app.MapPost("/api/profile/plan", async (ClaimsPrincipal u, ProfileService svc) =>
|
||||||
Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default))
|
Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default))
|
||||||
.RequireAuthorization();
|
.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);
|
return Results.Json(new { reward, profile = p }, JsonOpts.Default);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// ZarinPal: create a payment → returns the StartPay URL to redirect to.
|
// Create a payment → returns the StartPay URL to redirect to. Prefers the shared
|
||||||
app.MapPost("/api/coins/pay/request", async (ClaimsPrincipal u, ZarinpalService zp, BuyReq req) =>
|
// 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);
|
var pack = ProfileService.Packs.FirstOrDefault(p => p.Id == req.PackId);
|
||||||
if (pack == null) return Results.BadRequest(new { ok = false });
|
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 });
|
return url != null ? Results.Json(new { ok = true, url }) : Results.Json(new { ok = false });
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// ZarinPal redirects the browser here after payment (no JWT — authority is the secret).
|
// 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) =>
|
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;
|
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");
|
return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store in-app purchase (Cafe Bazaar / Myket): the native app sends the purchase
|
// FlatRender Pay broker webhook (server-to-server, HMAC-signed) → credit coins.
|
||||||
// token; we credit the matching pack. (SKU == packId for now.)
|
// Idempotent: the broker may deliver more than once.
|
||||||
app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabVerifyReq req) =>
|
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);
|
var (ok, p, coins) = await svc.BuyCoins(Uid(u), req.ProductId);
|
||||||
return ok
|
return ok
|
||||||
? Results.Json(new { ok, profile = p, coins }, JsonOpts.Default)
|
? 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();
|
Results.Json(await s.ListRequests(Uid(u)), JsonOpts.Default)).RequireAuthorization();
|
||||||
app.MapPost("/api/friends/add", async (ClaimsPrincipal u, SocialService s, QueryReq r) =>
|
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);
|
return Results.Json(new { ok, messageFa = fa, messageEn = en }, JsonOpts.Default);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
app.MapPost("/api/friends/accept", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
|
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 BuyReq(string PackId);
|
||||||
record ShopBuyReq(string Kind, string Id, int Price);
|
record ShopBuyReq(string Kind, string Id, int Price);
|
||||||
record IabVerifyReq(string Store, string ProductId, string Token);
|
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 IdReq(string Id);
|
||||||
record SendReq(string PeerId, string Text);
|
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 Username { get; set; } = "";
|
||||||
public string DisplayName { get; set; } = "";
|
public string DisplayName { get; set; } = "";
|
||||||
public string Avatar { get; set; } = "a-fox";
|
public string Avatar { get; set; } = "a-fox";
|
||||||
|
public string? AvatarImage { get; set; }
|
||||||
public int Level { get; set; }
|
public int Level { get; set; }
|
||||||
public int Rating { get; set; }
|
public int Rating { get; set; }
|
||||||
public string Status { get; set; } = "offline"; // online | offline
|
public string Status { get; set; } = "offline"; // online | offline
|
||||||
@@ -24,6 +25,7 @@ public class ChatMessageDto
|
|||||||
public bool FromMe { get; set; }
|
public bool FromMe { get; set; }
|
||||||
public string Text { get; set; } = "";
|
public string Text { get; set; } = "";
|
||||||
public long Ts { get; set; }
|
public long Ts { get; set; }
|
||||||
|
public bool SenderPro { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ConversationDto
|
public class ConversationDto
|
||||||
@@ -32,3 +34,19 @@ public class ConversationDto
|
|||||||
public ChatMessageDto? LastMessage { get; set; }
|
public ChatMessageDto? LastMessage { get; set; }
|
||||||
public int Unread { get; set; }
|
public int Unread { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A discoverable player in the social "find friends" hub.</summary>
|
||||||
|
public class PlayerSummaryDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
public string Avatar { get; set; } = "a-fox";
|
||||||
|
public string? AvatarImage { get; set; }
|
||||||
|
public int Level { get; set; }
|
||||||
|
public int Rating { get; set; }
|
||||||
|
public string Status { get; set; } = "offline";
|
||||||
|
public string Gender { get; set; } = "";
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public bool IsFriend { get; set; }
|
||||||
|
public bool RequestSent { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Hokm.Server.Data;
|
using Hokm.Server.Data;
|
||||||
using Hokm.Server.Game;
|
using Hokm.Server.Game;
|
||||||
@@ -14,6 +15,12 @@ public class SocialService
|
|||||||
private readonly GameManager _mgr;
|
private readonly GameManager _mgr;
|
||||||
private readonly IHubContext<GameHub> _hub;
|
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)
|
public SocialService(AppDbContext db, GameManager mgr, IHubContext<GameHub> hub)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
@@ -21,6 +28,28 @@ public class SocialService
|
|||||||
_hub = hub;
|
_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)
|
private async Task<FriendDto> FriendDtoFor(string userId)
|
||||||
{
|
{
|
||||||
var row = await _db.Profiles.FindAsync(userId);
|
var row = await _db.Profiles.FindAsync(userId);
|
||||||
@@ -31,6 +60,7 @@ public class SocialService
|
|||||||
Username = p?.Username ?? userId,
|
Username = p?.Username ?? userId,
|
||||||
DisplayName = p?.DisplayName ?? userId,
|
DisplayName = p?.DisplayName ?? userId,
|
||||||
Avatar = p?.Avatar ?? "a-fox",
|
Avatar = p?.Avatar ?? "a-fox",
|
||||||
|
AvatarImage = p?.AvatarImage,
|
||||||
Level = p?.Level ?? 1,
|
Level = p?.Level ?? 1,
|
||||||
Rating = p?.Rating ?? 1000,
|
Rating = p?.Rating ?? 1000,
|
||||||
Status = _mgr.IsOnline(userId) ? "online" : "offline",
|
Status = _mgr.IsOnline(userId) ? "online" : "offline",
|
||||||
@@ -60,21 +90,129 @@ public class SocialService
|
|||||||
{
|
{
|
||||||
var digits = new string(query.Where(char.IsDigit).ToArray());
|
var digits = new string(query.Where(char.IsDigit).ToArray());
|
||||||
var targetId = query.Contains(':') ? query.Trim() : (digits.Length >= 4 ? "phone:" + digits : query.Trim());
|
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);
|
var target = await _db.Profiles.FindAsync(targetId);
|
||||||
if (target == null || targetId == uid)
|
if (target == null || targetId == uid)
|
||||||
return (false, "کاربر پیدا نشد", "User not found");
|
return (false, "کاربر پیدا نشد", "User not found");
|
||||||
if (await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId))
|
if (await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId))
|
||||||
return (false, "از قبل دوست هستید", "Already friends");
|
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))
|
||||||
_db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow });
|
return (true, "درخواست دوستی ارسال شد", "Friend request sent");
|
||||||
await _db.SaveChangesAsync();
|
if (!TryRecordRequest(uid, out var mins))
|
||||||
await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid));
|
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");
|
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)
|
public async Task Accept(string uid, long requestId)
|
||||||
{
|
{
|
||||||
var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
|
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();
|
.OrderBy(m => m.CreatedAt).ToListAsync();
|
||||||
var unread = msgs.Where(m => m.UserId == peerId && m.PeerId == uid && !m.ReadByPeer).ToList();
|
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(); }
|
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)
|
public async Task<ChatMessageDto> Send(string uid, string peerId, string text)
|
||||||
@@ -137,14 +277,24 @@ public class SocialService
|
|||||||
_db.Messages.Add(m);
|
_db.Messages.Add(m);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
await _hub.Clients.User(peerId).SendAsync("chat", new { peerId = uid });
|
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(),
|
Id = m.Id.ToString(),
|
||||||
FromMe = m.UserId == uid,
|
FromMe = m.UserId == uid,
|
||||||
Text = m.Text,
|
Text = m.Text,
|
||||||
Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(),
|
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": {
|
"Jwt": {
|
||||||
"Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
|
"Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
|
||||||
"Issuer": "hokm",
|
"Issuer": "hokm",
|
||||||
@@ -12,10 +12,22 @@
|
|||||||
"// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)",
|
"// 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"
|
"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": {
|
"Zarinpal": {
|
||||||
"MerchantId": "<your-live-merchant-id>",
|
"MerchantId": "<your-live-merchant-id>",
|
||||||
"Sandbox": false,
|
"Sandbox": false,
|
||||||
"CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback",
|
"CallbackUrl": "https://api.bargevasat.ir/api/coins/pay/callback",
|
||||||
"ClientReturnUrl": "https://yourdomain.com"
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,5 +23,29 @@
|
|||||||
"Sandbox": true,
|
"Sandbox": true,
|
||||||
"CallbackUrl": "http://localhost:5005/api/coins/pay/callback",
|
"CallbackUrl": "http://localhost:5005/api/coins/pay/callback",
|
||||||
"ClientReturnUrl": "http://localhost:3000"
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
npm-debug.log*
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/node_modules
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
/.turbo
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||