feat: OTP rate limit, private-room invite UX, in-game UI fixes
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 54s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m11s

Auth / security
- Rate-limit real SMS OTP sends (dev mode unlimited): 60s resend cooldown,
  5 per phone/hour, 300/hour global backstop. OtpService.CheckAndRecordRate;
  POST /api/auth/otp/request returns 429 {error,retryAfter}; AuthScreen shows
  auth.rateLimited. Knobs in appsettings Sms (Sms__* env).

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-14 00:30:20 +03:30
parent 78878efc22
commit bc695bc8e9
12 changed files with 257 additions and 58 deletions
+15 -15
View File
@@ -118,12 +118,12 @@ export function GameTable({
{/* Top HUD */}
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between gap-2 safe-top safe-x pb-3 sm:p-4">
<Scoreboard />
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<SpeedBadge />
{trump && <TrumpBadge trump={trump} />}
<button
onClick={toggleAll}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("settings.audio")}
>
{muted ? (
@@ -135,7 +135,7 @@ export function GameTable({
{onForfeit && (
<button
onClick={() => setAskFf(true)}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("forfeit.title")}
>
<Flag className="size-4 text-rose-300/90" />
@@ -143,7 +143,7 @@ export function GameTable({
)}
<button
onClick={exit}
className="glass rounded-full min-h-11 min-w-11 grid place-items-center hover:bg-navy-800 transition"
className="glass rounded-full min-h-10 min-w-10 sm:min-h-11 sm:min-w-11 grid place-items-center hover:bg-navy-800 transition"
title={t("hud.quit")}
>
<LogOut className="size-4 text-cream/80" />
@@ -234,23 +234,23 @@ function Scoreboard() {
const game = useGameStore((s) => s.game);
const { t } = useI18n();
return (
<div className="glass rounded-2xl px-2.5 py-1.5 gap-2.5 sm:px-4 sm:py-2.5 sm:gap-4 flex items-center">
<div className="glass rounded-2xl px-2 py-1 gap-1.5 sm:px-4 sm:py-2.5 sm:gap-4 flex items-center shrink min-w-0">
<ScoreCol
label={t("team.us")}
tricks={game.roundTricks[0]}
score={game.matchScore[0]}
accent="text-teal-400"
/>
<div className="text-cream/30 text-lg font-light">/</div>
<div className="text-cream/30 text-base sm:text-lg font-light shrink-0">/</div>
<ScoreCol
label={t("team.them")}
tricks={game.roundTricks[1]}
score={game.matchScore[1]}
accent="text-rose-400"
/>
<div className="ltr:ml-2 rtl:mr-2 ltr:border-l rtl:border-r border-gold-500/20 ltr:pl-3 rtl:pr-3">
<div className="text-[10px] text-cream/50">{t("home.target")}</div>
<div className="gold-text font-bold text-center">{game.targetScore}</div>
<div className="ltr:ml-1.5 rtl:mr-1.5 sm:ltr:ml-2 sm:rtl:mr-2 ltr:border-l rtl:border-r border-gold-500/20 ltr:pl-2 rtl:pr-2 sm:ltr:pl-3 sm:rtl:pr-3 shrink-0">
<div className="text-[9px] sm:text-[10px] text-cream/50 leading-none">{t("home.target")}</div>
<div className="gold-text font-bold text-center text-sm sm:text-base leading-tight">{game.targetScore}</div>
</div>
</div>
);
@@ -269,10 +269,10 @@ function ScoreCol({
}) {
const { t } = useI18n();
return (
<div className="text-center min-w-10 sm:min-w-14">
<div className={cn("text-[11px] sm:text-xs font-semibold", accent)}>{label}</div>
<div className="text-lg sm:text-2xl font-black leading-none">{score}</div>
<div className="text-[10px] text-cream/45 mt-0.5">
<div className="text-center min-w-8 sm:min-w-14 shrink-0">
<div className={cn("text-[10px] sm:text-xs font-semibold truncate", accent)}>{label}</div>
<div className="text-base sm:text-2xl font-black leading-none">{score}</div>
<div className="text-[9px] sm:text-[10px] text-cream/45 mt-0.5 truncate">
{t("score.tricks")}: {tricks}
</div>
</div>
@@ -439,8 +439,8 @@ function TrickArea({
}) {
const { front } = useCardSkins();
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-1">
<div className="absolute inset-0">
<div className="absolute left-1/2 top-1/2 size-0">
<AnimatePresence>
{trick.map((pc) => {
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };