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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:40:01 +03:30

122 lines
4.8 KiB
TypeScript

"use client";
import { motion } from "framer-motion";
import { ChevronLeft, Coins, Lock, Trophy } from "lucide-react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { CoinsPill } from "@/components/online/CoinsPill";
import { MATCH_LEAGUES } from "@/lib/online/gamification";
import { useOnlineStore } from "@/lib/online-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useGameStore, hasActiveMatch } from "@/lib/game-store";
import { pushNotification } from "@/lib/notification-store";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/cn";
/** Block starting a 2nd game while one is running — resume it instead. */
function guardActiveMatch(): boolean {
if (!hasActiveMatch()) return false;
useGameStore.getState().resume();
useUIStore.getState().goGame("online");
pushNotification({
kind: "system",
titleFa: "بازی در جریان",
titleEn: "Game in progress",
bodyFa: "ابتدا بازی فعلی را تمام کنید یا تسلیم شوید.",
bodyEn: "Finish or forfeit your current game first.",
icon: "🎮",
});
return true;
}
export function OnlineLobbyScreen() {
const { t, locale } = useI18n();
const startMatchmaking = useOnlineStore((s) => s.startMatchmaking);
const go = useUIStore((s) => s.go);
const profile = useSessionStore((s) => s.profile);
const coins = profile?.coins ?? 0;
const level = profile?.level ?? 1;
// The cheapest league you can enter is highlighted as the default pick.
const featuredId = MATCH_LEAGUES.find((l) => level >= l.minLevel)?.id;
// Each league is its own play button — tap to queue a ranked match at its stake.
const playLeague = async (l: (typeof MATCH_LEAGUES)[number]) => {
if (guardActiveMatch()) return;
if (level < l.minLevel) return;
if (coins < l.entry) {
go("buycoins");
return;
}
// Navigate FIRST so the button never hangs on the SignalR handshake; the
// matchmaking screen shows "searching" while the connection establishes.
go("matchmaking");
void startMatchmaking({ ranked: true, stake: l.entry });
};
return (
<ScreenShell>
<ScreenHeader title={t("lobby.title")} right={<CoinsPill />} />
<div className="flex items-center gap-1.5 text-sm text-cream/70 mb-3">
<Trophy className="size-4 text-gold-400" />
{t("lobby.chooseLeague")}
</div>
<div className="grid gap-3">
{MATCH_LEAGUES.map((l) => {
const locked = level < l.minLevel;
const gold = !locked && l.id === featuredId;
const poor = !locked && coins < l.entry;
return (
<motion.button
key={l.id}
whileTap={locked ? undefined : { scale: 0.985 }}
disabled={locked}
onClick={() => playLeague(l)}
className={cn(
"w-full rounded-3xl p-4 flex items-center gap-3 text-start transition",
gold ? "btn-gold" : "press-3d panel hover:border-gold-500/40",
locked && "opacity-50 cursor-not-allowed"
)}
>
<span
className="grid size-12 place-items-center rounded-2xl text-2xl shrink-0"
style={{ background: gold ? "rgba(0,0,0,.12)" : l.color + "22" }}
>
{l.icon}
</span>
<span className="flex-1 min-w-0">
<span className={cn("block text-base font-black", gold ? "text-[#2a1f04]" : "text-cream")}>
{locale === "fa" ? l.nameFa : l.nameEn}
</span>
<span className={cn("block text-[11px]", gold ? "text-[#2a1f04]/70" : "text-cream/55")}>
{locale === "fa" ? l.descFa : l.descEn}
</span>
{poor && (
<span className="block text-[10px] text-rose-300 mt-0.5">{t("lobby.needCoins")}</span>
)}
</span>
{locked ? (
<span className="text-[11px] text-rose-300 flex items-center gap-1 shrink-0">
<Lock className="size-3.5" />
{t("lobby.lvl")} {l.minLevel}
</span>
) : (
<span className="flex items-center gap-2.5 shrink-0">
<span className={cn("flex items-center gap-1 font-black text-sm", gold ? "text-[#2a1f04]" : "text-gold-300")}>
{l.entry.toLocaleString()}
<Coins className="size-3.5" />
</span>
<ChevronLeft className={cn("size-5 ltr:rotate-180", gold ? "text-[#2a1f04]" : "text-gold-400")} />
</span>
)}
</motion.button>
);
})}
</div>
</ScreenShell>
);
}