76c4b68a74
- 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>
122 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|