Shop redesign: tactile cards + product detail sheet
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

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>
This commit is contained in:
soroush.asadi
2026-06-05 09:04:46 +03:30
parent 96c8abbeb3
commit be8c758425
4 changed files with 243 additions and 136 deletions
+219 -136
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Check, Coins } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion";
import { Check, Coins, Sparkles, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { Sticker } from "@/components/online/Sticker"; import { Sticker } from "@/components/online/Sticker";
@@ -11,12 +12,50 @@ import { sound } from "@/lib/sound";
import { ShopItem } from "@/lib/online/types"; import { ShopItem } from "@/lib/online/types";
import { cn } from "@/lib/cn"; 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() { export function ShopScreen() {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const profile = useSessionStore((s) => s.profile); const profile = useSessionStore((s) => s.profile);
const setProfile = useSessionStore((s) => s.setProfile); const setProfile = useSessionStore((s) => s.setProfile);
const [items, setItems] = useState<ShopItem[]>([]); const [items, setItems] = useState<ShopItem[]>([]);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [detail, setDetail] = useState<ShopItem | null>(null);
useEffect(() => { useEffect(() => {
getService().getShopItems().then(setItems); getService().getShopItems().then(setItems);
@@ -26,18 +65,12 @@ export function ShopScreen() {
const owns = (item: ShopItem) => { const owns = (item: ShopItem) => {
switch (item.kind) { switch (item.kind) {
case "avatar": case "avatar": return profile.ownedAvatars.includes(item.id);
return profile.ownedAvatars.includes(item.id); case "cardfront": return profile.ownedCardFronts.includes(item.id);
case "cardfront": case "cardback": return profile.ownedCardBacks.includes(item.id);
return profile.ownedCardFronts.includes(item.id); case "reactionpack": return profile.ownedReactionPacks.includes(item.id);
case "cardback": case "xp": return false; // consumable — always buyable
return profile.ownedCardBacks.includes(item.id); default: return profile.ownedStickerPacks.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);
} }
}; };
@@ -46,18 +79,21 @@ export function ShopScreen() {
if (res.ok && res.profile) { if (res.ok && res.profile) {
setProfile(res.profile); setProfile(res.profile);
sound.play("purchase"); sound.play("purchase");
setDetail(null);
} else { } else {
setMsg(locale === "fa" ? res.messageFa : res.messageEn); setMsg(locale === "fa" ? res.messageFa : res.messageEn);
setTimeout(() => setMsg(""), 1800); setTimeout(() => setMsg(""), 1800);
} }
}; };
const avatars = items.filter((i) => i.kind === "avatar"); const sections: { title: string; kind: ShopItem["kind"]; hint?: string }[] = [
const cardfronts = items.filter((i) => i.kind === "cardfront"); { title: t("shop.avatars"), kind: "avatar" },
const cardbacks = items.filter((i) => i.kind === "cardback"); { title: t("shop.cardfronts"), kind: "cardfront" },
const reactions = items.filter((i) => i.kind === "reactionpack"); { title: t("shop.cardbacks"), kind: "cardback" },
const stickers = items.filter((i) => i.kind === "stickerpack"); { title: t("shop.reactions"), kind: "reactionpack" },
const xp = items.filter((i) => i.kind === "xp"); { title: t("shop.stickers"), kind: "stickerpack" },
{ title: t("shop.xp"), kind: "xp", hint: t("shop.xpHint") },
];
return ( return (
<ScreenShell> <ScreenShell>
@@ -75,147 +111,194 @@ export function ShopScreen() {
<div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div> <div className="mb-3 text-center text-rose-300 text-sm glass rounded-xl py-2">{msg}</div>
)} )}
<Section title={t("shop.avatars")}> {sections.map((sec) => {
<div className="grid grid-cols-3 gap-3"> const list = items.filter((i) => i.kind === sec.kind);
{avatars.map((item) => ( if (!list.length) return null;
<ItemCard key={item.id} item={item} owned={owns(item)} onBuy={() => buy(item)} preview={<span className="text-4xl">{item.preview}</span>} /> return (
))} <Section key={sec.kind} title={sec.title} hint={sec.hint}>
</div> <div className="grid grid-cols-3 gap-3">
</Section> {list.map((item) => (
<ItemCard key={item.id} item={item} owned={owns(item)} onOpen={() => setDetail(item)} />
))}
</div>
</Section>
);
})}
<Section title={t("shop.cardfronts")}> <AnimatePresence>
<div className="grid grid-cols-3 gap-3"> {detail && (
{cardfronts.map((item) => ( <DetailSheet
<ItemCard item={detail}
key={item.id} owned={owns(detail)}
item={item} coins={profile.coins}
owned={owns(item)} onBuy={() => buy(detail)}
onBuy={() => buy(item)} onClose={() => setDetail(null)}
preview={ />
<span )}
className="w-8 h-11 rounded-md border flex items-center justify-center text-slate-900" </AnimatePresence>
style={{ background: `linear-gradient(160deg, #ffffff, ${item.preview})`, borderColor: "rgba(0,0,0,0.18)" }}
>
</span>
}
/>
))}
</div>
</Section>
<Section title={t("shop.cardbacks")}>
<div className="grid grid-cols-3 gap-3">
{cardbacks.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={owns(item)}
onBuy={() => buy(item)}
preview={
<span
className="w-8 h-11 rounded-md border"
style={{
borderColor: `${item.preview}80`,
background: `repeating-linear-gradient(45deg, ${item.preview}55 0 4px, transparent 4px 8px), #0a142e`,
}}
/>
}
/>
))}
</div>
</Section>
<Section title={t("shop.reactions")}>
<div className="grid grid-cols-3 gap-3">
{reactions.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={owns(item)}
onBuy={() => buy(item)}
preview={<span className="text-4xl">{item.preview}</span>}
/>
))}
</div>
</Section>
<Section title={t("shop.stickers")}>
<div className="grid grid-cols-3 gap-3">
{stickers.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={owns(item)}
onBuy={() => buy(item)}
preview={<Sticker id={item.preview} size={48} />}
/>
))}
</div>
</Section>
<Section title={t("shop.xp")}>
<p className="text-[11px] text-cream/45 -mt-2 mb-3">{t("shop.xpHint")}</p>
<div className="grid grid-cols-3 gap-3">
{xp.map((item) => (
<ItemCard
key={item.id}
item={item}
owned={false}
onBuy={() => buy(item)}
preview={<span className="text-4xl"></span>}
/>
))}
</div>
</Section>
</ScreenShell> </ScreenShell>
); );
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return ( return (
<div className="mb-5"> <div className="mb-5">
<h3 className="text-sm font-bold text-cream/80 mb-3">{title}</h3> <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} {children}
</div> </div>
); );
} }
function ItemCard({ function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) {
item, const { locale } = useI18n();
owned, const count = item.contents?.length;
onBuy,
preview,
}: {
item: ShopItem;
owned: boolean;
onBuy: () => void;
preview: React.ReactNode;
}) {
const { t } = useI18n();
return ( return (
<div className="glass rounded-2xl p-3 flex flex-col items-center gap-2"> <motion.button
<div className="h-12 flex items-center justify-center">{preview}</div> whileTap={{ scale: 0.96 }}
<button onClick={onOpen}
disabled={owned} className="press-3d glass rounded-2xl p-3 flex flex-col items-center gap-2 relative"
onClick={onBuy} >
{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( className={cn(
"w-full rounded-lg py-1.5 text-xs font-bold flex items-center justify-center gap-1", "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 ? "bg-navy-900/60 text-teal-300" : "btn-gold"
)} )}
> >
{owned ? ( {owned ? (
<> <Check className="size-3.5" />
<Check className="size-3.5" />
{t("shop.owned")}
</>
) : ( ) : (
<> <>
<Coins className="size-3.5" /> <Coins className="size-3.5" />
{item.price.toLocaleString()} {item.price.toLocaleString()}
</> </>
)} )}
</button> </span>
</div> </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>
); );
} }
+2
View File
@@ -260,6 +260,7 @@ const fa: Dict = {
"shop.stickers": "بسته استیکرها", "shop.stickers": "بسته استیکرها",
"shop.xp": "امتیاز تجربه (XP)", "shop.xp": "امتیاز تجربه (XP)",
"shop.xpHint": "افزایش سریع سطح — XP گران است", "shop.xpHint": "افزایش سریع سطح — XP گران است",
"shop.includes": "شامل",
"reward.newTitle": "عنوان جدید", "reward.newTitle": "عنوان جدید",
"reactions.title": "شکلک", "reactions.title": "شکلک",
@@ -525,6 +526,7 @@ const en: Dict = {
"shop.stickers": "Sticker packs", "shop.stickers": "Sticker packs",
"shop.xp": "XP packs", "shop.xp": "XP packs",
"shop.xpHint": "Level up faster — XP is expensive", "shop.xpHint": "Level up faster — XP is expensive",
"shop.includes": "Includes",
"reward.newTitle": "New title", "reward.newTitle": "New title",
"reactions.title": "Emoji", "reactions.title": "Emoji",
+15
View File
@@ -827,6 +827,8 @@ export class MockOnlineService implements OnlineService {
nameEn: "Avatar", nameEn: "Avatar",
price: a.price!, price: a.price!,
preview: a.emoji, preview: a.emoji,
descFa: "آواتار نمایه شما در بازی و جدول",
descEn: "Your profile avatar in games & leaderboard",
})); }));
const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({ const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({
id: c.id, id: c.id,
@@ -835,6 +837,8 @@ export class MockOnlineService implements OnlineService {
nameEn: c.nameEn, nameEn: c.nameEn,
price: c.price, price: c.price,
preview: c.accent, preview: c.accent,
descFa: "طرح پشت کارت‌ها روی میز",
descEn: "The pattern on the back of your cards",
})); }));
const frontItems: ShopItem[] = CARD_FRONTS.filter((c) => c.price > 0).map((c) => ({ const frontItems: ShopItem[] = CARD_FRONTS.filter((c) => c.price > 0).map((c) => ({
id: c.id, id: c.id,
@@ -843,6 +847,8 @@ export class MockOnlineService implements OnlineService {
nameEn: c.nameEn, nameEn: c.nameEn,
price: c.price, price: c.price,
preview: c.bg2, preview: c.bg2,
descFa: "ظاهر روی کارت‌های شما",
descEn: "The face style of your cards",
})); }));
const reactionItems: ShopItem[] = REACTION_PACKS.filter((r) => r.price > 0).map((r) => ({ const reactionItems: ShopItem[] = REACTION_PACKS.filter((r) => r.price > 0).map((r) => ({
id: r.id, id: r.id,
@@ -851,6 +857,9 @@ export class MockOnlineService implements OnlineService {
nameEn: r.nameEn, nameEn: r.nameEn,
price: r.price, price: r.price,
preview: r.reactions[0], preview: r.reactions[0],
contents: r.reactions,
descFa: `${faNum(r.reactions.length)} ایموجی برای استفاده در بازی`,
descEn: `${r.reactions.length} in-game emotes`,
})); }));
const stickerItems: ShopItem[] = STICKER_PACKS.filter((p) => p.price > 0).map((p) => ({ const stickerItems: ShopItem[] = STICKER_PACKS.filter((p) => p.price > 0).map((p) => ({
id: p.id, id: p.id,
@@ -859,6 +868,9 @@ export class MockOnlineService implements OnlineService {
nameEn: p.nameEn, nameEn: p.nameEn,
price: p.price, price: p.price,
preview: p.stickers[0], // sticker id; ShopScreen renders via <Sticker> preview: p.stickers[0], // sticker id; ShopScreen renders via <Sticker>
contents: p.stickers,
descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`,
descEn: `${p.stickers.length} in-game stickers`,
})); }));
const xpItems: ShopItem[] = XP_PACKS.map((x) => ({ const xpItems: ShopItem[] = XP_PACKS.map((x) => ({
id: x.id, id: x.id,
@@ -867,6 +879,9 @@ export class MockOnlineService implements OnlineService {
nameEn: `${x.xp} XP`, nameEn: `${x.xp} XP`,
price: x.price, price: x.price,
preview: "⚡", preview: "⚡",
xp: x.xp,
descFa: `${faNum(x.xp)} امتیاز تجربه که بلافاصله به حساب اضافه می‌شود`,
descEn: `${x.xp} XP added to your account instantly`,
})); }));
return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...xpItems]; return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...xpItems];
} }
+7
View File
@@ -389,6 +389,13 @@ export interface ShopItem {
nameEn: string; nameEn: string;
price: number; price: number;
preview: string; // emoji/avatar id/color preview: string; // emoji/avatar id/color
/** what the pack contains: sticker ids (stickerpack) or emoji chars (reactionpack) */
contents?: string[];
/** XP granted by an xp pack */
xp?: number;
/** short fa/en description of what the item is/does */
descFa?: string;
descEn?: string;
} }
/* ------------------------------ Coin packs --------------------------- */ /* ------------------------------ Coin packs --------------------------- */