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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 18:39:24 +03:30
parent e450a6a2ed
commit cb27a16dc1
49 changed files with 3438 additions and 592 deletions
+102
View File
@@ -0,0 +1,102 @@
// Store in-app billing (Cafe Bazaar / Myket) for coin packs.
//
// - **Bazaar** (embedded PWA): pure deep-link flow. We navigate to
// `bazaar://in_app?...&sku=...&redirect_url=...`; Bazaar processes payment and
// reopens the app at redirect_url with `?purchaseToken=...`. We stash the SKU
// first, then on return POST the token to `/api/coins/iab/verify`.
// - **Myket**: native AIDL billing. A Capacitor plugin must inject
// `window.MyketBilling` (see ANDROID.md). We call `.purchase(sku)` and POST the
// returned token to verify. Without the bridge, Myket is "unavailable".
//
// The active store is the build flavor `NEXT_PUBLIC_STORE` (bazaar|myket|web),
// overridden at runtime if the Myket native bridge is present.
import { CoinPack } from "./online/types";
export type StoreId = "bazaar" | "myket" | "web";
const ENV_STORE = ((process.env.NEXT_PUBLIC_STORE as StoreId | undefined) ?? "web");
const PACKAGE = process.env.NEXT_PUBLIC_APP_PACKAGE ?? "com.bargevasat.hokm";
const PENDING_SKU_KEY = "iab_pending_sku";
/** Native bridge contract a Myket Capacitor plugin must fulfil. */
interface MyketBridge {
available?: boolean;
purchase: (sku: string) => Promise<{ purchaseToken: string; productId?: string }>;
consume?: (token: string) => Promise<void>;
}
declare global {
interface Window {
MyketBilling?: MyketBridge;
}
}
export function getStore(): StoreId {
if (typeof window !== "undefined" && window.MyketBilling?.available) return "myket";
return ENV_STORE;
}
/** True when coin purchases should go through a store (not the web ZarinPal gateway). */
export function isStoreBilling(): boolean {
return getStore() !== "web";
}
function skuFor(pack: CoinPack): string {
return pack.sku ?? pack.id;
}
export type PurchaseStart =
| { kind: "redirect" } // Bazaar — the app navigated away; result arrives on return
| { kind: "token"; store: StoreId; productId: string; token: string } // Myket — verify now
| { kind: "unavailable" };
/** Begin a store purchase for a coin pack. */
export async function purchaseViaStore(pack: CoinPack): Promise<PurchaseStart> {
const store = getStore();
const sku = skuFor(pack);
if (store === "bazaar") {
try {
localStorage.setItem(PENDING_SKU_KEY, sku);
} catch {
/* ignore storage errors */
}
const redirect = encodeURIComponent(window.location.origin + window.location.pathname);
window.location.href =
`bazaar://in_app?package_name=${encodeURIComponent(PACKAGE)}` +
`&sku=${encodeURIComponent(sku)}&redirect_url=${redirect}`;
return { kind: "redirect" };
}
if (store === "myket" && window.MyketBilling) {
const res = await window.MyketBilling.purchase(sku);
return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken };
}
return { kind: "unavailable" };
}
/**
* On app load, capture a Bazaar redirect (`?purchaseToken=...`). Returns the
* pending purchase to verify, or null. Also clears the stashed SKU.
*/
export function captureBazaarRedirect(): { store: StoreId; productId: string; token: string } | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const token = params.get("purchaseToken");
if (!token) return null;
let productId = params.get("sku") ?? params.get("productId") ?? "";
if (!productId) {
try {
productId = localStorage.getItem(PENDING_SKU_KEY) ?? "";
} catch {
/* ignore */
}
}
try {
localStorage.removeItem(PENDING_SKU_KEY);
} catch {
/* ignore */
}
return { store: "bazaar", productId, token };
}