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