diff --git a/components/shop/shop-content.tsx b/components/shop/shop-content.tsx index 2915702..b910322 100644 --- a/components/shop/shop-content.tsx +++ b/components/shop/shop-content.tsx @@ -8,8 +8,10 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { useToast } from "@/components/ui/toast-provider"; import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events"; +import { getAvatarCustomization } from "@/lib/avatar-customization-storage"; +import type { AvatarCustomization } from "@/lib/avatar-customization-storage"; import { DEFAULT_GRAY_COLOR } from "@/lib/character/presets"; -import { getRoomBackground } from "@/lib/room-backgrounds"; +import { getRoomBackground, normalizeRoomBackgroundId } from "@/lib/room-backgrounds"; import { equipShopItem, getShopInventory, @@ -23,6 +25,8 @@ import { } from "@/lib/shop-catalog"; import { cn } from "@/lib/utils"; +import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal"; + const SHOP_LAYER_ORDER: ShopLayerId[] = [ "head", "torso", @@ -39,6 +43,7 @@ function ShopItemCard({ isPending, onPurchase, onEquipToggle, + onPreview, }: { item: ShopItemRecord; owned: boolean; @@ -47,6 +52,7 @@ function ShopItemCard({ isPending: boolean; onPurchase: () => void; onEquipToggle: () => void; + onPreview: () => void; }) { const room = item.type === "room" ? getRoomBackground(item.id) : null; @@ -101,16 +107,29 @@ function ShopItemCard({ - +
+ + +
); @@ -125,16 +144,27 @@ export function ShopContent() { const [coins, setCoins] = useState(null); const [error, setError] = useState(null); const [pendingItemId, setPendingItemId] = useState(null); + const [baseCustomization, setBaseCustomization] = + useState(null); + const [previewItem, setPreviewItem] = useState(null); const refreshShop = useCallback(async () => { try { setError(null); - const inventory = await getShopInventory(); + const [inventory, customization] = await Promise.all([ + getShopInventory(), + getAvatarCustomization(), + ]); setItems(inventory.items); setOwnedItemIds(inventory.ownedItemIds); setEquippedItems(inventory.equippedItems); setEquippedRoomBackground(inventory.equippedRoomBackground); setCoins(inventory.coins); + setBaseCustomization({ + ...customization, + equippedItems: inventory.equippedItems, + roomBackground: normalizeRoomBackgroundId(inventory.equippedRoomBackground), + }); } catch (refreshError) { setError( refreshError instanceof Error @@ -214,6 +244,12 @@ export function ShopContent() { return ( <> + setPreviewItem(null)} + /> +
Your balance @@ -253,6 +289,7 @@ export function ShopContent() { isPending={pendingItemId === item.id} onPurchase={() => void handlePurchase(item.id)} onEquipToggle={() => void handleEquipToggle(item)} + onPreview={() => setPreviewItem(item)} /> ))}
@@ -275,6 +312,7 @@ export function ShopContent() { isPending={pendingItemId === item.id} onPurchase={() => void handlePurchase(item.id)} onEquipToggle={() => void handleEquipToggle(item)} + onPreview={() => setPreviewItem(item)} /> ))} diff --git a/components/shop/shop-item-preview-modal.tsx b/components/shop/shop-item-preview-modal.tsx new file mode 100644 index 0000000..8fb704b --- /dev/null +++ b/components/shop/shop-item-preview-modal.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { X } from "lucide-react"; +import { useEffect } from "react"; + +import { CharacterLayerPreview } from "@/components/character/character-layer-preview"; +import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background"; +import { Button } from "@/components/ui/button"; +import type { AvatarCustomization } from "@/lib/avatar-customization-storage"; +import { buildShopItemPreviewCustomization } from "@/lib/shop-preview"; +import type { ShopItemRecord } from "@/lib/shop-catalog"; +import { + normalizeRoomBackgroundId, + type RoomBackgroundId, +} from "@/lib/room-backgrounds"; + +type ShopItemPreviewModalProps = { + item: ShopItemRecord | null; + baseCustomization: AvatarCustomization | null; + onClose: () => void; +}; + +export function ShopItemPreviewModal({ + item, + baseCustomization, + onClose, +}: ShopItemPreviewModalProps) { + useEffect(() => { + if (!item) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [item, onClose]); + + if (!item || !baseCustomization) { + return null; + } + + const previewCustomization = buildShopItemPreviewCustomization( + baseCustomization, + item, + ); + const roomId = normalizeRoomBackgroundId( + previewCustomization.roomBackground, + ) as RoomBackgroundId; + + return ( +
+
event.stopPropagation()} + > +
+
+

+ Preview on your pet +

+

{item.name}

+
+ +
+ +
+
+ +
+ +
+
+
+

+ This preview does not equip or purchase the item. +

+
+
+
+ ); +} diff --git a/lib/shop-preview.ts b/lib/shop-preview.ts new file mode 100644 index 0000000..17640a7 --- /dev/null +++ b/lib/shop-preview.ts @@ -0,0 +1,34 @@ +import type { AvatarCustomization } from "@/lib/avatar-customization-storage"; +import { parseVariantId, parseShopStyleItem, type ShopItemRecord } from "@/lib/shop-catalog"; +import { normalizeRoomBackgroundId } from "@/lib/room-backgrounds"; + +/** Build a non-persisted avatar state showing how a shop item would look. */ +export function buildShopItemPreviewCustomization( + base: AvatarCustomization, + item: ShopItemRecord, +): AvatarCustomization { + if (item.type === "room") { + return { + ...base, + roomBackground: normalizeRoomBackgroundId(item.id), + }; + } + + const style = parseShopStyleItem(item.id, item.type); + if (!style) { + return base; + } + + const nextEquipped = [ + ...base.equippedItems.filter((equippedId) => { + const equippedStyle = parseVariantId(equippedId); + return equippedStyle?.layerId !== style.layerId; + }), + item.id, + ]; + + return { + ...base, + equippedItems: nextEquipped, + }; +}