Files
HokmPlay/src/components/screens/ShopScreen.tsx
T
soroush.asadi 974a6bf0ae
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 29s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m6s
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>
2026-06-15 11:01:14 +03:30

441 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Check, Coins, Lock, Sparkles, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
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";
import { achievementById, cardBackById } from "@/lib/online/gamification";
import { backVisualFromDef, cardBackMotif } from "@/lib/cardBack";
import { celebrate } from "@/lib/celebration-store";
import { AchievementUnlock, ShopItem } from "@/lib/online/types";
import { cn } from "@/lib/cn";
/** The product artwork, used on the card and (bigger) in the detail sheet. */
function Preview({ item, size }: { item: ShopItem; size: number }) {
switch (item.kind) {
case "stickerpack":
return <Sticker id={item.preview} size={size} />;
case "cardfront":
return (
<span
className="rounded-md border flex items-center justify-center text-slate-900 font-black"
style={{
width: size * 0.72,
height: size,
fontSize: size * 0.4,
background: `linear-gradient(160deg, #ffffff, ${item.preview})`,
borderColor: "rgba(0,0,0,0.18)",
}}
>
</span>
);
case "cardback": {
const back = cardBackById(item.id);
return (
<span
className="rounded-md border grid place-items-center"
style={{
width: size * 0.72,
height: size,
borderColor: `${back.accent}80`,
...backVisualFromDef(back),
}}
>
<span style={{ fontSize: size * 0.3, color: `${back.accent}dd`, textShadow: "0 1px 2px rgba(0,0,0,0.4)" }}>
{cardBackMotif(back.pattern, back.motif)}
</span>
</span>
);
}
case "title":
return (
<span
className="rounded-full btn-gold px-2.5 py-1 font-black leading-none whitespace-nowrap"
style={{ fontSize: Math.max(9, size * 0.22) }}
>
🏷
</span>
);
case "avatar":
return <Avatar id={item.id} size={size} />;
default: // reactionpack, xp → emoji glyph
return <span style={{ fontSize: size * 0.82, lineHeight: 1 }}>{item.kind === "xp" ? "⚡" : item.preview}</span>;
}
}
export function ShopScreen() {
const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile);
const setProfile = useSessionStore((s) => s.setProfile);
const [items, setItems] = useState<ShopItem[]>([]);
const [msg, setMsg] = useState("");
const [detail, setDetail] = useState<ShopItem | null>(null);
const [cat, setCat] = useState<ShopItem["kind"]>("avatar");
useEffect(() => {
getService().getShopItems().then(setItems);
}, []);
if (!profile) return null;
const owns = (item: ShopItem) => {
switch (item.kind) {
case "avatar": return profile.ownedAvatars.includes(item.id);
case "cardfront": return profile.ownedCardFronts.includes(item.id);
case "cardback": return profile.ownedCardBacks.includes(item.id);
case "reactionpack": return profile.ownedReactionPacks.includes(item.id);
case "title": return profile.ownedTitles.includes(item.id);
case "xp": return false; // consumable — always buyable
default: return profile.ownedStickerPacks.includes(item.id);
}
};
// Requirement gate: returns a label while LOCKED, else null.
const lockLabel = (item: ShopItem): string | null => {
if (item.reqLevel && profile.level < item.reqLevel) return `${t("shop.reqLevel")} ${item.reqLevel}`;
if (item.reqRating && profile.rating < item.reqRating) return `${t("shop.reqRating")} ${item.reqRating}`;
if (item.reqAchievement && !(profile.unlocked ?? []).includes(item.reqAchievement)) {
const ach = achievementById(item.reqAchievement);
const name = ach ? (locale === "fa" ? ach.nameFa : ach.nameEn) : item.reqAchievement;
return `${t("shop.reqAchv")} ${name}`;
}
return null;
};
const buy = async (item: ShopItem) => {
const before = profile;
const res = await getService().buyItem(item.id);
if (res.ok && res.profile) {
const after = res.profile;
setProfile(after);
sound.play("purchase");
setDetail(null);
// newly-unlocked achievements (e.g. an XP pack crossing a level milestone)
const newAch: AchievementUnlock[] = after.unlocked
.filter((id) => !before.unlocked.includes(id))
.map((id) => achievementById(id))
.filter((d): d is NonNullable<typeof d> => !!d)
.map((d) => ({ id: d.id, nameFa: d.nameFa, nameEn: d.nameEn, icon: d.icon, coinReward: d.coinReward }));
if (item.kind === "xp") {
celebrate({
variant: "xp",
icon: "⚡",
title: locale === "fa" ? "امتیاز تجربه" : "Experience",
xpGained: item.xp ?? 0,
levelBefore: before.level,
levelAfter: after.level,
achievements: newAch.length ? newAch : undefined,
});
} else {
celebrate({
variant: "purchase",
// avatar/reaction previews are emojis; others fall back to the default glyph
icon: item.kind === "avatar" || item.kind === "reactionpack" ? item.preview : undefined,
title: locale === "fa" ? item.nameFa : item.nameEn,
achievements: newAch.length ? newAch : undefined,
});
}
} else {
setMsg(locale === "fa" ? res.messageFa : res.messageEn);
setTimeout(() => setMsg(""), 1800);
}
};
const sections: { title: string; kind: ShopItem["kind"]; hint?: string }[] = [
{ title: t("shop.avatars"), kind: "avatar" },
{ title: t("shop.cardfronts"), kind: "cardfront" },
{ title: t("shop.cardbacks"), kind: "cardback" },
{ title: t("shop.reactions"), kind: "reactionpack" },
{ title: t("shop.stickers"), kind: "stickerpack" },
{ title: t("shop.titles"), kind: "title", hint: t("shop.titlesHint") },
{ title: t("shop.xp"), kind: "xp", hint: t("shop.xpHint") },
];
return (
<ScreenShell>
<ScreenHeader title={t("shop.title")} right={<CoinsPill />} />
{msg && (
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
)}
{items.length === 0 && (
<div className="space-y-5">
{Array.from({ length: 2 }).map((_, s) => (
<div key={s}>
<div className="h-4 w-24 rounded bg-navy-800/80 animate-pulse mb-3" />
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="glass rounded-2xl p-3 flex flex-col items-center gap-2 animate-pulse">
<div className="size-12 rounded-xl bg-navy-800/80" />
<div className="h-2.5 w-3/4 rounded bg-navy-800/80" />
<div className="h-6 w-full rounded-lg bg-navy-800/60" />
</div>
))}
</div>
</div>
))}
</div>
)}
{/* category tabs */}
<div className="flex gap-2 overflow-x-auto pb-2 -mx-1 px-1 mb-3">
{sections.map((sec) => (
<button
key={sec.kind}
onClick={() => setCat(sec.kind)}
className={cn(
"shrink-0 rounded-full px-4 py-2 text-sm font-bold transition whitespace-nowrap",
cat === sec.kind ? "btn-gold" : "panel text-cream/70 hover:text-cream"
)}
>
{sec.title}
</button>
))}
</div>
{sections
.filter((sec) => sec.kind === cat)
.map((sec) => {
const list = items.filter((i) => i.kind === sec.kind);
return (
<Section key={sec.kind} title={sec.title} hint={sec.hint}>
{list.length === 0 ? (
<p className="text-center text-cream/40 text-sm py-8">{t("shop.emptyCat")}</p>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-3">
{list.map((item) => (
<ItemCard key={item.id} item={item} owned={owns(item)} reqLabel={lockLabel(item)} onOpen={() => setDetail(item)} />
))}
</div>
)}
</Section>
);
})}
<AnimatePresence>
{detail && (
<DetailSheet
item={detail}
owned={owns(detail)}
coins={profile.coins}
reqLabel={lockLabel(detail)}
onBuy={() => buy(detail)}
onClose={() => setDetail(null)}
/>
)}
</AnimatePresence>
</ScreenShell>
);
}
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<div className="mb-5">
<h3 className="text-sm font-bold text-cream/80 mb-1">{title}</h3>
{hint && <p className="text-[11px] text-cream/45 mb-2.5">{hint}</p>}
{!hint && <div className="mb-1" />}
{children}
</div>
);
}
function ItemCard({ item, owned, reqLabel, onOpen }: { item: ShopItem; owned: boolean; reqLabel: string | null; onOpen: () => void }) {
const { locale, t } = useI18n();
const count = item.contents?.length;
const luxury = item.price >= 2000; // premium "luxury" tier
const locked = !owned && !!reqLabel;
return (
<motion.button
whileTap={{ scale: 0.96 }}
onClick={onOpen}
className={cn(
"press-3d glass rounded-2xl p-3 flex flex-col items-center gap-2 relative",
luxury && !owned && "ring-1 ring-gold-400/50"
)}
>
{luxury && !owned && (
<span className="absolute top-1.5 ltr:left-1.5 rtl:right-1.5 inline-flex items-center gap-0.5 rounded-full bg-gradient-to-r from-gold-500 to-gold-300 text-[#2a1f04] text-[8px] font-black px-1.5 py-0.5 shadow">
{t("shop.luxury")}
</span>
)}
{owned && (
<span className="absolute top-1.5 ltr:right-1.5 rtl:left-1.5 grid size-5 place-items-center rounded-full bg-teal-500 text-navy-950">
<Check className="size-3.5" strokeWidth={3} />
</span>
)}
<div className={cn("h-12 flex items-center justify-center", locked && "opacity-40 grayscale")}>
<Preview item={item} size={44} />
</div>
<span className="text-[11px] font-semibold text-cream/90 truncate max-w-full">
{locale === "fa" ? item.nameFa : item.nameEn}
</span>
{/* quick detail hint */}
<span className="text-[9px] text-cream/45 leading-none">
{item.kind === "xp"
? `+${item.xp} XP`
: count != null
? locale === "fa"
? `${count} مورد`
: `${count} items`
: " "}
</span>
<span
className={cn(
"w-full rounded-lg py-1.5 text-xs font-bold flex items-center justify-center gap-1",
owned ? "bg-navy-900/60 text-teal-300" : locked ? "bg-navy-900/70 text-cream/55" : "btn-gold"
)}
>
{owned ? (
<Check className="size-3.5" />
) : locked ? (
<>
<Lock className="size-3" />
<span className="text-[10px] truncate">{reqLabel}</span>
</>
) : (
<>
<Coins className="size-3.5" />
{item.price.toLocaleString()}
</>
)}
</span>
</motion.button>
);
}
function DetailSheet({
item,
owned,
coins,
reqLabel,
onBuy,
onClose,
}: {
item: ShopItem;
owned: boolean;
coins: number;
reqLabel: string | null;
onBuy: () => void;
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
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-[70] flex items-end sm:items-center justify-center bg-navy-950/80 backdrop-blur-sm p-4"
>
<motion.div
initial={{ y: 40, scale: 0.96, opacity: 0 }}
animate={{ y: 0, scale: 1, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
transition={{ type: "spring", stiffness: 240, damping: 24 }}
onClick={(e) => e.stopPropagation()}
className="glass rounded-3xl p-6 w-full max-w-sm text-center relative max-h-[88vh] overflow-y-auto"
>
<button onClick={onClose} className="absolute top-3 ltr:right-3 rtl:left-3 grid size-9 place-items-center rounded-full hover:bg-navy-800/80 transition">
<X className="size-4 text-cream/60" />
</button>
<div className="mx-auto size-24 rounded-2xl bg-navy-900/70 gold-border grid place-items-center">
<Preview item={item} size={item.kind === "xp" || item.kind === "avatar" || item.kind === "reactionpack" ? 64 : 72} />
</div>
<h2 className="gold-text text-xl font-black mt-3">{name}</h2>
{desc && <p className="text-cream/65 text-sm mt-1">{desc}</p>}
{/* what's included */}
{item.contents && item.contents.length > 0 && (
<div className="mt-4 text-start">
<div className="text-[11px] font-bold text-cream/55 mb-2">
{t("shop.includes")} ({item.contents.length})
</div>
<div className="flex flex-wrap gap-2 justify-center">
{item.kind === "stickerpack"
? item.contents.map((s) => (
<div key={s} className="rounded-xl bg-navy-900/60 p-1.5">
<Sticker id={s} size={40} />
</div>
))
: item.contents.map((e, i) => (
<span key={i} className="rounded-xl bg-navy-900/60 size-11 grid place-items-center text-2xl">
{e}
</span>
))}
</div>
</div>
)}
{item.kind === "xp" && (
<div className="mt-4 inline-flex items-center gap-2 btn-gold rounded-full px-5 py-2 font-black">
<Sparkles className="size-4" />
+{item.xp?.toLocaleString()} XP
</div>
)}
{/* 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>
);
}