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:
@@ -6,6 +6,7 @@ import {
|
||||
Clock,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Pin,
|
||||
Search,
|
||||
Sparkles,
|
||||
UserMinus,
|
||||
@@ -336,16 +337,49 @@ function DiscoverRow({ player }: { player: PlayerSummary }) {
|
||||
|
||||
/* ------------------------------ Messages tab ----------------------------- */
|
||||
|
||||
const PINNED_KEY = "hokm.pinnedChats";
|
||||
const MAX_PINNED = 3;
|
||||
|
||||
function MessagesTab() {
|
||||
const { t } = useI18n();
|
||||
const openChat = useOnlineStore((s) => s.openChat);
|
||||
const go = useUIStore((s) => s.go);
|
||||
const [convs, setConvs] = useState<Conversation[] | null>(null);
|
||||
const [pinned, setPinned] = useState<string[]>([]);
|
||||
const [pinMsg, setPinMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
await openChat(c.friend);
|
||||
go("chat");
|
||||
@@ -362,37 +396,62 @@ function MessagesTab() {
|
||||
|
||||
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 (
|
||||
<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 && (
|
||||
<div className="lg:col-span-2">
|
||||
<EmptyState icon={<MessageCircle className="size-7 text-gold-400/70" />} text={t("messages.empty")} />
|
||||
</div>
|
||||
)}
|
||||
{convs.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">
|
||||
<div className="relative shrink-0">
|
||||
<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])} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-cream truncate">{c.friend.displayName}</span>
|
||||
{c.lastMessage && <span className="text-[10px] text-cream/40 shrink-0">{timeAgo(c.lastMessage.ts)}</span>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-cream/50 truncate">
|
||||
{c.lastMessage ? (c.lastMessage.fromMe ? `${t("messages.you")}: ` : "") + c.lastMessage.text : t("chat.empty")}
|
||||
</span>
|
||||
{c.unread > 0 && (
|
||||
<span className="min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white grid place-items-center shrink-0">
|
||||
{c.unread > 9 ? "9+" : c.unread}
|
||||
</span>
|
||||
{sorted.map((c) => {
|
||||
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">
|
||||
<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])} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-cream truncate">{c.friend.displayName}</span>
|
||||
{c.lastMessage && <span className="text-[10px] text-cream/40 shrink-0">{timeAgo(c.lastMessage.ts)}</span>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-cream/50 truncate">
|
||||
{c.lastMessage ? (c.lastMessage.fromMe ? `${t("messages.you")}: ` : "") + c.lastMessage.text : t("chat.empty")}
|
||||
</span>
|
||||
{c.unread > 0 && (
|
||||
<span className="min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white grid place-items-center shrink-0">
|
||||
{c.unread > 9 ? "9+" : c.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<Pin className={cn("size-4", pin && "fill-gold-500/30")} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Sticker } from "@/components/online/Sticker";
|
||||
import { Avatar } from "@/components/online/Avatar";
|
||||
import { CoinsPill } from "@/components/online/CoinsPill";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { sound } from "@/lib/sound";
|
||||
@@ -330,10 +331,12 @@ function DetailSheet({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const go = useUIStore((s) => s.go);
|
||||
const name = locale === "fa" ? item.nameFa : item.nameEn;
|
||||
const desc = locale === "fa" ? item.descFa : item.descEn;
|
||||
const locked = !owned && !!reqLabel;
|
||||
const canAfford = coins >= item.price;
|
||||
const needCoins = !owned && !locked && !canAfford;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -390,37 +393,47 @@ function DetailSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* buy */}
|
||||
<button
|
||||
onClick={onBuy}
|
||||
disabled={owned || locked || !canAfford}
|
||||
className={cn(
|
||||
"press-3d w-full rounded-2xl py-3.5 mt-6 font-black flex items-center justify-center gap-2",
|
||||
owned
|
||||
? "bg-navy-900/60 text-teal-300"
|
||||
: locked
|
||||
? "bg-navy-900/60 text-cream/60"
|
||||
: canAfford
|
||||
? "btn-gold"
|
||||
: "bg-navy-900/60 text-rose-300"
|
||||
)}
|
||||
>
|
||||
{owned ? (
|
||||
<>
|
||||
<Check className="size-4" /> {t("shop.owned")}
|
||||
</>
|
||||
) : locked ? (
|
||||
<>
|
||||
<Lock className="size-4" /> {reqLabel}
|
||||
</>
|
||||
) : canAfford ? (
|
||||
<>
|
||||
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
|
||||
</>
|
||||
) : (
|
||||
t("lobby.needCoins")
|
||||
)}
|
||||
</button>
|
||||
{/* 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
|
||||
onClick={onBuy}
|
||||
disabled={owned || locked}
|
||||
className={cn(
|
||||
"press-3d w-full rounded-2xl py-3.5 mt-6 font-black flex items-center justify-center gap-2",
|
||||
owned
|
||||
? "bg-navy-900/60 text-teal-300"
|
||||
: locked
|
||||
? "bg-navy-900/60 text-cream/60"
|
||||
: "btn-gold"
|
||||
)}
|
||||
>
|
||||
{owned ? (
|
||||
<>
|
||||
<Check className="size-4" /> {t("shop.owned")}
|
||||
</>
|
||||
) : locked ? (
|
||||
<>
|
||||
<Lock className="size-4" /> {reqLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -152,6 +152,9 @@ const fa: Dict = {
|
||||
"chat.placeholder": "پیام بنویسید…",
|
||||
"chat.send": "ارسال",
|
||||
"chat.emoji": "ایموجی",
|
||||
"chat.pin": "سنجاق",
|
||||
"chat.unpin": "برداشتن سنجاق",
|
||||
"chat.pinLimit": "حداکثر {n} گفتگو را میتوانید سنجاق کنید",
|
||||
"chat.typing": "در حال نوشتن…",
|
||||
"city.rewardTitle": "شهرت را انتخاب کن!",
|
||||
"city.rewardSub": "{n} سکه هدیه بگیر",
|
||||
@@ -260,6 +263,8 @@ const fa: Dict = {
|
||||
"shop.title": "فروشگاه",
|
||||
"shop.buy": "خرید",
|
||||
"shop.owned": "موجود",
|
||||
"shop.getCoins": "شارژ سکه",
|
||||
"shop.needMore": "{n} سکه کم دارید",
|
||||
"shop.luxury": "ویژه",
|
||||
"shop.avatars": "آواتارها",
|
||||
"shop.themes": "تمها",
|
||||
@@ -528,6 +533,9 @@ const en: Dict = {
|
||||
"chat.placeholder": "Type a message…",
|
||||
"chat.send": "Send",
|
||||
"chat.emoji": "Emoji",
|
||||
"chat.pin": "Pin",
|
||||
"chat.unpin": "Unpin",
|
||||
"chat.pinLimit": "You can pin up to {n} chats",
|
||||
"chat.typing": "typing…",
|
||||
"city.rewardTitle": "Set your city!",
|
||||
"city.rewardSub": "Earn {n} coins",
|
||||
@@ -633,6 +641,8 @@ const en: Dict = {
|
||||
"shop.title": "Shop",
|
||||
"shop.buy": "Buy",
|
||||
"shop.owned": "Owned",
|
||||
"shop.getCoins": "Get coins",
|
||||
"shop.needMore": "You need {n} more coins",
|
||||
"shop.luxury": "Luxury",
|
||||
"shop.avatars": "Avatars",
|
||||
"shop.themes": "Themes",
|
||||
|
||||
@@ -80,11 +80,21 @@ export class SignalrService implements OnlineService {
|
||||
/* ------------------------------ helpers ---------------------------- */
|
||||
|
||||
private async api<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${SERVER}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// 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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user