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,
+ };
+}