From 3470d444b4dd933141a06004bcdfe528dca53028 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 24 May 2026 05:05:46 -0600 Subject: [PATCH] Add customize-screen shop preview and purchase flow. Locked items can be previewed on the pet from the editor, purchased with points in the preview modal, and opened in the full customize view from the shop. --- components/avatar/customize-page-content.tsx | 31 ++- components/character/character-creator.tsx | 189 +++++++++++++++++- .../character/character-piece-selector.tsx | 19 +- components/pet/room-background-picker.tsx | 16 +- components/pet/room-style-selector.tsx | 19 +- components/shop/shop-item-preview-modal.tsx | 10 + lib/shop-catalog.ts | 7 + 7 files changed, 260 insertions(+), 31 deletions(-) diff --git a/components/avatar/customize-page-content.tsx b/components/avatar/customize-page-content.tsx index fc521e6..1bf9815 100644 --- a/components/avatar/customize-page-content.tsx +++ b/components/avatar/customize-page-content.tsx @@ -10,7 +10,9 @@ import { saveAvatarCustomization, type AvatarCustomization, } from "@/lib/avatar-customization-storage"; -import { getOwnedItemIds } from "@/lib/shop-storage"; +import { normalizeRoomBackgroundId } from "@/lib/room-backgrounds"; +import type { ShopItemRecord } from "@/lib/shop-catalog"; +import { getShopInventory } from "@/lib/shop-storage"; export function CustomizePageContent() { const { toast } = useToast(); @@ -20,18 +22,30 @@ export function CustomizePageContent() { const [avatarName, setAvatarName] = useState(""); const [isSavingAvatar, setIsSavingAvatar] = useState(false); const [ownedVariantIds, setOwnedVariantIds] = useState([]); + const [equippedItems, setEquippedItems] = useState([]); + const [shopItems, setShopItems] = useState([]); + const [coins, setCoins] = useState(null); const [loadError, setLoadError] = useState(null); const loadCustomization = useCallback(async () => { try { setLoadError(null); - const [next, owned] = await Promise.all([ + const [next, inventory] = await Promise.all([ getAvatarCustomization(), - getOwnedItemIds(), + getShopInventory(), ]); - setCustomization(next); + setCustomization({ + ...next, + equippedItems: inventory.equippedItems, + roomBackground: normalizeRoomBackgroundId( + inventory.equippedRoomBackground, + ), + }); setAvatarName(next.name); - setOwnedVariantIds(owned); + setOwnedVariantIds(inventory.ownedItemIds); + setEquippedItems(inventory.equippedItems); + setShopItems(inventory.items); + setCoins(inventory.coins); } catch (error) { setLoadError( error instanceof Error ? error.message : "Could not load your avatar.", @@ -55,7 +69,6 @@ export function CustomizePageContent() { await saveAvatarCustomization({ ...nextCustomization, name: avatarName.trim() || nextCustomization.name, - equippedItems: customization?.equippedItems ?? [], }); toast("Avatar updated.", "success"); await loadCustomization(); @@ -80,7 +93,7 @@ export function CustomizePageContent() { return ( void | Promise; onSave?: (customization: AvatarCustomization) => Promise; }; @@ -56,11 +73,16 @@ export function CharacterCreator({ name: controlledName, onNameChange, ownedVariantIds = [], + shopItems = [], + coins = null, + equippedItems: initialEquippedItems = [], + onShopDataChange, showNameField = false, saveLabel = "Save avatar", isSaving = false, onSave, }: CharacterCreatorProps) { + const { toast } = useToast(); const [internalName, setInternalName] = useState( initialCustomization?.name ?? "Pixel Me", ); @@ -83,11 +105,16 @@ export function CharacterCreator({ const [roomBackground, setRoomBackground] = useState( normalizeRoomBackgroundId(initialCustomization?.roomBackground), ); + const [equippedItems, setEquippedItems] = useState( + initialCustomization?.equippedItems ?? initialEquippedItems, + ); const [activePresetId, setActivePresetId] = useState( DEFAULT_GRAY_COLOR.id, ); const [activeTabId, setActiveTabId] = useState("skin"); const [saveError, setSaveError] = useState(null); + const [previewItem, setPreviewItem] = useState(null); + const [pendingItemId, setPendingItemId] = useState(null); const isRoomTab = activeTabId === "room"; const isSkinTab = activeTabId === "skin"; @@ -160,15 +187,135 @@ export function CharacterCreator({ setRoomBackground(roomId); }; + const openLockedItemPreview = useCallback( + (itemId: string) => { + const item = findShopItemById(shopItems, itemId); + + if (!item) { + toast("This item is not available in the shop yet.", "error"); + return; + } + + setPreviewItem(item); + }, + [shopItems, toast], + ); + const buildCustomization = (): AvatarCustomization => ({ name, colors, variants, customized: true, - equippedItems: initialCustomization?.equippedItems ?? [], + equippedItems, roomBackground, }); + const displayCustomization = previewItem + ? buildShopItemPreviewCustomization(buildCustomization(), previewItem) + : buildCustomization(); + + const previewOwned = previewItem + ? ownedVariantIds.includes(previewItem.id) + : false; + const previewEquipped = previewItem + ? previewItem.type === "room" + ? roomBackground === previewItem.id + : equippedItems.includes(previewItem.id) + : false; + const previewCanAfford = previewItem + ? coins !== null && coins >= previewItem.price + : false; + + const applyOwnedItemToEditor = (item: ShopItemRecord) => { + if (item.type === "room") { + setRoomBackground(normalizeRoomBackgroundId(item.id)); + return; + } + + const style = parseShopStyleItem(item.id, item.type); + + if (style) { + setVariants((current) => ({ + ...current, + [style.layerId]: style.variantId, + })); + } + }; + + const handlePreviewPurchase = async () => { + if (!previewItem) { + return; + } + + setPendingItemId(previewItem.id); + + try { + const purchased = await purchaseShopItem(previewItem.id); + const nextEquipped = await equipShopItem(previewItem.id); + setEquippedItems(nextEquipped); + applyOwnedItemToEditor(purchased); + toast(`Purchased and equipped ${purchased.name}.`, "success"); + setPreviewItem(null); + await onShopDataChange?.(); + } catch (error) { + toast( + error instanceof Error ? error.message : "Purchase failed.", + "error", + ); + } finally { + setPendingItemId(null); + } + }; + + const handlePreviewEquipToggle = async () => { + if (!previewItem) { + return; + } + + setPendingItemId(previewItem.id); + + try { + const isEquipped = + previewItem.type === "room" + ? roomBackground === previewItem.id + : equippedItems.includes(previewItem.id); + + if (previewItem.type !== "room" && isEquipped) { + const nextEquipped = await unequipShopItem(previewItem.id); + setEquippedItems(nextEquipped); + toast(`Unequipped ${previewItem.name}.`, "default"); + } else if (!isEquipped) { + const nextEquipped = await equipShopItem(previewItem.id); + setEquippedItems(nextEquipped); + applyOwnedItemToEditor(previewItem); + toast(`Equipped ${previewItem.name}.`, "success"); + setPreviewItem(null); + } + + await onShopDataChange?.(); + } catch (error) { + toast( + error instanceof Error ? error.message : "Could not equip item.", + "error", + ); + } finally { + setPendingItemId(null); + } + }; + + const previewStyleLayerId = + previewItem && previewItem.type !== "room" + ? parseShopStyleItem(previewItem.id, previewItem.type)?.layerId + : null; + const activePieceId = + previewStyleLayerId === activeLayerId && previewItem + ? previewItem.id + : variants[activeLayerId]; + const activeRoomId: RoomBackgroundId = + previewItem?.type === "room" + ? normalizeRoomBackgroundId(previewItem.id) + : roomBackground; + const handleSave = async () => { if (!onSave) { return; @@ -186,7 +333,26 @@ export function CharacterCreator({ }; return ( -
+ <> + setPreviewItem(null)} + onPurchase={() => { + void handlePreviewPurchase(); + }} + onEquipToggle={() => { + void handlePreviewEquipToggle(); + }} + /> + +
{showNameField ? (
@@ -205,11 +371,15 @@ export function CharacterCreator({
- +
{isRoomTab ? ( ) : ( )}
@@ -285,5 +457,6 @@ export function CharacterCreator({
+ ); } diff --git a/components/character/character-piece-selector.tsx b/components/character/character-piece-selector.tsx index 15f9f1f..cd8546c 100644 --- a/components/character/character-piece-selector.tsx +++ b/components/character/character-piece-selector.tsx @@ -16,6 +16,7 @@ type CharacterPieceSelectorProps = { color: HSL; skinColor?: HSL; onSelect: (variantId: string) => void; + onLockedSelect?: (variantId: string) => void; }; export function CharacterPieceSelector({ @@ -25,6 +26,7 @@ export function CharacterPieceSelector({ color, skinColor, onSelect, + onLockedSelect, }: CharacterPieceSelectorProps) { const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) => variants[index] ?? null, @@ -74,20 +76,25 @@ export function CharacterPieceSelector({ )} + + {showCustomizeLink ? ( + + ) : null}
diff --git a/lib/shop-catalog.ts b/lib/shop-catalog.ts index 5216735..024f17f 100644 --- a/lib/shop-catalog.ts +++ b/lib/shop-catalog.ts @@ -94,3 +94,10 @@ function getDefaultPriceForLayer(layerId: ShopLayerId, variantId: string) { export function getLayerLabel(layerId: ShopLayerId) { return CHARACTER_LAYERS.find((layer) => layer.id === layerId)?.label ?? layerId; } + +export function findShopItemById( + items: readonly ShopItemRecord[], + itemId: string, +): ShopItemRecord | undefined { + return items.find((item) => item.id === itemId); +}