Skip to content

Commit 69131ac

Browse files
committed
Add shop preview modal to try items on your pet before buying.
Preview overlays the item or room background on the current avatar without equipping.
1 parent 4df8ea8 commit 69131ac

3 files changed

Lines changed: 197 additions & 12 deletions

File tree

components/shop/shop-content.tsx

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import { Button } from "@/components/ui/button";
88
import { Card, CardContent } from "@/components/ui/card";
99
import { useToast } from "@/components/ui/toast-provider";
1010
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
11+
import { getAvatarCustomization } from "@/lib/avatar-customization-storage";
12+
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
1113
import { DEFAULT_GRAY_COLOR } from "@/lib/character/presets";
12-
import { getRoomBackground } from "@/lib/room-backgrounds";
14+
import { getRoomBackground, normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
1315
import {
1416
equipShopItem,
1517
getShopInventory,
@@ -23,6 +25,8 @@ import {
2325
} from "@/lib/shop-catalog";
2426
import { cn } from "@/lib/utils";
2527

28+
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
29+
2630
const SHOP_LAYER_ORDER: ShopLayerId[] = [
2731
"head",
2832
"torso",
@@ -39,6 +43,7 @@ function ShopItemCard({
3943
isPending,
4044
onPurchase,
4145
onEquipToggle,
46+
onPreview,
4247
}: {
4348
item: ShopItemRecord;
4449
owned: boolean;
@@ -47,6 +52,7 @@ function ShopItemCard({
4752
isPending: boolean;
4853
onPurchase: () => void;
4954
onEquipToggle: () => void;
55+
onPreview: () => void;
5056
}) {
5157
const room = item.type === "room" ? getRoomBackground(item.id) : null;
5258

@@ -101,16 +107,29 @@ function ShopItemCard({
101107
</div>
102108
</div>
103109

104-
<Button
105-
className="mt-auto h-auto min-h-9 w-full whitespace-normal px-2 py-2 text-center text-[10px] leading-snug"
106-
variant={owned && equipped ? "default" : "outline"}
107-
disabled={
108-
isPending || (!owned && !canAfford) || (owned && item.type === "room" && equipped)
109-
}
110-
onClick={owned ? onEquipToggle : onPurchase}
111-
>
112-
{actionLabel}
113-
</Button>
110+
<div className="mt-auto grid grid-cols-2 gap-2">
111+
<Button
112+
type="button"
113+
className="h-auto min-h-9 whitespace-normal px-2 py-2 text-[10px] leading-snug"
114+
variant="outline"
115+
disabled={isPending}
116+
onClick={onPreview}
117+
>
118+
Preview
119+
</Button>
120+
<Button
121+
className="h-auto min-h-9 whitespace-normal px-2 py-2 text-[10px] leading-snug"
122+
variant={owned && equipped ? "default" : "outline"}
123+
disabled={
124+
isPending ||
125+
(!owned && !canAfford) ||
126+
(owned && item.type === "room" && equipped)
127+
}
128+
onClick={owned ? onEquipToggle : onPurchase}
129+
>
130+
{actionLabel}
131+
</Button>
132+
</div>
114133
</CardContent>
115134
</Card>
116135
);
@@ -125,16 +144,27 @@ export function ShopContent() {
125144
const [coins, setCoins] = useState<number | null>(null);
126145
const [error, setError] = useState<string | null>(null);
127146
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
147+
const [baseCustomization, setBaseCustomization] =
148+
useState<AvatarCustomization | null>(null);
149+
const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null);
128150

129151
const refreshShop = useCallback(async () => {
130152
try {
131153
setError(null);
132-
const inventory = await getShopInventory();
154+
const [inventory, customization] = await Promise.all([
155+
getShopInventory(),
156+
getAvatarCustomization(),
157+
]);
133158
setItems(inventory.items);
134159
setOwnedItemIds(inventory.ownedItemIds);
135160
setEquippedItems(inventory.equippedItems);
136161
setEquippedRoomBackground(inventory.equippedRoomBackground);
137162
setCoins(inventory.coins);
163+
setBaseCustomization({
164+
...customization,
165+
equippedItems: inventory.equippedItems,
166+
roomBackground: normalizeRoomBackgroundId(inventory.equippedRoomBackground),
167+
});
138168
} catch (refreshError) {
139169
setError(
140170
refreshError instanceof Error
@@ -214,6 +244,12 @@ export function ShopContent() {
214244

215245
return (
216246
<>
247+
<ShopItemPreviewModal
248+
item={previewItem}
249+
baseCustomization={baseCustomization}
250+
onClose={() => setPreviewItem(null)}
251+
/>
252+
217253
<div className="mb-5 flex items-center justify-between gap-3 border-2 border-border bg-muted/40 px-4 py-3 shadow-[var(--retro-shadow-sm)]">
218254
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
219255
Your balance
@@ -253,6 +289,7 @@ export function ShopContent() {
253289
isPending={pendingItemId === item.id}
254290
onPurchase={() => void handlePurchase(item.id)}
255291
onEquipToggle={() => void handleEquipToggle(item)}
292+
onPreview={() => setPreviewItem(item)}
256293
/>
257294
))}
258295
</div>
@@ -275,6 +312,7 @@ export function ShopContent() {
275312
isPending={pendingItemId === item.id}
276313
onPurchase={() => void handlePurchase(item.id)}
277314
onEquipToggle={() => void handleEquipToggle(item)}
315+
onPreview={() => setPreviewItem(item)}
278316
/>
279317
))}
280318
</div>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"use client";
2+
3+
import { X } from "lucide-react";
4+
import { useEffect } from "react";
5+
6+
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
7+
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
8+
import { Button } from "@/components/ui/button";
9+
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
10+
import { buildShopItemPreviewCustomization } from "@/lib/shop-preview";
11+
import type { ShopItemRecord } from "@/lib/shop-catalog";
12+
import {
13+
normalizeRoomBackgroundId,
14+
type RoomBackgroundId,
15+
} from "@/lib/room-backgrounds";
16+
17+
type ShopItemPreviewModalProps = {
18+
item: ShopItemRecord | null;
19+
baseCustomization: AvatarCustomization | null;
20+
onClose: () => void;
21+
};
22+
23+
export function ShopItemPreviewModal({
24+
item,
25+
baseCustomization,
26+
onClose,
27+
}: ShopItemPreviewModalProps) {
28+
useEffect(() => {
29+
if (!item) {
30+
return;
31+
}
32+
33+
const handleKeyDown = (event: KeyboardEvent) => {
34+
if (event.key === "Escape") {
35+
onClose();
36+
}
37+
};
38+
39+
window.addEventListener("keydown", handleKeyDown);
40+
return () => {
41+
window.removeEventListener("keydown", handleKeyDown);
42+
};
43+
}, [item, onClose]);
44+
45+
if (!item || !baseCustomization) {
46+
return null;
47+
}
48+
49+
const previewCustomization = buildShopItemPreviewCustomization(
50+
baseCustomization,
51+
item,
52+
);
53+
const roomId = normalizeRoomBackgroundId(
54+
previewCustomization.roomBackground,
55+
) as RoomBackgroundId;
56+
57+
return (
58+
<div
59+
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/70 p-4 sm:items-center"
60+
role="dialog"
61+
aria-modal="true"
62+
aria-labelledby="shop-preview-title"
63+
onClick={onClose}
64+
>
65+
<div
66+
className="w-full max-w-lg border-2 border-border bg-card shadow-[var(--retro-shadow)]"
67+
onClick={(event) => event.stopPropagation()}
68+
>
69+
<div className="flex items-start justify-between gap-3 border-b-2 border-border px-4 py-3">
70+
<div className="min-w-0">
71+
<p
72+
id="shop-preview-title"
73+
className="text-[10px] uppercase tracking-wider text-muted-foreground"
74+
>
75+
Preview on your pet
76+
</p>
77+
<p className="mt-1 truncate text-sm">{item.name}</p>
78+
</div>
79+
<Button
80+
type="button"
81+
size="icon"
82+
variant="ghost"
83+
className="h-8 w-8 shrink-0"
84+
aria-label="Close preview"
85+
onClick={onClose}
86+
>
87+
<X className="h-4 w-4" />
88+
</Button>
89+
</div>
90+
91+
<div className="p-4">
92+
<div className="tamagotchi-lcd relative aspect-[4/3] w-full overflow-hidden">
93+
<ParallaxRoomBackground roomId={roomId} interactive={false} />
94+
<div className="relative z-10 flex h-full items-center justify-center">
95+
<CharacterLayerPreview
96+
colors={previewCustomization.colors}
97+
variants={previewCustomization.variants}
98+
equippedItems={previewCustomization.equippedItems}
99+
scale={8}
100+
compact
101+
className="!h-auto max-h-none"
102+
/>
103+
</div>
104+
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
105+
</div>
106+
<p className="mt-3 text-center text-[10px] text-muted-foreground">
107+
This preview does not equip or purchase the item.
108+
</p>
109+
</div>
110+
</div>
111+
</div>
112+
);
113+
}

lib/shop-preview.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
2+
import { parseVariantId, parseShopStyleItem, type ShopItemRecord } from "@/lib/shop-catalog";
3+
import { normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
4+
5+
/** Build a non-persisted avatar state showing how a shop item would look. */
6+
export function buildShopItemPreviewCustomization(
7+
base: AvatarCustomization,
8+
item: ShopItemRecord,
9+
): AvatarCustomization {
10+
if (item.type === "room") {
11+
return {
12+
...base,
13+
roomBackground: normalizeRoomBackgroundId(item.id),
14+
};
15+
}
16+
17+
const style = parseShopStyleItem(item.id, item.type);
18+
if (!style) {
19+
return base;
20+
}
21+
22+
const nextEquipped = [
23+
...base.equippedItems.filter((equippedId) => {
24+
const equippedStyle = parseVariantId(equippedId);
25+
return equippedStyle?.layerId !== style.layerId;
26+
}),
27+
item.id,
28+
];
29+
30+
return {
31+
...base,
32+
equippedItems: nextEquipped,
33+
};
34+
}

0 commit comments

Comments
 (0)