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.
This commit is contained in:
@@ -8,8 +8,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { useToast } from "@/components/ui/toast-provider";
|
import { useToast } from "@/components/ui/toast-provider";
|
||||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
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 { DEFAULT_GRAY_COLOR } from "@/lib/character/presets";
|
||||||
import { getRoomBackground } from "@/lib/room-backgrounds";
|
import { getRoomBackground, normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
|
||||||
import {
|
import {
|
||||||
equipShopItem,
|
equipShopItem,
|
||||||
getShopInventory,
|
getShopInventory,
|
||||||
@@ -23,6 +25,8 @@ import {
|
|||||||
} from "@/lib/shop-catalog";
|
} from "@/lib/shop-catalog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { ShopItemPreviewModal } from "@/components/shop/shop-item-preview-modal";
|
||||||
|
|
||||||
const SHOP_LAYER_ORDER: ShopLayerId[] = [
|
const SHOP_LAYER_ORDER: ShopLayerId[] = [
|
||||||
"head",
|
"head",
|
||||||
"torso",
|
"torso",
|
||||||
@@ -39,6 +43,7 @@ function ShopItemCard({
|
|||||||
isPending,
|
isPending,
|
||||||
onPurchase,
|
onPurchase,
|
||||||
onEquipToggle,
|
onEquipToggle,
|
||||||
|
onPreview,
|
||||||
}: {
|
}: {
|
||||||
item: ShopItemRecord;
|
item: ShopItemRecord;
|
||||||
owned: boolean;
|
owned: boolean;
|
||||||
@@ -47,6 +52,7 @@ function ShopItemCard({
|
|||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onPurchase: () => void;
|
onPurchase: () => void;
|
||||||
onEquipToggle: () => void;
|
onEquipToggle: () => void;
|
||||||
|
onPreview: () => void;
|
||||||
}) {
|
}) {
|
||||||
const room = item.type === "room" ? getRoomBackground(item.id) : null;
|
const room = item.type === "room" ? getRoomBackground(item.id) : null;
|
||||||
|
|
||||||
@@ -101,16 +107,29 @@ function ShopItemCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="mt-auto grid grid-cols-2 gap-2">
|
||||||
className="mt-auto h-auto min-h-9 w-full whitespace-normal px-2 py-2 text-center text-[10px] leading-snug"
|
<Button
|
||||||
variant={owned && equipped ? "default" : "outline"}
|
type="button"
|
||||||
disabled={
|
className="h-auto min-h-9 whitespace-normal px-2 py-2 text-[10px] leading-snug"
|
||||||
isPending || (!owned && !canAfford) || (owned && item.type === "room" && equipped)
|
variant="outline"
|
||||||
}
|
disabled={isPending}
|
||||||
onClick={owned ? onEquipToggle : onPurchase}
|
onClick={onPreview}
|
||||||
>
|
>
|
||||||
{actionLabel}
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-auto min-h-9 whitespace-normal px-2 py-2 text-[10px] leading-snug"
|
||||||
|
variant={owned && equipped ? "default" : "outline"}
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
(!owned && !canAfford) ||
|
||||||
|
(owned && item.type === "room" && equipped)
|
||||||
|
}
|
||||||
|
onClick={owned ? onEquipToggle : onPurchase}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -125,16 +144,27 @@ export function ShopContent() {
|
|||||||
const [coins, setCoins] = useState<number | null>(null);
|
const [coins, setCoins] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
|
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
|
||||||
|
const [baseCustomization, setBaseCustomization] =
|
||||||
|
useState<AvatarCustomization | null>(null);
|
||||||
|
const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null);
|
||||||
|
|
||||||
const refreshShop = useCallback(async () => {
|
const refreshShop = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const inventory = await getShopInventory();
|
const [inventory, customization] = await Promise.all([
|
||||||
|
getShopInventory(),
|
||||||
|
getAvatarCustomization(),
|
||||||
|
]);
|
||||||
setItems(inventory.items);
|
setItems(inventory.items);
|
||||||
setOwnedItemIds(inventory.ownedItemIds);
|
setOwnedItemIds(inventory.ownedItemIds);
|
||||||
setEquippedItems(inventory.equippedItems);
|
setEquippedItems(inventory.equippedItems);
|
||||||
setEquippedRoomBackground(inventory.equippedRoomBackground);
|
setEquippedRoomBackground(inventory.equippedRoomBackground);
|
||||||
setCoins(inventory.coins);
|
setCoins(inventory.coins);
|
||||||
|
setBaseCustomization({
|
||||||
|
...customization,
|
||||||
|
equippedItems: inventory.equippedItems,
|
||||||
|
roomBackground: normalizeRoomBackgroundId(inventory.equippedRoomBackground),
|
||||||
|
});
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
setError(
|
setError(
|
||||||
refreshError instanceof Error
|
refreshError instanceof Error
|
||||||
@@ -214,6 +244,12 @@ export function ShopContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ShopItemPreviewModal
|
||||||
|
item={previewItem}
|
||||||
|
baseCustomization={baseCustomization}
|
||||||
|
onClose={() => setPreviewItem(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<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)]">
|
<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)]">
|
||||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
Your balance
|
Your balance
|
||||||
@@ -253,6 +289,7 @@ export function ShopContent() {
|
|||||||
isPending={pendingItemId === item.id}
|
isPending={pendingItemId === item.id}
|
||||||
onPurchase={() => void handlePurchase(item.id)}
|
onPurchase={() => void handlePurchase(item.id)}
|
||||||
onEquipToggle={() => void handleEquipToggle(item)}
|
onEquipToggle={() => void handleEquipToggle(item)}
|
||||||
|
onPreview={() => setPreviewItem(item)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -275,6 +312,7 @@ export function ShopContent() {
|
|||||||
isPending={pendingItemId === item.id}
|
isPending={pendingItemId === item.id}
|
||||||
onPurchase={() => void handlePurchase(item.id)}
|
onPurchase={() => void handlePurchase(item.id)}
|
||||||
onEquipToggle={() => void handleEquipToggle(item)}
|
onEquipToggle={() => void handleEquipToggle(item)}
|
||||||
|
onPreview={() => setPreviewItem(item)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
113
components/shop/shop-item-preview-modal.tsx
Normal file
113
components/shop/shop-item-preview-modal.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/70 p-4 sm:items-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="shop-preview-title"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg border-2 border-border bg-card shadow-[var(--retro-shadow)]"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 border-b-2 border-border px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
id="shop-preview-title"
|
||||||
|
className="text-[10px] uppercase tracking-wider text-muted-foreground"
|
||||||
|
>
|
||||||
|
Preview on your pet
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-sm">{item.name}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
aria-label="Close preview"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="tamagotchi-lcd relative aspect-[4/3] w-full overflow-hidden">
|
||||||
|
<ParallaxRoomBackground roomId={roomId} interactive={false} />
|
||||||
|
<div className="relative z-10 flex h-full items-center justify-center">
|
||||||
|
<CharacterLayerPreview
|
||||||
|
colors={previewCustomization.colors}
|
||||||
|
variants={previewCustomization.variants}
|
||||||
|
equippedItems={previewCustomization.equippedItems}
|
||||||
|
scale={8}
|
||||||
|
compact
|
||||||
|
className="!h-auto max-h-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-center text-[10px] text-muted-foreground">
|
||||||
|
This preview does not equip or purchase the item.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
lib/shop-preview.ts
Normal file
34
lib/shop-preview.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user