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
+53 -16
View File
@@ -8,9 +8,17 @@ import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { Friend, RoomSeat, avatarEmoji } from "@/lib/online/types";
import { Friend, PresenceStatus, RoomSeat, avatarEmoji } from "@/lib/online/types";
import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = {
online: "bg-teal-400",
offline: "bg-slate-500",
"in-game": "bg-gold-400",
};
// online first, then in-game, then offline
const statusRank = (s: PresenceStatus) => (s === "online" ? 0 : s === "in-game" ? 1 : 2);
export function RoomScreen() {
const { t } = useI18n();
const room = useOnlineStore((s) => s.room);
@@ -35,6 +43,8 @@ export function RoomScreen() {
if (!room) return null;
const seat = (n: number) => room.seats.find((s) => s.seat === n)!;
const statusLabel = (s: PresenceStatus) =>
s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline");
const pick = async (friend: Friend) => {
if (!picker) return;
@@ -152,21 +162,39 @@ export function RoomScreen() {
onClick={(e) => e.stopPropagation()}
className="panel rounded-3xl p-5 w-full max-w-sm max-h-[70vh] overflow-y-auto"
>
<h3 className="text-lg font-black gold-text mb-3">{t("room.pickFriend")}</h3>
<h3 className="text-lg font-black gold-text mb-1">{t("room.pickFriend")}</h3>
<p className="text-[11px] text-cream/45 mb-3">{t("room.inviteHint")}</p>
<div className="space-y-2">
{friends.map((f) => (
<button
key={f.id}
onClick={() => pick(f)}
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
>
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span className="flex-1 text-sm font-semibold text-cream">{f.displayName}</span>
<span className="text-[11px] text-cream/45">
{t("common.level")} {f.level}
</span>
</button>
))}
{friends.length === 0 && (
<p className="text-center text-cream/40 text-sm py-8">{t("friends.empty")}</p>
)}
{[...friends]
.sort((a, b) => statusRank(a.status) - statusRank(b.status))
.map((f) => {
const inGame = f.status === "in-game";
return (
<button
key={f.id}
onClick={() => pick(f)}
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
>
<span className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-semibold text-cream truncate">{f.displayName}</span>
<span className={cn("block text-[10px]", inGame ? "text-gold-300" : "text-cream/45")}>
{inGame ? `🎮 ${t("friends.inGame")}` : statusLabel(f.status)} · {t("common.level")} {f.level}
</span>
</span>
<span className="text-[10px] font-bold text-teal-300 shrink-0 inline-flex items-center gap-1">
<UserPlus className="size-3.5" />
{t("room.invite")}
</span>
</button>
);
})}
</div>
</motion.div>
</motion.div>
@@ -210,7 +238,16 @@ function SeatCard({
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
</span>
{seat.kind === "invited" ? (
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
<div className="flex flex-col items-center gap-1">
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
<button
onClick={onClear}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold text-rose-300/80 hover:text-rose-200 hover:bg-rose-500/10 transition"
>
<X className="size-3" />
{t("room.cancelInvite")}
</button>
</div>
) : (
role !== "you" && (
<button