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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user