diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx index 57e458c..0356663 100644 --- a/src/components/screens/ShopScreen.tsx +++ b/src/components/screens/ShopScreen.tsx @@ -1,6 +1,7 @@ "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 { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { Sticker } from "@/components/online/Sticker"; @@ -11,12 +12,50 @@ 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 ; + case "cardfront": + return ( + + ♠ + + ); + case "cardback": + return ( + + ); + default: // avatar, reactionpack, xp → emoji glyph + return {item.kind === "xp" ? "⚡" : item.preview}; + } +} + export function ShopScreen() { const { t, locale } = useI18n(); const profile = useSessionStore((s) => s.profile); const setProfile = useSessionStore((s) => s.setProfile); const [items, setItems] = useState([]); const [msg, setMsg] = useState(""); + const [detail, setDetail] = useState(null); useEffect(() => { getService().getShopItems().then(setItems); @@ -26,18 +65,12 @@ export function ShopScreen() { 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); + 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); } }; @@ -46,18 +79,21 @@ export function ShopScreen() { 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 avatars = items.filter((i) => i.kind === "avatar"); - const cardfronts = items.filter((i) => i.kind === "cardfront"); - const cardbacks = items.filter((i) => i.kind === "cardback"); - const reactions = items.filter((i) => i.kind === "reactionpack"); - const stickers = items.filter((i) => i.kind === "stickerpack"); - const xp = items.filter((i) => i.kind === "xp"); + 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 ( @@ -75,147 +111,194 @@ export function ShopScreen() {
{msg}
)} -
-
- {avatars.map((item) => ( - buy(item)} preview={{item.preview}} /> - ))} -
-
+ {sections.map((sec) => { + const list = items.filter((i) => i.kind === sec.kind); + if (!list.length) return null; + return ( +
+
+ {list.map((item) => ( + setDetail(item)} /> + ))} +
+
+ ); + })} -
-
- {cardfronts.map((item) => ( - buy(item)} - preview={ - - ♠ - - } - /> - ))} -
-
- -
-
- {cardbacks.map((item) => ( - buy(item)} - preview={ - - } - /> - ))} -
-
- -
-
- {reactions.map((item) => ( - buy(item)} - preview={{item.preview}} - /> - ))} -
-
- -
-
- {stickers.map((item) => ( - buy(item)} - preview={} - /> - ))} -
-
- -
-

{t("shop.xpHint")}

-
- {xp.map((item) => ( - buy(item)} - preview={} - /> - ))} -
-
+ + {detail && ( + buy(detail)} + onClose={() => setDetail(null)} + /> + )} +
); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) { return (
-

{title}

+

{title}

+ {hint &&

{hint}

} + {!hint &&
} {children}
); } -function ItemCard({ - item, - owned, - onBuy, - preview, -}: { - item: ShopItem; - owned: boolean; - onBuy: () => void; - preview: React.ReactNode; -}) { - const { t } = useI18n(); +function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) { + const { locale } = useI18n(); + const count = item.contents?.length; return ( -
-
{preview}
- -
+ + + ); +} + +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 ( + + e.stopPropagation()} + className="glass rounded-3xl p-6 w-full max-w-sm text-center relative" + > + + +
+ +
+

{name}

+ {desc &&

{desc}

} + + {/* what's included */} + {item.contents && item.contents.length > 0 && ( +
+
+ {t("shop.includes")} ({item.contents.length}) +
+
+ {item.kind === "stickerpack" + ? item.contents.map((s) => ( +
+ +
+ )) + : item.contents.map((e, i) => ( + + {e} + + ))} +
+
+ )} + + {item.kind === "xp" && ( +
+ + +{item.xp?.toLocaleString()} XP +
+ )} + + {/* buy */} + +
+
); } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 212f5a3..fd0fcb4 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -260,6 +260,7 @@ const fa: Dict = { "shop.stickers": "بسته استیکرها", "shop.xp": "امتیاز تجربه (XP)", "shop.xpHint": "افزایش سریع سطح — XP گران است", + "shop.includes": "شامل", "reward.newTitle": "عنوان جدید", "reactions.title": "شکلک", @@ -525,6 +526,7 @@ const en: Dict = { "shop.stickers": "Sticker packs", "shop.xp": "XP packs", "shop.xpHint": "Level up faster — XP is expensive", + "shop.includes": "Includes", "reward.newTitle": "New title", "reactions.title": "Emoji", diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 68c4064..663fd40 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -827,6 +827,8 @@ export class MockOnlineService implements OnlineService { nameEn: "Avatar", price: a.price!, preview: a.emoji, + descFa: "آواتار نمایه شما در بازی و جدول", + descEn: "Your profile avatar in games & leaderboard", })); const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({ id: c.id, @@ -835,6 +837,8 @@ export class MockOnlineService implements OnlineService { nameEn: c.nameEn, price: c.price, 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) => ({ id: c.id, @@ -843,6 +847,8 @@ export class MockOnlineService implements OnlineService { nameEn: c.nameEn, price: c.price, preview: c.bg2, + descFa: "ظاهر روی کارت‌های شما", + descEn: "The face style of your cards", })); const reactionItems: ShopItem[] = REACTION_PACKS.filter((r) => r.price > 0).map((r) => ({ id: r.id, @@ -851,6 +857,9 @@ export class MockOnlineService implements OnlineService { nameEn: r.nameEn, price: r.price, 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) => ({ id: p.id, @@ -859,6 +868,9 @@ export class MockOnlineService implements OnlineService { nameEn: p.nameEn, price: p.price, preview: p.stickers[0], // sticker id; ShopScreen renders via + contents: p.stickers, + descFa: `${faNum(p.stickers.length)} استیکر برای استفاده در بازی`, + descEn: `${p.stickers.length} in-game stickers`, })); const xpItems: ShopItem[] = XP_PACKS.map((x) => ({ id: x.id, @@ -867,6 +879,9 @@ export class MockOnlineService implements OnlineService { nameEn: `${x.xp} XP`, price: x.price, preview: "⚡", + xp: x.xp, + descFa: `${faNum(x.xp)} امتیاز تجربه که بلافاصله به حساب اضافه می‌شود`, + descEn: `${x.xp} XP added to your account instantly`, })); return [...avatarItems, ...frontItems, ...backItems, ...reactionItems, ...stickerItems, ...xpItems]; } diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 7c9e0f6..c74c42b 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -389,6 +389,13 @@ export interface ShopItem { nameEn: string; price: number; 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 --------------------------- */