Files
HokmPlay/src/components/screens/ShopScreen.tsx
T
soroush.asadi be8c758425
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 26s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m1s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 0s
Shop redesign: tactile cards + product detail sheet
Answers "what's in this pack / how much XP": shop items now carry contents
(sticker ids / emojis), xp amount, and a fa/en description. Cards are tactile
(press-3d), show the artwork + name + a quick hint (item count or +XP) + price,
with an owned check badge. Tapping a card opens a detail sheet that shows the
full contents (every sticker/emoji rendered), the XP granted, a description,
and a Buy button (gold when affordable, "need coins" otherwise).

Verified: tsc + next build clean; web rebuilt :1500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:04:46 +03:30

305 lines
10 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, Sparkles, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { Sticker } from "@/components/online/Sticker";
import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { sound } from "@/lib/sound";
import { 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":
return (
<span
className="rounded-md border"
style={{
width: size * 0.72,
height: size,
borderColor: `${item.preview}80`,
background: `repeating-linear-gradient(45deg, ${item.preview}55 0 4px, transparent 4px 8px), #0a142e`,
}}
/>
);
default: // avatar, 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);
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 "xp": return false; // consumable — always buyable
default: return profile.ownedStickerPacks.includes(item.id);
}
};
const buy = async (item: ShopItem) => {
const res = await getService().buyItem(item.id);
if (res.ok && res.profile) {
setProfile(res.profile);
sound.play("purchase");
setDetail(null);
} 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.xp"), kind: "xp", hint: t("shop.xpHint") },
];
return (
<ScreenShell>
<ScreenHeader
title={t("shop.title")}
right={
<span className="glass rounded-full px-3 py-1.5 text-xs font-bold text-gold-300 flex items-center gap-1">
<Coins className="size-3.5 text-gold-400" />
{profile.coins.toLocaleString()}
</span>
}
/>
{msg && (
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
)}
{sections.map((sec) => {
const list = items.filter((i) => i.kind === sec.kind);
if (!list.length) return null;
return (
<Section key={sec.kind} title={sec.title} hint={sec.hint}>
<div className="grid grid-cols-3 gap-3">
{list.map((item) => (
<ItemCard key={item.id} item={item} owned={owns(item)} onOpen={() => setDetail(item)} />
))}
</div>
</Section>
);
})}
<AnimatePresence>
{detail && (
<DetailSheet
item={detail}
owned={owns(detail)}
coins={profile.coins}
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, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) {
const { locale } = useI18n();
const count = item.contents?.length;
return (
<motion.button
whileTap={{ scale: 0.96 }}
onClick={onOpen}
className="press-3d glass rounded-2xl p-3 flex flex-col items-center gap-2 relative"
>
{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="h-12 flex items-center justify-center">
<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" : "btn-gold"
)}
>
{owned ? (
<Check className="size-3.5" />
) : (
<>
<Coins className="size-3.5" />
{item.price.toLocaleString()}
</>
)}
</span>
</motion.button>
);
}
function DetailSheet({
item,
owned,
coins,
onBuy,
onClose,
}: {
item: ShopItem;
owned: boolean;
coins: number;
onBuy: () => void;
onClose: () => void;
}) {
const { t, locale } = useI18n();
const name = locale === "fa" ? item.nameFa : item.nameEn;
const desc = locale === "fa" ? item.descFa : item.descEn;
const canAfford = coins >= item.price;
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"
>
<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 */}
<button
onClick={onBuy}
disabled={owned || !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"
: canAfford
? "btn-gold"
: "bg-navy-900/60 text-rose-300"
)}
>
{owned ? (
<>
<Check className="size-4" /> {t("shop.owned")}
</>
) : canAfford ? (
<>
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
</>
) : (
t("lobby.needCoins")
)}
</button>
</motion.div>
</motion.div>
);
}