feat: shop buy-coins CTA, pin chats (max 3), surrender cooldown, OTP hardening
- Shop: when short on coins the detail sheet now shows "{n} more coins" + a
"Get coins" CTA that opens the buy-coins page (was a dead disabled button).
- Chat: pin/unpin conversations (max 3, persisted to localStorage); pinned float
to the top with a gold pin. i18n chat.pin/unpin/pinLimit.
- Surrender: server now rate-limits forfeit asks at a human teammate
(45s per-user cooldown) so it can't be spammed. (Bot teammate still ends
immediately; teammate confirm dialog already existed.)
- OTP login hardening: Kavenegar send now parses the API status from the body
(HTTP 200 can still be a failure) + logs it + 12s timeout; client auth fetch
gets a 20s AbortController timeout so a lost response surfaces an error
instead of freezing on "sending…".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Hokm.Server.Auth;
|
namespace Hokm.Server.Auth;
|
||||||
|
|
||||||
@@ -147,10 +148,36 @@ public sealed class OtpService
|
|||||||
$"?receptor={Uri.EscapeDataString(phone)}" +
|
$"?receptor={Uri.EscapeDataString(phone)}" +
|
||||||
$"&token={Uri.EscapeDataString(code)}" +
|
$"&token={Uri.EscapeDataString(code)}" +
|
||||||
$"&template={Uri.EscapeDataString(_opts.Template)}";
|
$"&template={Uri.EscapeDataString(_opts.Template)}";
|
||||||
var resp = await Http.GetAsync(url);
|
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
// Bound the call so a hung/slow Kavenegar can't freeze the login request.
|
||||||
if (!resp.IsSuccessStatusCode)
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(12));
|
||||||
throw new InvalidOperationException($"Kavenegar {(int)resp.StatusCode}: {body}");
|
var resp = await Http.GetAsync(url, cts.Token);
|
||||||
|
var body = await resp.Content.ReadAsStringAsync(cts.Token);
|
||||||
|
|
||||||
|
// Kavenegar replies HTTP 200 with {"return":{"status":200,"message":...}}.
|
||||||
|
// status 200 = queued/sent; anything else (411 receptor, 418 credit,
|
||||||
|
// 422 template, 424 template-params…) means it did NOT send.
|
||||||
|
int? apiStatus = null;
|
||||||
|
string? apiMessage = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
if (doc.RootElement.TryGetProperty("return", out var ret))
|
||||||
|
{
|
||||||
|
if (ret.TryGetProperty("status", out var st) && st.ValueKind == JsonValueKind.Number)
|
||||||
|
apiStatus = st.GetInt32();
|
||||||
|
if (ret.TryGetProperty("message", out var msg)) apiMessage = msg.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* non-JSON body */ }
|
||||||
|
|
||||||
|
if (!resp.IsSuccessStatusCode || (apiStatus.HasValue && apiStatus != 200))
|
||||||
|
{
|
||||||
|
_log.LogWarning("Kavenegar send FAILED http={Http} apiStatus={Api} message={Msg} body={Body}",
|
||||||
|
(int)resp.StatusCode, apiStatus, apiMessage, body);
|
||||||
|
throw new InvalidOperationException($"Kavenegar http={(int)resp.StatusCode} status={apiStatus} msg={apiMessage}");
|
||||||
|
}
|
||||||
|
_log.LogInformation("Kavenegar OTP sent to {Phone} (status {Status})", phone, apiStatus ?? 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Normalize to Kavenegar's 09xxxxxxxxx form (handles +98 / 98 prefixes).</summary>
|
/// <summary>Normalize to Kavenegar's 09xxxxxxxxx form (handles +98 / 98 prefixes).</summary>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ public sealed class GameRoom : IDisposable
|
|||||||
private int? _forfeitPendingTeam;
|
private int? _forfeitPendingTeam;
|
||||||
private string? _forfeitRequester;
|
private string? _forfeitRequester;
|
||||||
private Timer? _forfeitTimer;
|
private Timer? _forfeitTimer;
|
||||||
|
// Per-user cooldown so a player can't spam surrender requests at their teammate.
|
||||||
|
private const int ForfeitCooldownSeconds = 45;
|
||||||
|
private readonly Dictionary<string, DateTime> _forfeitNextAllowed = new();
|
||||||
|
|
||||||
public string Id { get; } = Guid.NewGuid().ToString("N")[..8];
|
public string Id { get; } = Guid.NewGuid().ToString("N")[..8];
|
||||||
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
||||||
@@ -198,12 +201,16 @@ public sealed class GameRoom : IDisposable
|
|||||||
if (seat is null) return;
|
if (seat is null) return;
|
||||||
int team = seat.Value % 2;
|
int team = seat.Value % 2;
|
||||||
var mate = Seats.FirstOrDefault(s => s.Seat % 2 == team && s.Seat != seat.Value);
|
var mate = Seats.FirstOrDefault(s => s.Seat % 2 == team && s.Seat != seat.Value);
|
||||||
// No human teammate to ask → forfeit immediately.
|
// No human teammate to ask → forfeit immediately (no cooldown needed).
|
||||||
if (mate is null || mate.IsBot || mate.UserId is null || !mate.Connected)
|
if (mate is null || mate.IsBot || mate.UserId is null || !mate.Connected)
|
||||||
{
|
{
|
||||||
FinalizeForfeit(team);
|
FinalizeForfeit(team);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Rate-limit repeated asks at a human teammate (anti-nag).
|
||||||
|
if (_forfeitNextAllowed.TryGetValue(userId, out var until) && DateTime.UtcNow < until)
|
||||||
|
return;
|
||||||
|
_forfeitNextAllowed[userId] = DateTime.UtcNow.AddSeconds(ForfeitCooldownSeconds);
|
||||||
_forfeitPendingTeam = team;
|
_forfeitPendingTeam = team;
|
||||||
_forfeitRequester = userId;
|
_forfeitRequester = userId;
|
||||||
var requester = Seats.First(s => s.UserId == userId);
|
var requester = Seats.First(s => s.UserId == userId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
Pin,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
@@ -336,16 +337,49 @@ function DiscoverRow({ player }: { player: PlayerSummary }) {
|
|||||||
|
|
||||||
/* ------------------------------ Messages tab ----------------------------- */
|
/* ------------------------------ Messages tab ----------------------------- */
|
||||||
|
|
||||||
|
const PINNED_KEY = "hokm.pinnedChats";
|
||||||
|
const MAX_PINNED = 3;
|
||||||
|
|
||||||
function MessagesTab() {
|
function MessagesTab() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const openChat = useOnlineStore((s) => s.openChat);
|
const openChat = useOnlineStore((s) => s.openChat);
|
||||||
const go = useUIStore((s) => s.go);
|
const go = useUIStore((s) => s.go);
|
||||||
const [convs, setConvs] = useState<Conversation[] | null>(null);
|
const [convs, setConvs] = useState<Conversation[] | null>(null);
|
||||||
|
const [pinned, setPinned] = useState<string[]>([]);
|
||||||
|
const [pinMsg, setPinMsg] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getService().listConversations().then(setConvs).catch(() => setConvs([]));
|
getService().listConversations().then(setConvs).catch(() => setConvs([]));
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PINNED_KEY);
|
||||||
|
if (raw) setPinned(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const savePinned = (next: string[]) => {
|
||||||
|
setPinned(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PINNED_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePin = (id: string) => {
|
||||||
|
if (pinned.includes(id)) {
|
||||||
|
savePinned(pinned.filter((x) => x !== id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pinned.length >= MAX_PINNED) {
|
||||||
|
setPinMsg(t("chat.pinLimit").replace("{n}", String(MAX_PINNED)));
|
||||||
|
setTimeout(() => setPinMsg(""), 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savePinned([...pinned, id]);
|
||||||
|
};
|
||||||
|
|
||||||
const open = async (c: Conversation) => {
|
const open = async (c: Conversation) => {
|
||||||
await openChat(c.friend);
|
await openChat(c.friend);
|
||||||
go("chat");
|
go("chat");
|
||||||
@@ -362,15 +396,27 @@ function MessagesTab() {
|
|||||||
|
|
||||||
if (convs == null) return <div className="grid place-items-center py-10"><Loader2 className="size-6 text-gold-400 animate-spin" /></div>;
|
if (convs == null) return <div className="grid place-items-center py-10"><Loader2 className="size-6 text-gold-400 animate-spin" /></div>;
|
||||||
|
|
||||||
|
// Pinned conversations float to the top (stable order otherwise).
|
||||||
|
const sorted = [...convs].sort(
|
||||||
|
(a, b) => (pinned.includes(a.friend.id) ? 0 : 1) - (pinned.includes(b.friend.id) ? 0 : 1)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 lg:grid-cols-2 pb-6">
|
<div className="grid gap-2 lg:grid-cols-2 pb-6">
|
||||||
|
{pinMsg && <div className="lg:col-span-2 text-center text-xs text-gold-300">{pinMsg}</div>}
|
||||||
{convs.length === 0 && (
|
{convs.length === 0 && (
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<EmptyState icon={<MessageCircle className="size-7 text-gold-400/70" />} text={t("messages.empty")} />
|
<EmptyState icon={<MessageCircle className="size-7 text-gold-400/70" />} text={t("messages.empty")} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{convs.map((c) => (
|
{sorted.map((c) => {
|
||||||
<button key={c.friend.id} onClick={() => open(c)} className="w-full glass rounded-xl p-2.5 flex items-center gap-3 text-start active:scale-[0.99] transition">
|
const pin = pinned.includes(c.friend.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.friend.id}
|
||||||
|
className={cn("w-full glass rounded-xl p-2.5 flex items-center gap-2", pin && "ring-1 ring-gold-500/40")}
|
||||||
|
>
|
||||||
|
<button onClick={() => open(c)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<span className="text-2xl">{avatarEmoji(c.friend.avatar)}</span>
|
<span className="text-2xl">{avatarEmoji(c.friend.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[c.friend.status])} />
|
<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[c.friend.status])} />
|
||||||
@@ -392,7 +438,20 @@ function MessagesTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
|
onClick={() => togglePin(c.friend.id)}
|
||||||
|
title={pin ? t("chat.unpin") : t("chat.pin")}
|
||||||
|
aria-label={pin ? t("chat.unpin") : t("chat.pin")}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 grid size-8 place-items-center rounded-lg transition",
|
||||||
|
pin ? "text-gold-400" : "text-cream/35 hover:text-gold-300 hover:bg-navy-800/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Pin className={cn("size-4", pin && "fill-gold-500/30")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Sticker } from "@/components/online/Sticker";
|
|||||||
import { Avatar } from "@/components/online/Avatar";
|
import { Avatar } from "@/components/online/Avatar";
|
||||||
import { CoinsPill } from "@/components/online/CoinsPill";
|
import { CoinsPill } from "@/components/online/CoinsPill";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { getService } from "@/lib/online/service";
|
import { getService } from "@/lib/online/service";
|
||||||
import { sound } from "@/lib/sound";
|
import { sound } from "@/lib/sound";
|
||||||
@@ -330,10 +331,12 @@ function DetailSheet({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const go = useUIStore((s) => s.go);
|
||||||
const name = locale === "fa" ? item.nameFa : item.nameEn;
|
const name = locale === "fa" ? item.nameFa : item.nameEn;
|
||||||
const desc = locale === "fa" ? item.descFa : item.descEn;
|
const desc = locale === "fa" ? item.descFa : item.descEn;
|
||||||
const locked = !owned && !!reqLabel;
|
const locked = !owned && !!reqLabel;
|
||||||
const canAfford = coins >= item.price;
|
const canAfford = coins >= item.price;
|
||||||
|
const needCoins = !owned && !locked && !canAfford;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -390,19 +393,30 @@ function DetailSheet({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* buy */}
|
{/* buy — when short on coins, offer a CTA to the buy-coins page */}
|
||||||
|
{needCoins ? (
|
||||||
|
<>
|
||||||
|
<p className="text-rose-300 text-xs mt-6 mb-2">
|
||||||
|
{t("shop.needMore").replace("{n}", (item.price - coins).toLocaleString())}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); go("buycoins"); }}
|
||||||
|
className="press-3d btn-gold w-full rounded-2xl py-3.5 font-black flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Coins className="size-4" /> {t("shop.getCoins")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={onBuy}
|
onClick={onBuy}
|
||||||
disabled={owned || locked || !canAfford}
|
disabled={owned || locked}
|
||||||
className={cn(
|
className={cn(
|
||||||
"press-3d w-full rounded-2xl py-3.5 mt-6 font-black flex items-center justify-center gap-2",
|
"press-3d w-full rounded-2xl py-3.5 mt-6 font-black flex items-center justify-center gap-2",
|
||||||
owned
|
owned
|
||||||
? "bg-navy-900/60 text-teal-300"
|
? "bg-navy-900/60 text-teal-300"
|
||||||
: locked
|
: locked
|
||||||
? "bg-navy-900/60 text-cream/60"
|
? "bg-navy-900/60 text-cream/60"
|
||||||
: canAfford
|
: "btn-gold"
|
||||||
? "btn-gold"
|
|
||||||
: "bg-navy-900/60 text-rose-300"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{owned ? (
|
{owned ? (
|
||||||
@@ -413,14 +427,13 @@ function DetailSheet({
|
|||||||
<>
|
<>
|
||||||
<Lock className="size-4" /> {reqLabel}
|
<Lock className="size-4" /> {reqLabel}
|
||||||
</>
|
</>
|
||||||
) : canAfford ? (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
|
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
t("lobby.needCoins")
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ const fa: Dict = {
|
|||||||
"chat.placeholder": "پیام بنویسید…",
|
"chat.placeholder": "پیام بنویسید…",
|
||||||
"chat.send": "ارسال",
|
"chat.send": "ارسال",
|
||||||
"chat.emoji": "ایموجی",
|
"chat.emoji": "ایموجی",
|
||||||
|
"chat.pin": "سنجاق",
|
||||||
|
"chat.unpin": "برداشتن سنجاق",
|
||||||
|
"chat.pinLimit": "حداکثر {n} گفتگو را میتوانید سنجاق کنید",
|
||||||
"chat.typing": "در حال نوشتن…",
|
"chat.typing": "در حال نوشتن…",
|
||||||
"city.rewardTitle": "شهرت را انتخاب کن!",
|
"city.rewardTitle": "شهرت را انتخاب کن!",
|
||||||
"city.rewardSub": "{n} سکه هدیه بگیر",
|
"city.rewardSub": "{n} سکه هدیه بگیر",
|
||||||
@@ -260,6 +263,8 @@ const fa: Dict = {
|
|||||||
"shop.title": "فروشگاه",
|
"shop.title": "فروشگاه",
|
||||||
"shop.buy": "خرید",
|
"shop.buy": "خرید",
|
||||||
"shop.owned": "موجود",
|
"shop.owned": "موجود",
|
||||||
|
"shop.getCoins": "شارژ سکه",
|
||||||
|
"shop.needMore": "{n} سکه کم دارید",
|
||||||
"shop.luxury": "ویژه",
|
"shop.luxury": "ویژه",
|
||||||
"shop.avatars": "آواتارها",
|
"shop.avatars": "آواتارها",
|
||||||
"shop.themes": "تمها",
|
"shop.themes": "تمها",
|
||||||
@@ -528,6 +533,9 @@ const en: Dict = {
|
|||||||
"chat.placeholder": "Type a message…",
|
"chat.placeholder": "Type a message…",
|
||||||
"chat.send": "Send",
|
"chat.send": "Send",
|
||||||
"chat.emoji": "Emoji",
|
"chat.emoji": "Emoji",
|
||||||
|
"chat.pin": "Pin",
|
||||||
|
"chat.unpin": "Unpin",
|
||||||
|
"chat.pinLimit": "You can pin up to {n} chats",
|
||||||
"chat.typing": "typing…",
|
"chat.typing": "typing…",
|
||||||
"city.rewardTitle": "Set your city!",
|
"city.rewardTitle": "Set your city!",
|
||||||
"city.rewardSub": "Earn {n} coins",
|
"city.rewardSub": "Earn {n} coins",
|
||||||
@@ -633,6 +641,8 @@ const en: Dict = {
|
|||||||
"shop.title": "Shop",
|
"shop.title": "Shop",
|
||||||
"shop.buy": "Buy",
|
"shop.buy": "Buy",
|
||||||
"shop.owned": "Owned",
|
"shop.owned": "Owned",
|
||||||
|
"shop.getCoins": "Get coins",
|
||||||
|
"shop.needMore": "You need {n} more coins",
|
||||||
"shop.luxury": "Luxury",
|
"shop.luxury": "Luxury",
|
||||||
"shop.avatars": "Avatars",
|
"shop.avatars": "Avatars",
|
||||||
"shop.themes": "Themes",
|
"shop.themes": "Themes",
|
||||||
|
|||||||
@@ -80,11 +80,21 @@ export class SignalrService implements OnlineService {
|
|||||||
/* ------------------------------ helpers ---------------------------- */
|
/* ------------------------------ helpers ---------------------------- */
|
||||||
|
|
||||||
private async api<T>(path: string, body: unknown): Promise<T> {
|
private async api<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await fetch(`${SERVER}${path}`, {
|
// Bound the request so a hung/lost response (CDN, network) surfaces an error
|
||||||
|
// instead of freezing the UI (e.g. the OTP "sending…" button forever).
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 20000);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${SERVER}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user